Skip to content

feat(sync): TAM-6887: import sensitive networks and fix facility membership at creation - #10970

Open
chris-bes wants to merge 15 commits into
workhorse/v6from
workhorse/t6
Open

chris-bes wants to merge 15 commits into
workhorse/v6from
workhorse/t6

Conversation

@chris-bes

@chris-bes chris-bes commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Changes

Extends the sensitive networks spec with the rules for administering networks and guarding facility membership:

  • Documents that networks and facility membership are managed via reference data import, alongside other reference data types: a new networks sheet (id, code, name) plus a network column on the facility sheet, both round-tripped by the reference data export.
  • Spells out import behaviour: networks import before facilities, an empty network cell leaves existing membership untouched, unknown network ids fail the row, and re-importing a network just relabels it.
  • Documents that a membership change (removing a facility from its network, or moving it to another) is refused wherever a facility record is written, not just on the import path, with the refusal named and reported on both import and dry-run validation, and the whole import abandoned if any row would change a facility's network.
  • Clarifies that restoring a deleted facility does not re-enrol it in a network, and that deployments with no confidential data need no network sheet at all.

No code changes yet — this PR only updates the spec.

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 requested a review from a team as a code owner September 2, 2026 03:39
chris-bes and others added 7 commits September 2, 2026 13:53
…ership at creation

Registers sensitiveNetwork as an importable reference data type with its own
sheet, adds a sensitiveNetworkId column to the facility sheet, and refuses any
write that changes an existing facility's network.

The import column matches what the reference data export writes, so an
export/edit/import round trip preserves membership rather than silently
dropping it.

The guard is a Facility instance validator, so it covers the reference data
import and provisioning's facilities block while leaving the schema card's SQL
backfill and incoming sync alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ility

models/index.ts re-exports each model file wholesale and initDatabase treats
every export as a model class, testing `'initModel' in modelClass`. A string
export made that `in` throw, so database init failed for every suite, migration
run and dbt model generation.

The message is now module-local and tests assert its text directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ble cases

SensitiveNetwork.id is a UUID column, so the new importer fixtures' readable
slugs were rejected by Postgres. They now use well-formed UUIDs.

Removes two edge cases from CentralSyncManager.sensitiveFacilities that drove a
facility into and out of a network through the model. Both describe transitions
the membership guard refuses and the spec makes unreachable; the migration path
that can still change membership is covered by the lookup-rescope tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the switch of sensitive_networks.id from uuid to the standard string
primary key. Updates the generated data_type on sensitive_networks.id,
facilities.sensitive_network_id and sync_lookup.sensitive_network_id to match
the live schema, which the dbt-model check compares against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
field: id
- name: sensitive_network_id
data_type: uuid
data_type: character varying(255)

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] suggestion

The dbt model is corrected to character varying(255) here (matching the DDL, which uses DataTypes.STRING), but packages/database/src/models/SyncLookup.ts:38 still declares sensitiveNetworkId: { type: DataTypes.UUID }. Sequelize's UUID type validates on write, so any model-level write carrying a real network id — now readable strings like sensitiveNetwork-srh from the backfill and the new merge step, not UUIDs — fails with "is not a valid uuid". It only stays quiet today because lookup population writes raw SQL and the fake data defaults the column to null. Change the model attribute to DataTypes.STRING so schema, model and dbt agree.

