Nigeria only · Evaluation release

CASCADE

Cartridges are finite. This decides who gets tested today — and makes sure the test turns into care.

Pre-calibration build · Phase 0 in preparation
For use within an approved evaluation protocol

The problem is no longer finding people

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.

458,534
Cases notified in 2025, against ~510,000 estimated
6–8%
Share of national TB programme need historically met from domestic financing
50.4%
Median share of confirmed TB that was subclinical across 28 prevalence surveys — invisible to symptom screening

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.

Three surfaces, one engine

SurfaceWho it servesThe question it answers
Triage channelThe person who walked in unwell, or a CHEW sitting with themWhat do I do next, and where do I go?
Allocation consoleWhoever is spending today's cartridgesWho gets tested today?
Cascade trackerThe facility, over the following weeksDid 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.

What CASCADE deliberately does not do

This list is the product as much as the feature list is.

For the health worker using it

There is a separate guide written for you — eight pages, no technical language, covering how to go through the questions with someone, what the result card means, how to follow people up, what to do in awkward situations, and when to ignore the tool entirely. How to use it (PDF). Print it; it is meant to be carried.

A tool earns trust by being clear about its job, not by claiming to do more than it does. So, plainly:

Decides
Who should be tested first when there are not enough cartridges for everyone, and what each person should do next
Does not decide
Whether anyone has TB. Only the laboratory does that. It will never tell a person they have TB, and never tell them they do not
Never overrules you
A red flag beats the queue every time. If someone in front of you looks unwell, send them — the ranking does not get a vote

Things you can rely on

Why the queue shows “Highest” and not “68%”. A percentage looks like a measurement, and this model has not yet been fitted to Nigerian data, so a percentage would be claiming more than it can support. The order is built from risk factors that are well established — a cough lasting two weeks, weight loss, a household contact, being underweight — and that order is what the tool is actually for. When Phase 0 is complete and the numbers mean what they say, they go back on the screen.

Where this sits on the evidence pathway

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.

Why the queue shows a rank and not a percentage. Calibration affects the probability values; it does not necessarily affect the order. A model built from established tuberculosis risk factors — cough duration, weight loss, household contact, BMI, HIV status — ranks people usefully before those values are fitted to local data. CASCADE's output is an ordering, so the interface shows the ordering and withholds the percentages until Phase 0 justifies them. The arithmetic stays available to any supervisor who asks for it.
AreaStateDetail
Allocation engineBuiltDeterministic, offline, interpretable. Weights are provisional priors
Triage channelBuiltEnglish only; no audio
Cascade trackerBuiltState machine, SLA windows, enumerated loss reasons
Database & RLSBuilt & tested12 tables, 24 policies, adversarial suite passing
StorageBuilt & testedFacility-scoped, private bucket, admin-only deletion
PulmIQ bridgeBuiltWritten; not yet deployed to the pulmiq project
Clinical validationPhase 0 nextRefit on Nigerian register and GxAlert data. Until then the interface shows rank, not probability
Field testingScheduledDesign rules in the triage channel are reasoned, not yet observed in use. First on the roadmap
LanguagesEnglish onlyHausa, Yoruba, Igbo and Pidgin require clinician translation with back-translation
Offline syncNoneThe engine runs offline; nothing syncs afterwards
Walk-in modeBy design, nextThe engine ranks a batch. Walk-in clinics need a threshold mode — see the red team
RegulatoryNot startedNAFDAC pre-submission and a retention period agreed with NTBLCP
Recall deliveryPartialMessages are composed; nothing sends them yet
Three things would stop this project, and they are written down rather than discovered later. Phase 1 failing the WHO target product profile, in which case we publish and stop. The batch-versus-walk-in objection being right, in which case the engine is rebuilt around thresholds. Or number-needed-to-test being gamed in a pilot — it improves if you test fewer, higher-probability people, so it must never be reported without absolute case-finding beside it. All three are written up in the red team document.

