Data Cohorts — discovery record, decisions, and proposed design

Date: 2026-09-21, revised 2026-09-22 · Owner: Moe (data cohorts) · Sibling document: TRE design (owner Yahia) at https://cohorts-tre-design.pages.dev · Status: design only, no code.

0. Ownership split (2026-09-22)

The data cohorts product (catalogue, search, pricing, cart, checkout, orders, allocation, cohort files) and the TRE (the locked workspace the purchased data is mounted into) are two separate entities with no overlap. Moe owns cohorts; Yahia owns the TRE. They meet at one contract (§5.6): cohorts hands the TRE a provisioning request; the TRE hands back a workspace id and honours lock/unlock. Neither side reaches into the other's tables.

1. Context

bionl is building a cohort marketplace: researchers browse patient cohorts, configure a data package (patients × data types × access duration), pay, and receive the purchased data inside a Trusted Research Environment. A backend (data-cohorts-server, NestJS, built Feb–Apr 2026) and a frontend (data-cohorts-frontend, TanStack Start, Rami) exist as standalone repos and cover browse → price → cart. Nothing exists for checkout, purchase, file delivery, or the TRE hand-off. The cohorts work is to (a) complete browse → buy → hand-off end to end and (b) move both apps into the bionl nx monorepo the way sentinel and IMS were.

2. Current state (verified against code, not docs)

2.1 data-cohorts-server (branch patient-cohorts-poc, 91 commits, Feb 8 → Apr 2 2026)

2.2 data-cohorts-frontend (github.com/bionl/data-cohorts-frontend, Rami, deployed to cohorts.bionl.ai on Vercel)

2.3 bionl monorepo facts that change the plan

3. Decision log (Moe; 2026-09-21, revised 2026-09-22)

