Skip to content

feat(sensitiveNetworks): TAM-6888: scope sync lookup and outgoing snapshots by sensitive network - #10944

Open
chris-bes wants to merge 22 commits into
workhorse/u6from
workhorse/v6
Open

chris-bes wants to merge 22 commits into
workhorse/u6from
workhorse/v6

Conversation

@chris-bes

@chris-bes chris-bes commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Changes

Add a brief description of the changes in this PR to help give the reviewer context.

Auto-Deploy

  • Deploy
Options
  • Artillery load test
  • Seed from closest snapshot
  • Generate fake data
  • More data (20Gi)
  • No facility servers (central-only)
  • No sync (facility tasks scaled to zero)
  • Skip mobile build
  • Always build mobile
  • Stay up for 8 hours
  • Stay up for 24 hours
  • Stay up (no TTL)
  • Build images only (don't deploy)
  • Build all images (amd64 + Windows; default is arm64 only)
  • Pause this deploy

Tests

  • Run E2E tests
  • Run DAST scan

Review Hero

  • Run Review Hero
  • Auto-fix review suggestions Wait for Review Hero to finish, resolve any comments you disagree with or want to fix manually, then check this to auto-fix the rest.
  • Auto-fix CI failures Check this to auto-fix lint errors, test failures, and other CI issues.
  • Auto-merge upstream Check this to merge the base branch into this PR, with AI conflict resolution if needed.
  • Save suppressions Check this to capture 👎 reactions on Review Hero comments as suppression rules in .github/review-hero/suppressions.yml. Also runs automatically at the end of any auto-fix run.

Remember to...

  • ...write or update tests
  • ...add UI screenshots and testing notes to the Linear issue
  • ...add any manual upgrade steps to the Linear issue
  • ...update the config reference, settings reference, or any relevant runbook(s)
  • ...call out additions or changes to config files for the deployment team to take note of

@chris-bes chris-bes changed the title V6: Sync lookup: population + snapshot filtering for sensitive networks feat(sync): TAM-6888: scope sync lookup and snapshots by sensitive network Aug 29, 2026
@chris-bes chris-bes changed the title feat(sync): TAM-6888: scope sync lookup and snapshots by sensitive network feat(sensitiveNetworks): TAM-6888: scope sync lookup and outgoing snapshots by sensitive network Aug 29, 2026
@chris-bes
chris-bes changed the base branch from main to workhorse/u6 August 29, 2026 06:07
@chris-bes
chris-bes requested a review from a team as a code owner August 29, 2026 06:07
@chris-bes
chris-bes changed the base branch from workhorse/u6 to main August 29, 2026 06:22
@chris-bes
chris-bes changed the base branch from main to workhorse/u6 August 29, 2026 06:27
@chris-bes
chris-bes removed request for a team, dannash100, edmofro and passcod August 29, 2026 06:27
@chris-bes
chris-bes removed the request for review from rohan-bes August 29, 2026 06:27
chris-bes and others added 6 commits September 2, 2026 12:45
…resh dbt models

sync_lookup.sensitive_network_id is a uuid column, but the model declared it
STRING. Generated test data then produced values like
"SyncLookup.sensitiveNetworkId.<uuid>", which Postgres rejects, failing
snapshotOutgoingChanges.syncLookup tests at insert.

Also regenerates the dbt source models for the network schema: uuid for the id
and both sensitive_network_id columns, text for code and name, the not_null
tests on the network timestamps, and the trigger list on sensitive_networks.

Both issues come from the sensitive network schema on the base branch rather
than from this card's snapshot changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making SyncLookup.sensitiveNetworkId a UUID stopped generated rows failing the
column's type, but fake() then filled it with a random network id. The outgoing
snapshot admits a row only when facility_id and sensitive_network_id are both
null, so every faked lookup row became scoped to a network no facility belongs
to and was withheld - failing six snapshotOutgoingChanges cases that set
facility_id null to mean unscoped.

Default the column to null in MODEL_SPECIFIC_OVERRIDES rather than adding it to
each fake() call; tests that want a network still pass one, since test-specific
overrides merge last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sensitive network ids became string ids rather than uuids, so the three columns
carrying them are character varying again. Points the dbt source models back at
that, and restores SyncLookup.sensitiveNetworkId to STRING alongside facilityId.

The UUID declaration was only ever there to stop generated test data failing the
column's type; the fake-data default that leaves a lookup row unscoped is what
actually fixes that, and it holds whatever the column type is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
),
${patientId || 'NULL'},
${facilityId || 'NULL'},
${sensitiveNetworkId || 'NULL'},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Design & Architecture] suggestion