Operator guide

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.

  1. What you need first

    • A Supabase project, in a Nigerian or nearest-region data centre — NDPA 2023 expects data residency
    • A Netlify account connected to the repository
    • Node 20 or later, if you are building locally
    • An Anthropic API key — optional; the AI features are assistive and everything works without it
    You do not need a CAD vendor, a telehealth account or a messaging provider to get running. Add those later.
  2. Set up the database

    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.

    Order matters. 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.
  3. Create the storage bucket

    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.

    Never make this bucket public. It holds chest X-rays and laboratory results belonging to identifiable people in small communities. The SQL re-asserts public = false on every run precisely so an accidental toggle in the dashboard is undone the next time you deploy.
  4. Deploy to Netlify

    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/.

    Every file must sit at the repository root. Both build entry points — 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.
    Do not set 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.
  5. Environment variables

    Site configuration → Environment variables. Nothing here is optional except where marked.

    VariableValueNotes
    NODE_VERSION20
    VITE_SUPABASE_URLProject URLPublic by design
    VITE_SUPABASE_ANON_KEYAnon keyPublic by design — RLS is what protects the data
    SUPABASE_URLSame URLServer side
    SUPABASE_ANON_KEYSame anon keyUsed to verify caller tokens
    SUPABASE_SERVICE_ROLE_KEYService role keyNever with a VITE_ prefix
    ALLOWED_ORIGINYour site URL
    ANTHROPIC_API_KEYYour keyOptional — enables AI intake and briefing
    VITE_AI_PROXY_URL/api/aiOptional, with the above
    ENGINE_SHARED_SECRETMesh secretNeeded for the PulmIQ bridge
    The 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.
    Redeploy after adding variables. Vite reads 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.
  6. Create your first users

    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.');
    RoleScopeCannot see
    chewOne facilityAny other facility
    facility_officerOne facility, plus cartridge allocation and closing lossesAny other facility
    lab_techOne testing siteThe assessment — no triage answers, no HIV status, no risk factors
    dots_officerOne treatment centreThe assessment
    state_officerOne state, read onlyAssessments and individual documents; cannot change anything
    adminEverything—
    A facility-scoped role saved without a facility would silently see nothing, so the database refuses the row outright rather than creating a user who quietly cannot work.
  7. Load your real facilities

    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.

  8. Day-to-day use, by role

    Community health worker

    1. Open the triage channel. Work through it with the person, one question per screen.
    2. Read them the outcome card. It gives one action, what it costs, and what to bring.
    3. If they are authorised for a test, collect the specimen and mark it in the tracker.
    4. When a result slip comes back, record it as a document. It advances the cascade automatically.
    A red flag — coughing blood, breathless at rest, chest pain, too unwell to eat or drink — stops the interview immediately and sends the person for same-day care. Above the testing line it adds a clinician as well as the specimen; below the line it overrides the deferral entirely.

    Facility officer

    1. Set today's cartridge count in the console. The authorisation line moves and the queue reorders.
    2. Check the overdue count in the tracker each morning — anything past its window needs a human today.
    3. Close anything genuinely lost, with the real reason.
    The loss reason is the most valuable field in the system. "Lost to follow-up" tells a programme manager nothing they can act on. "Could not afford transport" tells them to fund transport. Most of the reasons on that list name something a programme can pay for — which is why it is a fixed list and not a free-text box.

    Laboratory

    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.

    DOTS officer

    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.

    State programme officer

    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.

  9. Wire CASCADE into PulmIQ

    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.

    That file is CommonJS, because Firebase functions are. It will not load inside a Vite repo whose package.json has "type": "module" — you get module is not defined in ES module scope, which surfaces as a 502.
  10. Test your security

    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.

    Run this after any policy change. Two real bugs have already been caught this way, and both looked like working security — one made every user see nothing, the other made the append-only audit trail impossible to append to.
  11. When things break

    SymptomCauseFix
    type "cascade_state" already existsAn older, non-idempotent copy of the schemaUse the current cascade_schema.sql; it guards every enum
    permission denied for schema appGrants did not run — usually the RLS file was truncated or run out of orderRe-run cascade_rls.sql whole
    permission denied for sequenceMissing usage on a bigserial sequenceRe-run cascade_rls.sql; the grant is in it
    A save reports success but nothing changedRLS refused it. A denied UPDATE returns zero rows, not an errorEvery write must go through writeGuarded(). Never call .update() directly
    AI panels say "AI off" with a key setVite reads VITE_* at build timeRedeploy
    Function returns 502CommonJS .js in an ESM repo, or a call over the 10-second ceilingCheck module syntax first; then check for a slow upstream call
    Uploads fail on a big X-rayBytes routed through a functionThey must not be — the browser PUTs straight to storage from a signed URL
    Site-wide 404, no functions runFiles nested one folder deep in the repoindex.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 GitHubAlready fixed — the console entry is app.html at the root. If you see this, you are on an older copy
  12. Before any pilot

    None of these is optional, and none is currently done.

    • Field test with CHEWs. Every design rule in the triage channel is an untested hypothesis. This outranks everything else on the roadmap.
    • Translate. Hausa, Yoruba, Igbo, Pidgin — by clinicians, with back-translation. The question wording is the instrument; a bad translation is a bad test.
    • Phase 0 calibration. Until the model is refitted on Nigerian data, every probability on every screen is illustrative.
    • Replace the handoff log sink with a real datastore. A lost handoff is a lost patient, and logs rotate.
    • Set a retention period with NTBLCP. NDPA 2023 expects one, and TB has a long clinical tail.
    • Engage NAFDAC. This is software as a medical device.
    • Check policy performance at a realistic row count. app.sees_pseudonym() runs on every storage access.