Server-only client for the Valtrix data platform (ACI Pilot 2, Qualifacts InSync EHR).
valtrix/client.ts is generated by pnpm valtrix:generate from the published Valtrix schema. Never edit it; regenerate and review the diff.pnpm valtrix:check fails when the published schema drifted from the generated client. Run it next to check-types in CI.VALTRIX_API_KEY (and optional VALTRIX_API_URL) from the shell or from .env.local in this directory. The key never reaches a browser: import this package only from server code.org. Resolve the organization id in one place and pass it through; nothing else in an app knows about organizations. The SDK's records.attachments(recordId) and attachments.url(attachmentId) take no org at all, so production MUST run on an org-scoped key (minted with POST /v1/keys for the customer's organization, which is the plan); on a platform-wide key those two calls would honour any org's ids.createValtrixClient({ apiKey, orgId }) from @repo/valtrix, never the env-keyed valtrix singleton: the singleton stays in valtrix/client.ts for scripts only. The factory is async: @valtrix/sdk is ESM-only and this package is also loaded by CommonJS callers (the tsx scripts in packages/backfills and packages/ai-agents), so the SDK is pulled in with a native import() on first use and its error classes are matched by shape (isValtrixError, isValtrixAuthError) rather than re-exported; test files get the real classes from @repo/valtrix/tests.admissions, patients, notes, assessments, clinicalEntries); add a table here when a milestone needs it. Of the records API only listRecordAttachments(recordId) (id, bytes, content hash, media type) and downloadAttachment(attachmentId, { maxBytes }) are exposed: the download mints the vendor's ~300 s signed link, uses it once (re-minting once if it has expired), reads the body under the byte cap with the 30 s request timeout, and never returns, stores or logs the link. Both take ids read from this org's own rows; the SDK's by-id record lookups stay unexposed because they take no org.upsert() and other writes are rejected.node_modules/@valtrix/sdk/AGENTS.md; this package deliberately differs from them (the org is always pinned and the key is read-only). Full API reference: Valtrix console under Developers.proxy_qualifacts_insync. In the facility wizard's type dropdown it appears as Qualifacts Insync; neither the vendor name nor the transport appears anywhere in the UI. The older RPA-based qualifacts_insync entry is no longer offered for new facilities; facilities already on it render and edit as before.integrations.verifyEmrProxyConnection, which resolves the organization's connected EMR, rejects it when its connector is not the one the type expects (EXPECTED_CONNECTOR_BY_PROXY_INTEGRATION_TYPE), and stores the connector slug and label alongside the credentials. The key is written once and is never read back to the browser.proxy_<emr>. qualifacts_insync keeps meaning "run RPA"; the proxied transport never reuses an existing type.ProxyQualifactsInsyncAdapter (packages/legacy/src/integrations/valtrix/proxy-qualifacts-insync-adapter.ts) is the EMRAdapter for proxy_qualifacts_insync, dispatched by get-integration-adapter.ts with the facility timezone. It is a thin subclass of the abstract ProxyEmrAdapter (proxy-emr-adapter.ts), which owns the client, paging, timezone anchoring, the facility and program catalog, episodes, patient admissions and program stays. The subclass only supplies a ProxyEmrProfile (proxy-qualifacts-insync-profile.ts): the table handles it reads, the patient source-id column (insync_patient_id), the nine InSync status labels, and the expected connector slug; the base refuses an integration whose stored connector differs. It builds the org-pinned client from the integration's credentials and reads the admissions and patients tables 200 rows per page, at most 50 pages per query: a read that runs past the cap fails with ValtrixPageCapError rather than returning a truncated list.
external_id is the InSync episode_id; the patient's external_patient_id (and mrn) is patients.insync_patient_id, joined from admissions.patient_id. Rows without either are counted and skipped.fetchEpisodesInRange and fetchPatientAdmissions read admissions admitted inside the window (floored at sync_from) plus every admission with is_open = "true" since sync_from. Date-only admit/discharge stamps (midnight UTC) are anchored to the facility's local midnight; stamps with a real time or offset pass through.dispositionByStatusLabel): Admitted is open; AMA, Discharge, Discharged/Transferred to Rehab, Medical Discharge, Admin Discharge are closed and take discharged_at; ERROR, Aborted Admission, Screen Not Admitted are never synced. Any other label falls back to is_open and is warned once per label. The raw label is kept as the episode status.fetchIntegrationFacilities returns { facility: null } without a vendor request, and every admission lands on the facility the integration is attached to.program_map keys are raw admission program labels and values are CircleHealth program names (unmapped labels keep the raw label). Programs are catalogued from the distinct labels since sync_from; each admission row becomes one program stay from admitted_at to discharged_at, keyed ${episode_id}:${admission_id}. A patient sync writes that stay for every one of the patient's synced episodes from the admission rows it already read (fetchTreatmentEpisodes returns each episode with its program_stays); the hourly path keeps writing stays per fanned-out episode through fetchEpisodeProgramStays.program; that mapping is to be confirmed by ACI and is not available yet.proxy_<emr> (one migration, M0-style) and list it in PROXY_INTEGRATION_TYPES; add its connector slug to EXPECTED_CONNECTOR_BY_PROXY_INTEGRATION_TYPE; write a subclass of ProxyEmrAdapter with its own profile and return it from get-integration-adapter.ts; add a wizard config entry in integration-type-configs.ts. If the EMR publishes tables this client does not pin yet, pin them in createValtrixClient.ts.fetchEpisodeDocuments reads the notes, assessments and clinical_entries tables by episode_id (the profile's documents section names each table's columns, type, label, created-at column, business id and title). Every row becomes one patient_charts row keyed on its _record_id. No document types were added: a note takes the existing InSync type its note_type label resolves to (resolveDocumentType, e.g. insync_rpa_encounter_note, insync_rpa_treatment_plan; unmatched labels fall to insync_rpa_document), and assessments and clinical entries are insync_rpa_document, so the insync_rpa_* formatter route and the chart-review paths need no change. raw_document holds the row's data columns; _changed_at is the document's external_document_updated_at, and circle_metadata.proxy carries the connector, vendor table, schema version, the raw label (note_type / tool / category), business id (encounter_id / result_id / entry_id), attachment_id and a contentHash slot for the attachment read. Once the episode has stored proxy documents, only rows with _changed_at after the newest stored external_document_updated_at (less a one hour overlap, PROXY_DOCUMENT_CURSOR_OVERLAP_MS, for the vendor's clock skew) are read and the result is flagged incremental, so the manager refreshes only those charts' vectors and an unchanged chart costs three empty reads. The episode's last_synced_documents stamp is not the cursor (it keeps being written for the UI), so an episode stamped before this adapter stored anything, as every episode was while the document step was a stub, still gets a full read on its first real sync. patient_name is never selected, and the text columns are titles, not bodies: the full note lives only in its attachment.sync-integration run reads admissions twice per integration (the census admissions pass and the episode pass each call the adapter), so one run is 4 rows-API admission reads plus 2 patient joins. That is the pre-existing chain behaviour for every adapter, not something this adapter adds. Each document sync adds 3 reads per episode (one per document table, each capped at 50 pages of 200 rows, so 10,000 rows per table). On the vector side, an incremental sync of the patient's latest episode embeds only the charts that actually changed (each embed replaces that chart's own vectors by id prefix); the separate vector delete lists the patient's whole EMR namespace once and fetches it 100 ids at a time, and it runs only for the rows a re-typed note left behind under its old type; when no proxy row changed, the only thing refreshed is the derived insurance document, if the patient has one, and a patient without one touches neither Pinecone nor the embedding model.idsToDelete for it, and a row leaving the proxy's window is not a clinical deletion. The change feed (a Later item) is what would fix it.note_type label changes re-types to a new patient_charts row, because external_document_type is part of the upsert key. The adapter returns the old-type row's id in idsToDelete (every chart sharing the record id under another type), so the stale row is deleted in the same sync; businessId and record_id in circle_metadata.proxy remain the recovery key.Decided 2026-09-19: ACI goes live on a NEW organization/entity with fresh accounts, and patients sync into that entity. On this path no patient identity migration and no integration row swap are needed: the new entity only ever has external_source = proxy_qualifacts_insync patients.
Invariants
integrationsRepo.getActiveByFacility is an unordered take-first read behind several production paths, and updateFacilityWithIntegration.service.ts refuses to change the integration of a facility that already has synced patients. A proxy_qualifacts_insync row can never sit beside another active integration on the same facility.admissions and patients tables carry no facility column, so one proxy_qualifacts_insync integration ingests every ACI site onto the single facility it is attached to (see "Adapter" for what per-site routing would need).Prerequisites
Go-live
emrIntegrationEnabled org setting on the entity.sync_from and a program_map covering the program labels listed under "Adapter". Unmapped labels stay raw, so gaps show up in logs, not as data loss.sync-integration run syncs the roster and episodes (patients, admissions, episodes, per-episode program stays) under external_source = proxy_qualifacts_insync.Optional confidence check
pnpm --filter @repo/backfills valtrix-shadow-diff prod --integration <newValtrixId> --compare <oldAciInsyncId> --password <pw>diff.matched should approach current.episodes; diff.admitDateMismatches points at timezone or date-only anchoring (see "Adapter"); valtrix.unmappedProgramLabels should be empty. Both sides are open episodes only, and a future-dated discharge counts as active on the current side but closed on the live side.Retirement
qualifacts_insync to proxy_qualifacts_insync / MRN) have to move in the same change.Rollback
proxy_qualifacts_insync integration row; the census stops and nothing else was changed. Its patients, episodes, and stays stay in place under external_source = proxy_qualifacts_insync and can be removed separately if wanted.