# Decision
D1 Journey for this iteration: Buy → automatic TRE provisioning. No human review gate now (ClickUp 5.2–5.4 later).
D2 Payment provider: Frame (already integrated in sentinel). Keep provider pluggable.
D3 Organization owns cart, purchases, and the resulting TRE.
D4 Data sources per region are unknown; design region-generic. Files are not in GCS anywhere yet. MIMIC is disregarded (not a source, not demo data; its ingest path and placeholder genomics are out of scope).
D5 Two regions today (KSA / US). A cohort belongs to exactly one region; region travels with the cohort into every downstream object (bucket, order, TRE).
D6 (revised) One TRE per (organization, cohort). A top-up purchase of the same cohort by the same org (e.g. 10 samples, later 100 more) adds to the existing cohort TRE; the TRE's expiry is NOT extended — it expires on the first purchase's date. Different cohorts → different TREs, even in the same region.
D7 The TRE is an existing bionl Workspace flagged kind = tre (TRE-side detail in the sibling doc).
D8 Files live in a platform cohort bucket per region; a purchase is delivered as a read-only mount segmented at file level: a buyer gets N of M files, assigned randomly once at purchase.
D9 Purchase unit = patients, per data type: e.g. 500 of 3000 patients have CT; buyer buys 5 CT → 5 random patients among those 500.
D10 (revised) On expiry the TRE is locked, not deleted: all users of the org are denied access to that workspace; data stays. (Enforced TRE-side; cohorts only flips the order to expired and sends the lock.)
D11 Backend lands as new nx app apps/cohorts, NestJS kept, mirroring apps/sentinel; shared bionl DB via @bionl/prisma.
D12 Frontend: bring Rami's app into the monorepo as apps/cohorts-web, keep its stack, Vercel deploy like web, keep the code handshake with lab.
D13 Rami owns FE work; Claude delivers BE + typed contracts + an FE integration brief.
D14 Post-purchase UX: Purchases page in the cohorts app with an "Open TRE" deep link into lab.
D15 Definition of done: dev demo end to end (browse → configure & price → Frame sandbox payment → order recorded → provisioning request accepted by the TRE → open the TRE and see the files).
D16 Demo data: Synthea cohorts staged into a dev cohort bucket; manifest from per-patient CSVs (no genomics).
D18 Elasticsearch hosting: decide later.
D19 Admin ops (ingest, price book, cohort publishing): decide later.
D20 (revised) Who may buy and who enters the TRE: Org Owner + RBAC (the RBAC system of PR #429). Purchase = org owner or a role granted cohorts.purchase; TRE entry = TRE-side rule using the same RBAC.
D21 Output: shareable design docs (this one + the TRE one).

4. Open decisions (cohorts side)

  1. Top-up after expiry: if the first purchase has expired (TRE locked) and the org buys the same cohort again — new TRE, or unlock the old one with a new expiry? Proposal: unlock and set expiry from the new purchase (the "first purchase" rule applies only while the TRE is active).
  2. File-level segmentation mechanism (D8/D9): per-purchase materialised prefix (server-side copy) vs object-level allowlist; drives bucket layout and what the TRE mounts. Proposal in §5.4.
  3. Cohort entity ownership & publishing (D19): who marks a cohort published, provider, access level, "contact us" pricing.
  4. Partner file handover: how a hospital delivers files + manifest into the regional cohort bucket (landing bucket + validation vs direct transfer). Relates to the KHCC 100-patient task.
  5. ES hosting per region and reindex strategy (D18); KSA residency for patient-level metadata.
  6. Pricing source of truth: DB-driven price book (CohortPricingConfig exists) vs constants; currency beyond USD.
  7. Sample-lock semantics: one random set per cohort today vs D9's per-data-type samples; "exact match" (patients having ALL selected types) vs independent per-type samples.
  8. RBAC permissions to define with PR #429: cohorts.purchase, cohorts.manage (admin), cohorts.review (future access requests).

5. Proposed target architecture (cohorts side)

5.1 Deployables

5.2 Data model additions (bionl DB, owned by apps/cohorts)

5.3 Purchase flow

  1. Checkout: apps/cohorts freezes the cart into Orders (one per cohort), asks sentinel for a Frame charge intent (org customer), returns clientSecret.
  2. FE confirms with Frame.js; sentinel webhook charge_intent.succeeded → S2S orders/:id/paid on apps/cohorts (idempotent).
  3. Allocation: per order item, per data type, sample N random subjects among those that have the type (crypto-random, once), resolve file keys from CohortFile, persist OrderAllocation.
  4. Materialise: same-bucket server-side copy of allocated objects into purchases/<orderId>/<dataType>/<subjectId>/… (region-local, no egress). Alternative if copies are unacceptable: object-level allowlist consumed by the TRE mount layer (open decision 2).
  5. Hand-off (§5.6): if no TreLink(org, cohort) → send provision with the first prefix and expiresAt; else send add-mount to the existing TRE (top-up; expiry unchanged).
  6. Order → active; purchases page polls; "Open TRE" deep-links to the workspace id the TRE returned.
  7. Expiry job: at TreLink.expiresAt → send lock; all orders under the link → expired. Data and materialised prefixes are kept per the retention policy (D10).

5.4 File segmentation (proposal for open decision 2)

Materialised per-order prefix, read-only mount by the TRE. Rationale: gcsfuse cannot express a random object set; a same-bucket rewrite is metadata-fast and keeps the cohort master untouched; revocation and proof of deletion are a prefix operation. Cost: duplicated bytes per purchase (bounded by sample size).

5.5 Region model

Cohort carries region; cohort bucket per region; an order inherits the cohort's region; the TRE inherits the order's region. Nothing in cohorts ever combines two cohorts, so cross-region mixing cannot arise on this side.

5.6 Contract with the TRE (the only coupling)

Cohorts → TRE (S2S, idempotent, region-aware):

6. Debt to fix while landing (no product change)

Remove token logging; catch the fire-and-forget promises; guard migrate-search and ingest behind admin/S2S auth; drop GraphQL/Bull/mailer/AWS/JWT scaffolding; stop baking DATABASE_URL into images; lock ES/Kibana to the VPC; align FE hardcoded presets/discounts with server values (serve them from the API); fix the FE requireAuthMiddleware TODO; unit tests around allocation + pricing + ES query builder; docs refresh (auth section is wrong).

7. Phased plan (cohorts side; sizes rough)

8. Verification for D15

Dev: run nx serve cohorts + cohorts-web locally against dev ES/Redis; ingest a Synthea cohort with manifest; sign in via the lab handshake; configure 5 patients × 2 data types × 3 months; pay with a Frame sandbox card; confirm Order=active, OrderAllocation rows, materialised prefix objects, TreLink with the returned workspace id; buy 5 more of the same cohort → addMount sent, expiresAt unchanged; advance expiresAt, run the expiry job → lock sent and orders expired.

9. SATRE statements that land on the cohorts side

The TRE doc carries the full SATRE mapping; these statements are satisfied by cohorts-side features and are tracked here: 1.3.02 (purpose/DPIA capture at purchase), 1.4.01/1.4.02 (licence acceptance, funding = payment, expiry enforcement), 1.4.06 + 1.7.02 (public Safe-Projects register built from Order), 1.5.04 (Data Controller agreement per cohort: Cohort.accessLevel + licence), 3.1.03 (Cohort.sensitivityTier set by the provider), 3.1.04 + 3.1.20 (cohort ingress validation/approval, metadata disclosure check), 3.1.08 (Cohort as the record of data held), 3.1.13 (minimum data: only bought files materialised), 3.3.03 (intended outputs at purchase), 3.4.01–3.4.04 (catalogue, metadata model, query UI, synthetic previews), 4.4.01 (costs visible before purchase), 2.3.01 (TRE resources/costs shown on the purchase page — content from the TRE doc).

10. Next steps

  1. CTO/Yahia: agree the §5.6 contract and D6/D10 semantics (top-up, lock).
  2. Define the RBAC permissions (open decision 8) with PR #429.
  3. Start P1 on feat/cohorts-monorepo when Moe gives the go.