This adds a second UUID/string column that is matched by position to the INSERT column list in packages/central-server/app/sync/updateLookupTable.js, across two packages, immediately adjacent to the column it is most likely to be confused with (facility_id). The PR's own plan names this as a failure mode: transposing the two compiles, runs, and scopes every sensitive record to a facility id that is really a network id. Now is a good moment to remove the coupling rather than widen it — export the ordered column list from the database package and have buildLookupUpsertQuery build its INSERT list (and its ON CONFLICT DO UPDATE list) from it, so order and membership have one definition.

// population logic it is mirroring — Notification included, whose joins reach encounters through
// its metadata.
const getEncounterScopedRecordTypes = async (query: QueryInterface) => {
const models = getModelsForPull(query.sequelize.models as any);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Design & Architecture] suggestion

The migration derives the tables it rewrites from the live model registry (getModelsForPull + buildSyncLookupQueryDetails) rather than from a pinned list. A migration is a fixed point in the schema's history, but this one's effect changes every time a model is added, renamed, or has its lookup joins reworked — a deployment upgrading six months from now rescopes a different set of record_types than a deployment upgrading today, and the two DBs silently diverge. It also couples a migration file to application query-building code (buildSyncLookupQueryDetails even hits the DB per model via LocalSystemFact.isLookupRebuildingModel, ~50 round-trips, just to read joins), so any future refactor of that helper can break an already-shipped migration. The immediate precedent, 1785372544730-RebuildLookupTableForSensitiveFacilityScopedModels.ts, hardcodes MODELS_TO_REBUILD for exactly this reason. Prefer generating the list once and inlining it as a literal, with the drift guarded by the existing registry test rather than at migration time.

