Imported from wildflowerhealthio/Wildflower (
slices/emr/AGENTS.md). Install upstream withnpx skills add wildflowerhealthio/Wildflower --skill emr. Copyright stays with the author.
AGENTS.md — slices/emr
FHIR R4 slice: pure wire schemas (Patient / Practitioner / Observation / DiagnosticReport / MedicationRequest / MedicationDispense / DocumentReference / Binary / ServiceRequest / ImagingStudy), the HttpApi description, and the HTTP client for the off-the-shelf HFS FHIR server embedded by emr-rust. Read the Packages Explanation before restructuring anything here.
Guardrails
-
The server is HFS, not TypeScript.
emr-rustembeds the HeliosSoftware/hfs crates and is mounted at/fhir-r4byapps/wildflower-tauri. There is no TS server implementation;fhir-r4is a description + schemas + client only. Don't add server handlers back to the TS side. -
fhir-r4deliberately breaks the-corenaming convention. It is a wire-protocol package that never grows platform adapters, so it takes no suffix. It depends on no other emr package. -
fhir-r4/clientsowns writing to the store: one resource (upsertResource), one batch of PUTs (persistResources), one batch Bundle (persistBatchBundle), and one server-diff probe (classifyAgainstServer) — all reporting outcomes as data. The batch sinks live here rather than in the collector or importer slice because they needupsertResourceand the typed client, while bothcollector-fundamentalsandimporter-fundamentalsare deliberately FHIR-agnostic —persistResourceswas three duplicated copies across*-client-collectorpackages before it was consolidated, andpersistBatchBundlewas two duplicatedwithMetaSource → persistResourcessinks across HAR and LifeLabs before it too was hoisted here. They take no options. The writes are this package's, so the spans they emit (fhir.persist.write/fhir.persist.bundle/fhir.persist.classify) and the attributes they tag (fhir.resource.type,fhir.persist.resource_count) are named in this package's own telemetry catalog — a caller cannot make one write report itself as two different operations.ResourceWriteFailure(frompersistResources) is likewise declared here rather than imported, because this slice sits below its consumers and cannot name them; a consumer checks the two against each other at its own call site. -
persistResourcesvspersistBatchBundle— pick by transport shape, not by convenience.persistResourcesfans one PUT out per resource with bounded retries + concurrency (the collector slice's live real-time sync loop — dozens of small writes over the lifetime of a session, each with its own attempt count on the trace), reporting only the failures asResourceWriteFailure[].persistBatchBundlesubmits onePOST /Bundle carrying N PUT entries in a single round trip (the importer's confirmed one-shot — the reviewed batch is opted into as a set, so a whole-set round trip fits the "the user just clicked Import" shape), and reports the whole per-entry result asBatchEntryOutcome[]— every submitted resource's echoed status, whether it succeeded, and any OperationOutcome diagnostics (parsed fromBundle.entry.response.outcome) — so the importer's results view can show what wrote alongside what did not, grouped by response code. Both channels arenever. The collector should NOT switch to bundle: the failure model (per-entry, one shot) does not fit an infinite drive loop with per-resource retries. -
fhir-r4/identityowns the derivation every stored resource id comes from.localResourceIdis persisted wire format — changing it orphans every stored resource — andadoptUnderRecognizedRoot(inadopt-under-recognized-root.ts) is the one per-kind combinator a source maps its response kinds through, adopting each parse output under the identity the kind's owntryRecognizeminted for the response URL (aParseErroron a URL it does not recognize). It lives here rather than in the collector slice becauseweb-trace-coreneeds it too, and both sit above this package. The hash lane is not owned here: it iskitchen-sink'sfnv1a64, the standard algorithm pinned against the published FNV vectors. What this package owns is what is not standard FNV — the second lane's displaced basis, the 128-bit concatenation, thewf-rendering, and the length-prefixed component encoding (joinIdComponents, exported because a caller folding several values into oneoriginalIdmust fold them the same way rather than inventing a separator). The design is written up in the Source Identity Explanation. -
fhir-r4-react/smartowns the wiring a self-hosted SMART app needs, and it is auth-critical.self-hosted-runtime.tsis what an app served from its own origin (http://127.0.0.1:8091/on device, a tunnel subdomain through the front) uses to authenticate with its own SMART bearer token, since the host webview's loopback-provenance auth does not extend to another origin. It came out ofapps/web-tracewhen a second app needed it; copying it again is copying auth-critical code. Three properties are the reason it exists and must survive any edit — each is pinned by a test inself-hosted-runtime.test.ts:- The typed client emits base-relative FHIR paths; the provider names the base URL explicitly.
FhirResourcesApino longer bakes in Wildflower's/fhir-r4mount prefix, so the client emits/Patient, not/fhir-r4/Patient, and can address any FHIR server. Naming the base is the provider's job: the host app re-appliesFhirResourcesApiPrefixat its own call site (apps/wildflower-react'srouter-context.ts), whilesmartHttpClientLayerprependssession.serverUrl— theiss, or the server a standalone launch was pointed at — verbatim (only trailing slashes trimmed). There is noissparsing and noEither/UnexpectedFhirBase: whatever server the handshake named is the base, sosmartHttpClientLayerandbuildSmartRouterContextreturn plain values and the app uses them directly.self-hosted-runtime.test.tspins the verbatim-prepend as a property over arbitrary bases. - The bearer token rides only the requests the layer addressed. An already-absolute URL passes through untouched and uncredentialed: the SMART token was granted for the FHIR server the handshake named, so it must never leave for an origin the session did not name. A session with no token sets no header at all, never a
Bearerwith nothing after it. - The transport stays a parameter — never a baked-in
FetchHttpClient.layer. That is what lets a consumer drive its whole tree over a stub and assert what actually went on the wire (apps/web-trace'sapp.test.tsxdoes exactly this). The app is the one place the real transport is named.
- The typed client emits base-relative FHIR paths; the provider names the base URL explicitly.
-
fhir-r4-react/smart's standalone-launch primitives probe before they connect, andunreachable≠open.standalone-launch.tsruns a Standalone SMART App Launch against a user-picked server:detectSmartSupport(iss, fetchFn?)GETs{iss}/.well-known/smart-configurationand returns one of three outcomes —smart(config names anauthorization_endpoint→ OAuth+PKCE viaauthorizeSmartLaunch),open(a real HTTP answer with no such config →authorizeOpenServer, noAuthorizationheader), orunreachable(the fetch rejected). The third is deliberate and load-bearing: a CORS-blocked probe rejects identically to a down server, so it is surfaced to the user with a retry, never silently degraded toopen— treating it as open would fire unauthenticated reads at a server that may require auth.fetchFnis a parameter (defaultglobalThis.fetch), same transport-is-a-parameter philosophy as the runtime above.normalizeServerUrlis not defined here any more —standalone-launch.tsre-exports the one implementation, which lives ingatekeeper-core/smart-client'sserver-target.ts. It used to be a byte-identical copy, because the original sat inapps/wildflower-server-docsand a slice cannot import from an app; moving it intogatekeeper-coreremoved that obstacle and the copy went with it. A test instandalone-launch.test.tsasserts the re-export is the same function, so a third copy cannot quietly grow back. The two launches remain otherwise unrelated (fhirclient here, Effect there). The UI over these primitives issmart-app-react'sConnectMenu— see slices/smart-app/AGENTS.md. -
Every
fhir-r4-react/smartpaged read goes throughfetchResourcePage, and the read descriptor is the only resource-specific part.resource-page.tsowns the whole algorithm — the permissiveBundlepage decode (a response that is not a bundle is an empty last page), thenext-link cursor, and the per-entry decode-or-drop that keeps one malformed row from failing a page. A resource adds aPagedResourceReadnaming three things and nothing else: itsresourceType, thefhir-r4schema its entries decode through, and afirstPageQuerybuilding the first page's search parameters.fetchMedicationRequestPage(MedicationRequest?patient=…&_sort=-authoredon, keeping its own{ patientId } | { pageUrl }cursor soapps/medications-appis untouched, and pinning no_countbecause that app pages against the server's own size),fetchObservationPage(Observation?patient=…&_sort=date) andfetchPatientPage(Patient?_sort=family, the no-context picker's read) are all thin wrappers over it — a fourth read is a descriptor, not a fourth copy of the paging. The two newer reads pin_countto the sharedRESOURCE_PAGE_SIZE(200): a server's default page is commonly 10–50, which turns a year of observations into dozens of round trips. The patient id is always a parameter, never read offclient.patient.idinside the reader — the caller decides what "no patient in context" means for its launch, and both patient-scoped reads drop thepatient=filter entirely when it isnull.fetchPatient(client, id)is the single-resource counterpart and isOption-returning:Nonemeans "the server's answer does not decode as aPatient", while a transport or HTTP failure (a 404 included) rejects, because a caller shows those two differently. -
A failed SMART launch is never silent — it rides
?launchErrorto the app root and renders there.launch-error.tsowns the contract, and it exists because three separate failures used to end with the user on a page that said nothing:authorizeSmartLaunchrejecting on the launch page (an unreachable or CORS-blockediss) only wrote bare text intolaunch.html's body; the authorization server's own OAuth?error=return was deliberately excluded fromshouldCompleteSmartLaunchand so rendered the plain connect menu; and a failed token exchange showed a bare red line in a dead end. All three now encode aLaunchErrorBody(launchErrorBodyFor), redirect to the app root (launchErrorRedirect), and are read back bylaunchErrorFrominto anErrorthe app root hands to tundraish'sErrorBanner. Three properties are load-bearing:- The wire is the Tauri arm's wire. URL-safe-base64 JSON, byte-identical to
slices/apps/apps-react/src/routes/_auth/home/-launch-error.ts, so both arms'?launchErrordecode the same way. Changing one without the other forks the contract. launchErrorRedirectstripscode/state/error/error_description/error_urifrom the target. This is what makes the redirect safe to fire from a failed exchange:shouldCompleteSmartLaunchreadscode/state, so carrying either to the app root would re-enter the launched branch, fail the same single-use code, and redirect again — forever. Pinned by a property test.- The reported value is an
Error, not a string. Tundraish'sformatErrorDetailsreturnsnullfor a non-Error, which silently drops the "Show details" disclosure — the one placeissand the OAutherror_uriare visible. The diagnostics are attached as own enumerable properties precisely because that is what its own-fields pass surfaces.
- The wire is the Tauri arm's wire. URL-safe-base64 JSON, byte-identical to
-
The redirect-target token exchange runs through
useSmartHandshake, once — never a bareuseEffect. The authorization code in the redirect URL is single-use, so the exchange (readySmartClient→ fhirclient'soauth2.ready(), the POST to/oauth/token) may fire once, ever.useSmartHandshake(use-smart-handshake.ts) wraps it in a keyed TanStack Query withretry: falseandstaleTime/gcTime: Infinity, which is what makes that hold underReact.StrictMode's dev double-mount — a rawuseEffectdouble-POSTs the code and the second exchange fails against an already-redeemed code (fhirclient's own guard only catches a sequential reload). The app builds its oneQueryClientwithbuildSmartQueryClient(), provides it at the tree root (where the hook runs, before any router context exists), and hands that same instance tobuildSmartRouterContextso the exchange and every app read share one cache.smart-app-react'sSmartAppRootbuilds and provides that client, so an app mounted in it gets this wiring for free;apps/medications-appandapps/importer-webmount it, and a new self-hosted app must too rather than copying the root (apps/web-tracestill carries its own copy). -
Deviations from the FHIR R4 spec must be recorded, in the catalogue for the side you touched, in the same change. Client-side (TS schemas /
HttpApi) go in fhir-r4/docs/Client Capabilities Reference.md; server-side (emr-rust's overrides on top of HFS) go in emr-rust/docs/Capability Statement.md.
Traps
- No drift guard exists between
fhir-r4'sHttpApiand HFS's actual surface. A snapshot pair does exist (emr-rust/openapi/fhir-r4.openapi.json, generated from the TSfhir-r4HttpApiand kept fresh by a TS-side test, and read on the Rust side byemr_rust::openapi_specfor the host's unified/docspage) — but it only guards the snapshot against theHttpApi, not against what HFS actually serves. If you change theHttpApidefinition, verify HFS actually serves that shape (see the Client Capabilities Reference). - Consuming
fhir-r4from another package has two non-obvious traps — building schemas from its datatype schemas can breakvp pack's.d.tsemit (TS2883) whilevp checkstays green, anduri/urlfields on an already-decoded resource areURLs, not strings. Both are catalogued in the Consumer Gotchas Reference; keep the top-level interface re-exports indata-types/index.tswhen editingfhir-r4. - Complex datatypes self-register into the registry at module load (
registerDatatypeSchemaat the bottom of each datatype file). Avalue[x]slot whose datatype module hasn't been imported fails encode withUnregisteredDatatype. Import the registration barrelfhir-r4/src/data-types/register-all.ts(one side-effect import that loads every registrable module) instead of hand-listing modules per resource.register-all.test.tsasserts the barrel populates every registry slot, so a dropped or forgotten registration is a CI failure rather than a latent runtime one — but the barrel's imports are still bare side effects, so add the matching line whenever a new complex datatype module lands.
References
- Packages Explanation — why the packages split the way they do
- Client Capabilities Reference — client-side (TS) gap catalogue
- Consumer Gotchas Reference — traps for packages that reuse
fhir-r4schemas or re-decode its resources - emr-rust Capability Statement — server-side (HFS embedding) deltas from stock HFS
