Skip to content

Persist approved cohorts and serve them frozen (#857) - #1066

Draft
atriaybagur wants to merge 6 commits into
developfrom
857-persist-approved-cohorts
Draft

Persist approved cohorts and serve them frozen (#857)#1066
atriaybagur wants to merge 6 commits into
developfrom
857-persist-approved-cohorts

Conversation

@atriaybagur

@atriaybagur atriaybagur commented Aug 26, 2026

Copy link
Copy Markdown
Member

Closes #857.

What

At project approval, each trust now materialises its cohort once and persists it as a file artefact — dataframe.parquet + meta.json per hub project UUID on a dedicated data-access-api bind mount (atomic-rename writes, no TTL). The two row-level routes (/cohort/dataframe, /cohort/accession-ids) serve only that frozen artefact, keyed on the encrypted hub project id, and ignore caller-supplied SQL (hash-compared against the frozen query and logged on drift). No database writes anywhere: the read-only OMOP engine remains data-access-api's only DB connection, and researcher SQL (still pinned to the omop schema on the routes that do execute it) cannot reach a filesystem.

Why (#857 + its security-audit comment)

  • Arbitrary SQL closed: /cohort/dataframe used to execute any SELECT that researcher FL code passed to flip.get_dataframe(project_id, query) — the decrypted project id was only ever logged. The query is now advisory; the only row-level data obtainable under a project id is the cohort that was approved.
  • No silent cohort growth: pre-Cohort membership is re-derived from live OMOP at every stage — no record of the approved cohort #857, the SQL was re-run against live OMOP at every stage, so patients nobody approved could be pulled and trained on as the database grew. The frozen artefact cannot grow.
  • Deterministic training data: Flower apps re-fetch the dataframe every round; live drift used to land mid-run. Every fetch now deserialises an identical frame.
  • Status poll off OMOP: the ~10s imaging status poll read /cohort/accession-ids (full cohort SQL) each time; it now reads the frozen pointer set — stable denominator, no per-poll query.
  • The hub records what was approved: new cohort_snapshot_status row per (project, trust) with row count, query id/hash and the approved-count diff; membership drift between submission and approval is surfaced as a WARNING, never silently adopted. Opt-outs/removals propagate at explicit re-approval (atomic artefact replacement) and via the /cohort/snapshot/delete hook ([Feature]: Explicit administrator purge of Trust imaging for a deleted project #997) — never mid-training.

How

  • data-access-api: services/cohort_snapshot.py (project-UUID-keyed file store; path-safe, atomic, fail-closed), POST /cohort/snapshot (threshold enforced before anything persists; oversize refused, never truncated; validate_query remains the authority on the one query executed), POST /cohort/snapshot/delete; row-level routes refuse a project with no snapshot; a snapshot without accession_id serves an empty accession list, so tabular/OMOP-only projects no-op imaging cleanly.

  • flip-api: TaskType.PERSIST_COHORT queued + committed before CREATE_IMAGING (dispatch orders by created_at, the trust poller is sequential), Alembic revision (enum value + audit table, drift guard green), post-processing in private_services/snapshot_notifications.py.

  • trust-api: handle_persist_cohort forwards to /cohort/snapshot; the snapshot facts ride back verbatim as the task result.

  • imaging-api: all three accession-id fetch paths now send the hub project id (the XNAT project's secondary_ID) — snapshots are keyed on it; the XNAT-minted uuid never matched anything. data-access 403 details are relayed so below-threshold and no-snapshot refusals stay distinguishable.

  • deploy: per-trust COHORT_SNAPSHOT_STORAGE_DIR (kit Host-local profile), bind-mounted as /snapshots in dev/prod compose and pre-created host-user-owned by make up-trust (docker would create it root-owned; the non-root service would then disable the store). Gitignored — it holds row-level patient data.

  • Kubernetes chart: new per-release cohort-snapshots PVC (default 5Gi RWO, dataAccessApi.snapshots.*), mounted at /snapshots with pod fsGroup: 1000 so the non-root image can write a fresh volume — without it the store self-disables and fail-closed serving refuses everything. helm lint + template verified.

  • No backward compatibility (per discussion — no deployments exist yet): the live-SQL serving path on the row-level routes is deleted outright. e2e_smoke --project-id reuse of a pre-snapshot project is refused (documented in the flag's help) — re-approve or create a fresh project.

  • Cohort-admin gate on the write routes: this PR adds a POST /cohort/snapshot write route that defines the artefact everyone trains on. fl-client holds the shared TRUST_INTERNAL_SERVICE_KEY (legitimately — it reads the frozen cohort and pulls imaging), so that key alone can't stop researcher code from rewriting/deleting the frozen cohort. The cohort router is split by privilege: read routes keep the trust-internal gate; the snapshot create/delete routes additionally require cohort-admin — proof of possessing AES_KEY_BASE64 (sent as its SHA-256, constant-time compared), which trust-api + data-access-api hold and fl-client does not. No new secret is provisioned. The broader "give fl-client a least-privilege data-plane key" hardening is tracked in Least-privilege fl-client credential: two-tier trust-internal keys #1067.

  • Hub + UI surfacing: GET /projects/{id}/cohort-snapshots returns each trust's frozen-cohort record, and the project status card shows an "Approved cohort (frozen at approval)" strip per trust — frozen count + date, an amber drift badge when it differs from the approved count (Cohort membership is re-derived from live OMOP at every stage — no record of the approved cohort #857's surface-the-diff requirement), and a "tabular — no imaging" chip explaining an empty imaging panel.

  • Cache-bypass fix: snapshot creation initially read through get_records' 60-day query cache — a re-approval (the opt-out propagation event) would have silently re-frozen the stale frame the statistics run cached at submission. get_records now takes use_cache; snapshot creation passes False (pinned by a poisoned-cache test).

Testing

  • data-access-api: 253 unit + 12 integration (full snapshot lifecycle against real Postgres: freeze → serve-ignoring-SQL → tabular empty list → below-threshold refusal persists nothing → re-approval replace → delete; plus the cohort-admin gate — trust-internal-key-only is refused 403 on both write routes, end-to-end against the container), ruff + mypy.
  • flip-api: 1579 unit + 126 integration incl. the migration drift guard; task-ordering and drift-surfacing tests.
  • trust-api: 108 unit + 8 integration; imaging-api: 280 unit.
  • Live dual-backend e2e on the dev stack (both PASS, RESULTS_UPLOADED): Flower ran the full lifecycle — snapshot artefact written at approval (/snapshots/<uuid>/dataframe.parquet + meta), the ~10s status polls served "300 frozen accession ids" with zero OMOP queries, imaging pulled 599/600 from the frozen set, training fetched the frozen dataframe every round, and the hub recorded cohort_snapshot_status rows (300 frozen = 300 approved, no drift). NVFLARE then reused the same project (--project-id): the image-pull wait cleared instantly off the frozen set and training ran on the identical parquet. GET /projects/{id}/cohort-snapshots verified against the live hub.
  • Drive-by fix: the Testcontainers fixtures now pass build=True — the integration stack used to reuse a stale image, so a dependency change (pyarrow here) silently never reached it and the suite green-lit code that could not run in a fresh build.
  • Drive-by docs fix: security.rst claimed cached results "expire in minutes" (never true — 60-day query-keyed cache); it now describes the frozen-cohort model.

🤖 Generated with Claude Code

At project approval each trust now materialises its cohort ONCE and persists
it as a file artefact (parquet + metadata per hub project UUID, on a dedicated
data-access-api bind mount, atomic-rename writes, no TTL). The two row-level
routes serve ONLY that frozen artefact keyed on the encrypted project id and
ignore caller-supplied SQL (hash-compared and logged on drift). No writes to
the OMOP Postgres anywhere — the read-only engine stays the only DB connection.

Closes the #857 gaps and its audit comment:
- researcher FL code can no longer execute arbitrary SQL under a project id
  (/cohort/dataframe used to run any SELECT it was handed);
- the cohort cannot grow after approval, and every training round fetches
  byte-identical data (Flower re-fetches per round — live drift used to land
  mid-run);
- the imaging status poll (~10s while a project page is open) reads the frozen
  accession set instead of re-running cohort SQL against live OMOP, giving a
  stable denominator;
- the hub finally records what was approved: a cohort_snapshot_status row per
  (project, trust) with row count, query id/hash and the approved-count diff,
  logged as a WARNING when membership drifted between submission and approval.

Mechanics:
- data-access-api: services/cohort_snapshot.py file store; POST /cohort/snapshot
  (validate_query remains the authority; threshold enforced before anything is
  persisted; oversize refused, never truncated) and POST /cohort/snapshot/delete
  (the not-yet-called #997 teardown hook); row-level routes fail closed on a
  missing snapshot; a snapshot without accession_id serves an EMPTY accession
  list so tabular/OMOP-only projects no-op imaging cleanly.
- flip-api: TaskType.PERSIST_COHORT queued + committed BEFORE CREATE_IMAGING
  (dispatch orders by created_at; the trust poller is sequential), Alembic
  revision for the enum + audit table, post-processing via
  snapshot_notifications.py.
- trust-api: handle_persist_cohort forwards to /cohort/snapshot and returns the
  snapshot facts as the task result.
- imaging-api: all accession-id fetches now send the HUB project id (the XNAT
  project's secondary_ID) — snapshots are keyed on it; the XNAT uuid never
  matched anything. 403 details from data-access are relayed so below-threshold
  and no-snapshot refusals stay distinguishable.
- deploy: per-trust COHORT_SNAPSHOT_STORAGE_DIR bind mount (kit Host-local
  profile; pre-created host-user-owned by make up-trust so the non-root service
  can write), wired in dev/prod/test compose.
- tests: snapshot store + route unit suites, end-to-end snapshot lifecycle in
  the data-access integration stack, hub task-ordering + drift tests. The
  Testcontainers fixtures now build=True — the stack used to reuse a stale
  image, so dependency changes (pyarrow here) silently never reached it.
- docs: data-access-api README/CLAUDE/AGENTS, root CLAUDE/AGENTS security
  section, and security.rst — whose old claim that cached results 'expire in
  minutes' was never true (60-day query-keyed cache); it now describes the
  frozen-cohort model honestly.

No backward compatibility kept (no deployments exist yet): the live-SQL serving
path on the row-level routes is deleted outright, so a project without a
snapshot is refused rather than falling back.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
@github-actions github-actions Bot changed the title Persist approved cohorts and serve them frozen Cohort membership is re-derived from live OMOP at every stage — no record of the approved cohort Aug 26, 2026
@atriaybagur atriaybagur changed the title Cohort membership is re-derived from live OMOP at every stage — no record of the approved cohort Persist approved cohorts and serve them frozen (#857) Aug 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@atriaybagur
atriaybagur requested a lite review from Copilot August 26, 2026 21:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Without a snapshot volume the k8s trust's store self-disables and the new
fail-closed row-level serving refuses every project. Adds a per-release PVC
(dataAccessApi.snapshots: size/class/accessMode, default 5Gi RWO to match
replicas: 1), mounts it at /snapshots with COHORT_SNAPSHOT_DIR set, and gives
the pod fsGroup 1000 so the image's non-root user can write a fresh volume —
the chart otherwise avoids fsGroup, but this one is load-bearing. Disabling
dataAccessApi.snapshots.enabled is deliberate refusal of all row-level data.
helm lint + template verified.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
A pre-#857 project has no frozen cohort artefact and the trusts' row-level
routes now refuse it outright; the flag's help says so instead of letting the
reuse flow die downstream with a bare 403.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
GET /projects/{id}/cohort-snapshots returns each trust's cohort_snapshot_status
record (aggregates only), and the project status card shows an 'Approved cohort
(frozen at approval)' strip per trust: frozen record count + snapshot date, an
amber 'drifted — approved on N' badge when the frozen count differs from the
count the project was approved on (#857's surface-the-diff requirement), and a
'tabular — no imaging' chip when the frozen cohort has no accession_id column —
which also explains that project's empty imaging panel. No polling: the frozen
cohort only changes at re-approval. A trust missing from the list has not
reported its snapshot (task pending/failed), matching the fail-closed serving.

flip-ui: CohortSnapshotSummary is its own component with its own useSWRV so the
existing ProjectStatus spec's single-ref swrv mock stays scoped; covered by its
own Vitest spec (written first).

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
/cohort/snapshot read through get_records' 60-day query cache: the statistics
run at submission caches the identical SQL, so a RE-approval — the one event
that must propagate OMOP-side removals/opt-outs into the artefact — would
silently re-freeze the stale pre-removal frame. get_records grows a use_cache
flag (default unchanged); snapshot creation passes False and the fresh read
still updates the cache. Pinned by a poisoned-cache service test and the
router-call assertion.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
The trust-internal key gates every /cohort route, but fl-client legitimately
holds it (it reads the frozen cohort via get_dataframe and pulls imaging), so
that key alone cannot separate reading the approved cohort from DEFINING it.
Split the cohort router by privilege: the read routes keep the trust-internal
gate; the snapshot create/delete routes additionally require cohort-admin —
proof of possessing AES_KEY_BASE64, sent as its SHA-256 (never the key itself)
and constant-time compared. trust-api and data-access-api hold the AES key;
fl-client does not, so researcher training code can no longer rewrite or delete
a project's frozen cohort. A valid trust-internal key without the proof gets 403.

No new secret is minted or distributed — the gate reuses the existing AES-key
possession boundary, so kit files, register_trust, and compose are unchanged.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cohort membership is re-derived from live OMOP at every stage — no record of the approved cohort

3 participants