await query.sequelize.query(`
INSERT INTO sensitive_networks (id, code, name)
SELECT 'sensitiveNetwork-' || code, code, name
SELECT 'sensitiveNetwork-' || regexp_replace(code, '[^A-Za-z0-9-]', '', 'g'), code, name

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

Stripping disallowed characters out of the facility code to build the network id can collapse two distinct codes onto the same id (e.g. SRH.1 and SRH-1, or A/B and AB), and a code made only of punctuation yields the bare id sensitiveNetwork-. Both cases make the INSERT fail on the sensitive_networks primary key, which aborts the migration and blocks the whole upgrade for that deployment — and it fails only on deployments that happen to have such codes, so it won't show up in testing. Derive the id from the facility id (already a safe, unique key: 'sensitiveNetwork-' || facilities.id) rather than from a lossy transform of the code, and pair facilities to networks on that instead of on code.

// writes it, and the Facility model refuses a membership change. A blank cell is already absent
// (sheet_to_json is called without defval), but an explicit empty string survives as '', so
// transform it back to undefined. Deliberately no default, for the same reason.
sensitiveNetworkId: yup.string().transform(value => (value === '' ? undefined : value)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Bugs & Correctness] suggestion

sensitiveNetworkId has no existence check before the insert, so a typo'd or unknown network id reaches Postgres and fails as a foreign-key violation rather than a row-level validation error. FOREIGN_KEY_SCHEMATA.Facility (referenceDataImporter/sheet.js) only resolves catchment, and the raw column name never matches findFieldName, so the value passes straight through to Model.create. Two consequences: the administrator sees a raw insert or update on table "facilities" violates foreign key constraint ... wrapped in an UpsertionError instead of the "valid foreign key expected in column ..." message every other FK column produces; and because a constraint violation aborts the surrounding Postgres transaction, every row processed after it in the same import fails with current transaction is aborted, burying the row that actually caused it. The new test only asserts errors.length > 0, so it passes either way. Add an existence check for sensitiveNetworkId in validateTableRows.js (it already loads existing rows and pushes errors with the sheet row number) — this is the fallback the plan left undecided, and the error message is the reason to add it.

},

facility: {},
sensitiveNetwork: {},

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

sensitiveNetwork now flows through POST /v1/admin/import/referenceData, but no test exercises the new type over HTTP with the real permission layer. sensitiveNetworkImporter.test.js calls importerTransaction directly with checkPermission: () => true, and the one permission test asserts only that a vi.fn() mock was called with ('create', 'SensitiveNetwork') — that proves the importer asks, not that a role can actually be granted it or that a user lacking it is refused. Given this gates enrolment into a confidential-data network, add a case in the style of referenceDataImporter.test.js:71 (ctx.baseApp.asRole('practitioner').post('/v1/admin/import/referenceData').attach('file', ...)) covering: permitted role succeeds, least-privilege user (asRole('base')) is forbidden. Same HTTP path is also where the membership-change refusal should be seen at least once, to confirm the InvalidOperationError from the Facility validator surfaces as a row-attributed error in the endpoint response rather than a bare 500.

@review-hero

review-hero Bot commented Sep 15, 2026

Copy link
Copy Markdown

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

Below consensus threshold (9 unique issues not confirmed by majority)
Location Agent Severity Comment
.workhorse/plans/t6/plan.md:189 BES Requirements nitpick This "Open question" asks whether a network id should be a UUID and says SensitiveNetwork.id is DataTypes.UUID, but 1787600000000-createSensitiveNetworks.ts declares it (and both `sensitive_n...
packages/central-server/app/admin/referenceDataManage.js:68 Integration tests suggestion The spec criterion added here says "a membership change is refused wherever a facility record is written", but the admin manage PUT /v1/admin/referenceData/:id path — which writes a loaded instan...
packages/constants/src/importable.ts:83 Bugs & Correctness suggestion Registering sensitiveNetwork in OTHER_REFERENCE_TYPES makes referenceDataImporter demand create/write on the new SensitiveNetwork noun for every import whose includedDataTypes contain...
packages/database/src/migrations/1787600000001-backfillSensitiveNetworks.ts:11 BES Requirements suggestion This comment now states the opposite of what the PR ships: "There is no way to merge these networks afterwards: ... an operator who wants two facilities in one network stands up a new facility enro...
packages/database/src/models/Facility.ts:16 BES Requirements nitpick SENSITIVE_NETWORK_IS_FIXED_MESSAGE is copy-pasted verbatim into two test files (packages/database/__tests__/models/SensitiveNetwork.test.ts and `packages/central-server/tests/importers/sens...
packages/database/src/models/Facility.ts:82 BES Requirements nitpick InvalidOperationError is discarded here: Sequelize catches anything thrown from a custom validator and re-wraps it as a SequelizeValidationError item, keeping only the message. So the import of...
packages/upgrade/src/steps/1788700000000-mergeFijiSensitiveNetworks.ts:67 Bugs & Correctness critical The merge retags and retires networks, but only moves the three named facilities — so any other member of those networks is left orphaned and cross-exposed. mergedIds is whatever networks the t...
packages/upgrade/src/steps/1788700000000-mergeFijiSensitiveNetworks.ts:118 BES Requirements suggestion This step is the one path in the product that still changes a facility's network membership and retags sync_lookup, but its only tests mock sequelize.query wholesale — they assert the replaceme...
packages/upgrade/src/steps/1788700000000-mergeFijiSensitiveNetworks.ts:120 BES Requirements suggestion Hand-writing sync_lookup.updated_at_sync_tick goes against the documented sync antipattern (llm/project-rules/coding-rules.md: never modify updated_at_sync_tick manually) and reimplements a m...
Local fix prompt (copy to your coding agent)
Fix these issues identified on the pull request. One commit per issue fixed.

-------

`database/model/public/sync_lookup.yml:65`: The dbt model is corrected to `character varying(255)` here (matching the DDL, which uses `DataTypes.STRING`), but `packages/database/src/models/SyncLookup.ts:38` still declares `sensitiveNetworkId: { type: DataTypes.UUID }`. Sequelize's UUID type validates on write, so any model-level write carrying a real network id — now readable strings like `sensitiveNetwork-srh` from the backfill and the new merge step, not UUIDs — fails with "is not a valid uuid". It only stays quiet today because lookup population writes raw SQL and the fake data defaults the column to null. Change the model attribute to `DataTypes.STRING` so schema, model and dbt agree.

-------

`packages/database/src/migrations/1787600000001-backfillSensitiveNetworks.ts:18`: Stripping disallowed characters out of the facility code to build the network id can collapse two distinct codes onto the same id (e.g. `SRH.1` and `SRH-1`, or `A/B` and `AB`), and a code made only of punctuation yields the bare id `sensitiveNetwork-`. Both cases make the INSERT fail on the sensitive_networks primary key, which aborts the migration and blocks the whole upgrade for that deployment — and it fails only on deployments that happen to have such codes, so it won't show up in testing. Derive the id from the facility id (already a safe, unique key: `'sensitiveNetwork-' || facilities.id`) rather than from a lossy transform of the code, and pair facilities to networks on that instead of on code.

-------

`packages/central-server/app/admin/importSchemas/baseSchemas.js:147`: `sensitiveNetworkId` has no existence check before the insert, so a typo'd or unknown network id reaches Postgres and fails as a foreign-key violation rather than a row-level validation error. `FOREIGN_KEY_SCHEMATA.Facility` (referenceDataImporter/sheet.js) only resolves `catchment`, and the raw column name never matches `findFieldName`, so the value passes straight through to `Model.create`. Two consequences: the administrator sees a raw `insert or update on table "facilities" violates foreign key constraint ...` wrapped in an `UpsertionError` instead of the "valid foreign key expected in column ..." message every other FK column produces; and because a constraint violation aborts the surrounding Postgres transaction, every row processed after it in the same import fails with `current transaction is aborted`, burying the row that actually caused it. The new test only asserts `errors.length > 0`, so it passes either way. Add an existence check for `sensitiveNetworkId` in `validateTableRows.js` (it already loads existing rows and pushes errors with the sheet row number) — this is the fallback the plan left undecided, and the error message is the reason to add it.

-------

`packages/central-server/app/admin/referenceDataImporter/dependencies.js:45`: `sensitiveNetwork` now flows through `POST /v1/admin/import/referenceData`, but no test exercises the new type over HTTP with the real permission layer. `sensitiveNetworkImporter.test.js` calls `importerTransaction` directly with `checkPermission: () => true`, and the one permission test asserts only that a `vi.fn()` mock was called with `('create', 'SensitiveNetwork')` — that proves the importer asks, not that a role can actually be granted it or that a user lacking it is refused. Given this gates enrolment into a confidential-data network, add a case in the style of `referenceDataImporter.test.js:71` (`ctx.baseApp.asRole('practitioner')` → `.post('/v1/admin/import/referenceData').attach('file', ...)`) covering: permitted role succeeds, least-privilege user (`asRole('base')`) is forbidden. Same HTTP path is also where the membership-change refusal should be seen at least once, to confirm the `InvalidOperationError` from the Facility validator surfaces as a row-attributed error in the endpoint response rather than a bare 500.

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

Android builds 📱

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

chris-bes and others added 5 commits September 16, 2026 00:07
Backfill derives each network id from the facility id rather than a lossy
transform of its code. Stripping the characters a code allows but an id does
not could collapse two distinct codes onto one id, or yield a bare prefix for a
code of pure punctuation, colliding on the primary key and failing the upgrade
on just those deployments.

SyncLookup.sensitiveNetworkId declared UUID after the column became a string,
so any model-level write carrying a real network id would fail validation.

Facility rows now check the network exists during schema validation. Reporting
it in validateTableRows would not have been enough: that hook leaves the row in
the upsert, so it would still have raised a foreign key violation, aborted the
transaction, and buried the offending row under "current transaction is
aborted" errors for every row after it. importRows puts models into the
validation context so a schema can check against the database.

Adds tests over POST /v1/admin/import/referenceData covering the permission
layer and the refusal's error shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sensitive_networks holds code and name unique, but facilities holds neither —
only facilities.id is unique. Copying a facility's code and name verbatim
therefore failed the upgrade wherever two sensitive facilities shared one, such
as a "Central Clinic" in two divisions, which is what the non-determinism check
hit. Where a value repeats among the facilities being backfilled, the facility
id now qualifies it, and an administrator can rename it through the reference
data import afterwards.

Corrects the spec alongside: it claimed facility codes and names are unique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sensitive_networks is a syncable table, so its unique constraints have to be
DEFERRABLE INITIALLY IMMEDIATE for the sync-apply transaction to defer their
validation to its end — otherwise a batch only transiently in conflict, such as
two networks swapping codes, cannot be applied. They were created with
addIndex({ unique: true }), which makes a bare unique index, and Postgres
supports DEFERRABLE only on a constraint.

Regenerates the dbt model alongside: its generator reads constraint_type, so
promoting the indexes adds a unique test to code and name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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