for (const model of Object.values<any>(models)) {
const { joins } = (await model.buildSyncLookupQueryDetails({})) ?? {};
if (model.tableName === 'encounters' || /JOIN\s+encounters\b/.test(joins ?? '')) {
recordTypes.push(model.tableName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Design & Architecture] suggestion

The "is this model encounter-linked" predicate (tableName === 'encounters' || /JOIN\s+encounters\b/.test(joins)) is now duplicated verbatim between this migration and packages/database/__tests__/sync/syncLookupFacilityScope.test.ts. It is load-bearing in both places — the test uses it to decide what must be network-scoped, the migration uses it to decide what to rewrite — and the two are meant to be the same set by construction, yet nothing ties them together. If the regex has to change (e.g. a model joins encounters via a CTE or an alias), one copy will be updated and the other won't, and the failure is silent in the migration. Extract a single exported helper next to the lookup builders and have both call it.

facilityIds,
deviceId,
{}, // sending empty session config because this snapshot attempt is only for syncing new marked for sync patients
// only the network scoping carries over; this snapshot is just for newly marked for

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Integration tests] suggestion

The newly-marked-for-sync-patients snapshot now passes { sensitiveNetworkIds } as its whole session config, but no test exercises that pass with a networked facility. Every test in CentralSyncManager.sensitiveFacilities.test.js (including the new sharing within a network block) creates no PatientFacility rows for the network facilities, so newPatientFacilitiesCount is 0 and this snapshotOutgoingChanges call inserts nothing — the coverage all lands on the regular/incremental call below. If the network ids were ever dropped from this config, a facility that has just marked a patient for sync would silently miss that patient's entire network history on the full-sync pass, and the suite would stay green. The PR's own test-case list flags this (A facility receives its network's data only for patients it marks for sync is unticked). Add a case in the new sharing within a network describe that creates a PatientFacility for the sibling with updatedAtSyncTick > since, pulls with since below that tick, and asserts the network's encounters arrive on the full-sync pass.

export async function down(query: QueryInterface): Promise<void> {
if (!(await hasNetworkedFacility(query))) return;

const encounterScopedRecordTypes = await getEncounterScopedRecordTypes(query);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BES Requirements] critical

down cannot restore rows belonging to a multi-member network (the HAVING COUNT(*) = 1 filter skips them), and leaves them with facility_id = NULL and sensitive_network_id set. Under the reverted code the snapshot clause is back to facility_id IS NULL meaning "unscoped" — so every one of those confidential encounter rows is admitted to every facility until the lookup happens to be rebuilt. That is a confidentiality regression on rollback, not just an incomplete restore. At minimum down should also SET sensitive_network_id = NULL only where it successfully restores a facility, and NULL out nothing otherwise — or flag those models for a lookup rebuild so they re-materialise under the old logic immediately. Per packages/database/CLAUDE.md this down also needs a // DESTRUCTIVE: comment spelling out what is not restored; the current prose comment doesn't use the marker and understates the consequence ("the old population logic would rebuild them" only happens on the next write to each source record).

@review-hero

review-hero Bot commented Sep 16, 2026

Copy link
Copy Markdown

🦸 Review Hero Summary (round 1)
15 agents reviewed this PR | 1 critical | 4 suggestions | 0 nitpicks | Filtering: consensus 3 voters, 7 below threshold, 4 suppressed

Below consensus threshold (7 unique issues not confirmed by majority)
Location Agent Severity Comment
database/model/public/sensitive_networks.yml:41 BES Requirements suggestion The unique data_tests on code and name were dropped, but the schema still enforces uniqueness — 1787600000000-createSensitiveNetworks.ts creates sensitive_networks_code_unique and `sensit...
packages/central-server/__tests__/sync/CentralSyncManager.sensitiveFacilities.test.js:1163 Integration tests nitpick In syncs a facility's encounters to its sibling in the same network, the child record (the note) is verified by reading its sync_lookup row rather than by pulling it. That checks population but...
packages/central-server/app/sync/CentralSyncManager.js:538 BES Requirements critical The comment asserts a guard this path doesn't have. setupSnapshotForPull receives facilityIds from its params argument, and the pull/initiate handler reads that list straight from the reque...
packages/central-server/app/sync/snapshotOutgoingChanges.js:275 Integration tests nitpick The admission clause was rewritten and the new suite pins the unscoped branch, the network branch and the fail-open case, but nothing added here pins the plain facility_id in (:facilityIds) branc...
packages/central-server/app/sync/snapshotOutgoingChanges.js:316 Design & Architecture nitpick ...(sensitiveNetworkIds?.length ? { sensitiveNetworkIds } : {}) is redundant: Sequelize only objects to replacement keys referenced by the SQL and missing from the bag, not to unused extras, so t...
packages/database/src/migrations/1787700000000-rescopeSyncLookupToSensitiveNetworks.ts:31 Performance nitpick This loop calls buildSyncLookupQueryDetails for every pullable model sequentially, and each call reaches buildSyncLookupSelect, which awaits LocalSystemFact.isLookupRebuildingModel — a separa...
packages/fake-data/src/fake/fake.ts:668 BES Requirements nitpick The comment says "a lookup row is unscoped unless a test deliberately scopes it", but SyncLookup.facilityId has no references in the model definition, so fakeField still generates a random st...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`packages/database/src/sync/buildSyncLookupSelect.ts:52`: This adds a second UUID/string column that is matched by *position* to the INSERT column list in `packages/central-server/app/sync/updateLookupTable.js`, across two packages, immediately adjacent to the column it is most likely to be confused with (`facility_id`). The PR's own plan names this as a failure mode: transposing the two compiles, runs, and scopes every sensitive record to a facility id that is really a network id. Now is a good moment to remove the coupling rather than widen it — export the ordered column list from the database package and have `buildLookupUpsertQuery` build its INSERT list (and its `ON CONFLICT DO UPDATE` list) from it, so order and membership have one definition.

-------

`packages/database/src/migrations/1787700000000-rescopeSyncLookupToSensitiveNetworks.ts:25`: The migration derives the tables it rewrites from the live model registry (`getModelsForPull` + `buildSyncLookupQueryDetails`) rather than from a pinned list. A migration is a fixed point in the schema's history, but this one's effect changes every time a model is added, renamed, or has its lookup joins reworked — a deployment upgrading six months from now rescopes a different set of `record_type`s than a deployment upgrading today, and the two DBs silently diverge. It also couples a migration file to application query-building code (`buildSyncLookupQueryDetails` even hits the DB per model via `LocalSystemFact.isLookupRebuildingModel`, ~50 round-trips, just to read `joins`), so any future refactor of that helper can break an already-shipped migration. The immediate precedent, `1785372544730-RebuildLookupTableForSensitiveFacilityScopedModels.ts`, hardcodes `MODELS_TO_REBUILD` for exactly this reason. Prefer generating the list once and inlining it as a literal, with the drift guarded by the existing registry test rather than at migration time.

-------

`packages/database/src/migrations/1787700000000-rescopeSyncLookupToSensitiveNetworks.ts:34`: The "is this model encounter-linked" predicate (`tableName === 'encounters' || /JOIN\s+encounters\b/.test(joins)`) is now duplicated verbatim between this migration and `packages/database/__tests__/sync/syncLookupFacilityScope.test.ts`. It is load-bearing in both places — the test uses it to decide what must be network-scoped, the migration uses it to decide what to rewrite — and the two are meant to be the same set by construction, yet nothing ties them together. If the regex has to change (e.g. a model joins encounters via a CTE or an alias), one copy will be updated and the other won't, and the failure is silent in the migration. Extract a single exported helper next to the lookup builders and have both call it.

-------

`packages/central-server/app/sync/CentralSyncManager.js:585`: The newly-marked-for-sync-patients snapshot now passes `{ sensitiveNetworkIds }` as its whole session config, but no test exercises that pass with a networked facility. Every test in `CentralSyncManager.sensitiveFacilities.test.js` (including the new `sharing within a network` block) creates no `PatientFacility` rows for the network facilities, so `newPatientFacilitiesCount` is 0 and this `snapshotOutgoingChanges` call inserts nothing — the coverage all lands on the regular/incremental call below. If the network ids were ever dropped from this config, a facility that has just marked a patient for sync would silently miss that patient's entire network history on the full-sync pass, and the suite would stay green. The PR's own test-case list flags this (`A facility receives its network's data only for patients it marks for sync` is unticked). Add a case in the new `sharing within a network` describe that creates a `PatientFacility` for the sibling with `updatedAtSyncTick > since`, pulls with `since` below that tick, and asserts the network's encounters arrive on the full-sync pass.

-------

`packages/database/src/migrations/1787700000000-rescopeSyncLookupToSensitiveNetworks.ts:73`: `down` cannot restore rows belonging to a multi-member network (the `HAVING COUNT(*) = 1` filter skips them), and leaves them with `facility_id = NULL` and `sensitive_network_id` set. Under the reverted code the snapshot clause is back to `facility_id IS NULL` meaning "unscoped" — so every one of those confidential encounter rows is admitted to *every* facility until the lookup happens to be rebuilt. That is a confidentiality regression on rollback, not just an incomplete restore. At minimum `down` should also `SET sensitive_network_id = NULL` only where it successfully restores a facility, and NULL out nothing otherwise — or flag those models for a lookup rebuild so they re-materialise under the old logic immediately. Per `packages/database/CLAUDE.md` this `down` also needs a `// DESTRUCTIVE:` comment spelling out what is not restored; the current prose comment doesn't use the marker and understates the consequence ("the old population logic would rebuild them" only happens on the next write to each source record).

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.

1 participant