An AI agent that reads clinical data is a retrieval problem. An AI agent that writes clinical data is a liability problem. Somewhere between "the model drafted a medication list update" and "the medication list changed," a human with authority and context needs to look at exactly what is about to happen — not a summary of it, not a chat transcript that gestures at it, but the actual mutation.
Infrastructure engineering solved an analogous problem years ago with the plan/apply split: Terraform doesn't mutate your cloud account as it reasons; it emits a plan — a complete, diffable statement of intended changes — and a human (or a policy engine) approves the plan before apply executes it. The plan is the review artifact, and everything good about that workflow (diffs, policy checks, drift detection, audit) follows from the plan being data.
FHIR-native applications can do the same thing, and FHIR is unusually well equipped for it. The pattern this post describes: instead of letting the agent call the FHIR API directly, the agent produces a change proposal — a transaction Bundle capturing the proposed changeset, persisted but not executed. Creates and deletes are plain entries. Updates are expressed as FHIRPath Patch operations rather than whole-resource PUTs. When review can't happen on the spot, a Task wraps the Bundle as its review envelope. A reviewer approves or rejects; on approval, the server executes the Bundle atomically.
The core move is representational: the proposal is data, in the same type system as the data it modifies. That buys you rendering, validation, diffing, deferral, and audit — and it raises a set of design questions worth walking through in order, because most of them only surface after you've built the happy path.
The shape of a change proposal
A FHIR transaction Bundle already has everything a changeset needs. Each entry carries a request with an HTTP method and URL, so a single Bundle captures a heterogeneous set of mutations. Here's a proposal a visit-documentation agent might draft after today's HTN follow-up for James Okonkwo — titrate lisinopril, order a BMP, schedule follow-up, and update the care plan BP goal:
{
"resourceType": "Bundle",
"type": "transaction",
"meta": {
"extension": [
{
"url": "https://example.org/fhir/StructureDefinition/proposal-summary",
"valueString": "After today's HTN follow-up: increase lisinopril to 20 mg daily, order BMP in two weeks, schedule four-week follow-up, and update the care plan BP goal to <130/80."
},
{
"url": "https://example.org/fhir/StructureDefinition/proposal-evidence",
"valueReference": { "reference": "Encounter/enc-20260701" }
},
{
"url": "https://example.org/fhir/StructureDefinition/proposal-reason",
"valueString": "HTN follow-up Encounter/enc-20260701 — BP 158/94 despite adherence"
}
]
},
"entry": [
{
"extension": [
{
"url": "https://example.org/fhir/StructureDefinition/entry-intent",
"valueString": "Titrate lisinopril from 10 mg to 20 mg daily — BP 158/94 despite adherence."
},
{
"url": "https://example.org/fhir/StructureDefinition/expected-current-value",
"valueString": "10"
}
],
"resource": {
"resourceType": "Parameters",
"parameter": [{
"name": "operation",
"part": [
{ "name": "type", "valueCode": "replace" },
{ "name": "path", "valueString": "MedicationRequest.dosageInstruction[0].doseAndRate[0].doseQuantity.value" },
{ "name": "value", "valueDecimal": 20 }
]
}]
},
"request": {
"method": "PATCH",
"url": "MedicationRequest/789",
"ifMatch": "W/\"12\""
}
},
{
"fullUrl": "urn:uuid:a2b3c4d5-e6f7-8901-abcd-ef1234567890",
"extension": [{
"url": "https://example.org/fhir/StructureDefinition/entry-intent",
"valueString": "Order BMP to recheck electrolytes after dose increase."
}],
"resource": {
"resourceType": "ServiceRequest",
"status": "active",
"intent": "order",
"code": { "coding": [{ "system": "http://loinc.org", "code": "24320-4", "display": "Basic metabolic panel" }] },
"subject": { "reference": "Patient/456" },
"encounter": { "reference": "Encounter/enc-20260701" },
"occurrenceTiming": { "repeat": { "boundsPeriod": { "start": "2026-07-15" } } }
},
"request": { "method": "POST", "url": "ServiceRequest" }
},
{
"extension": [{
"url": "https://example.org/fhir/StructureDefinition/entry-intent",
"valueString": "Schedule four-week HTN follow-up with Dr. Chen — after BMP results."
}],
"resource": {
"resourceType": "Appointment",
"status": "proposed",
"start": "2026-08-05T14:00:00Z",
"end": "2026-08-05T14:20:00Z",
"reasonReference": [{ "reference": "urn:uuid:a2b3c4d5-e6f7-8901-abcd-ef1234567890" }],
"participant": [
{ "actor": { "reference": "Patient/456" }, "status": "needs-action" },
{ "actor": { "reference": "PractitionerRole/dr-chen" }, "status": "needs-action" }
]
},
"request": { "method": "POST", "url": "Appointment" }
},
{
"extension": [{
"url": "https://example.org/fhir/StructureDefinition/entry-intent",
"valueString": "Update care plan BP goal to <130/80 mmHg."
}],
"resource": {
"resourceType": "Parameters",
"parameter": [{
"name": "operation",
"part": [
{ "name": "type", "valueCode": "replace" },
{ "name": "path", "valueString": "CarePlan.goal.where(description.text='Blood pressure control').description.text" },
{ "name": "value", "valueString": "Blood pressure <130/80 mmHg" }
]
}]
},
"request": {
"method": "PATCH",
"url": "CarePlan/321",
"ifMatch": "W/\"7\""
}
}
]
}Five mechanisms are doing load-bearing work — three from the FHIR transaction rules, two from this pattern:
- Mixed methods with defined ordering. A transaction processes DELETE, then POST, then PUT/PATCH, then any reads, regardless of entry order. The Bundle above lists a PATCH first and POSTs later; at execution the server still applies the BMP and appointment creates before the medication and care-plan patches.
- Internal references. The
urn:uuid:infullUrllets entries reference each other before ids exist; the server SHALL rewrite those references to real ids as it assigns them. That's how theAppointmentabove points at theServiceRequestfor the BMP that doesn't exist yet — follow-up and lab order linked atomically. - Per-entry preconditions.
request.ifMatchpins an entry to a specific resource version (412 on mismatch);request.ifNoneExistmakes creates conditional. Both PATCH entries above pin versions;ifNoneExiston the POSTs matters when you need idempotent retry (execution section). - Self-describing metadata. The
metaextensions carry the proposal's summary, clinical reason, and evidence pointer; each entry carries a one-line intent. This is pattern convention, not spec —Bundlehas no rootextensionelement (it inherits fromResource, notDomainResource), sometaand the entries are the standard-legal anchors. These fields are what the reviewer reads first (rendering section) and what aTaskprojects its human-facing fields from (next section). - Expected current value on high-stakes patches. The dose-titration entry carries an
expected-current-valueextension (10) alongside the patch that sets 20 mg — the executor's belt-and-suspenders check that the field still holds what the agent saw before applying. Also pattern convention; expanded in the drift section.
The Bundle is persisted as a resource — POST /Bundle — not submitted to the transaction endpoint (POST / against the server base), which is what would execute it. That distinction is the whole trick, and it's worth being precise that it is a local convention, not a spec-defined mode: the server stores a Bundle whose type happens to be transaction the same way it stores any other resource, and nothing in the spec makes it inert except which endpoint it was sent to.
That convention leans on an assumption worth testing before you build on it — that the server will agree to store the blueprint at all. Many enterprise FHIR servers — HAPI FHIR, Azure API for FHIR, Google Cloud Healthcare API — run the same resource validator on POST /Bundle that they run on everything else, and a stored transaction blueprint is still a Bundle instance whose entry array is fair game: urn:uuid: relative references inside entries, Parameters resources carrying PATCH operations, and request metadata that only makes sense at execution time can all trip structural validation as a malformed Bundle even though the identical payload sails through the transaction endpoint. Where storage is refused, two fallbacks keep the pattern intact: wrap the proposal JSON in a Binary (FHIR-shaped, but opaque to $validate and awkward to diff in place), or hold drafts in a sidecar store keyed by a lightweight DocumentReference or Basic pointer. Neither is as clean as a first-class Bundle, but both beat fighting a validator built for clinical instances rather than integration artifacts.
When review is deferred (more on when that's the case in a moment), a Task references the stored Bundle and carries the review workflow:
{
"resourceType": "Task",
"status": "requested",
"intent": "proposal",
"code": { "text": "Review proposed changes" },
"description": "After today's HTN follow-up: increase lisinopril to 20 mg daily, order BMP in two weeks, schedule four-week follow-up, and update the care plan BP goal to <130/80.",
"focus": { "reference": "Bundle/draft-proposal-42" },
"for": { "reference": "Patient/456" },
"requester": { "reference": "Device/visit-documentation-agent" },
"reasonCode": { "text": "HTN follow-up Encounter/enc-20260701 — BP 158/94 despite adherence" },
"restriction": { "period": { "end": "2026-07-12T00:00:00Z" } }
}Notice Task.description is character-for-character the Bundle's summary extension, Task.reasonCode matches the proposal-reason extension, and Task.for is derivable from the entries' targets — the envelope invents nothing.
Task is deliberately boring here, and that's the point: review lands in the same work-queue machinery the rest of the application already uses (assignment, Task.owner, business status, due dates via restriction.period), instead of a bolted-on approval inbox. Two of these usages are conventions worth naming: Task.focus pointing at a Bundle is defensible but not vanilla (the spec's comments suggest subtasks when multiple resources are manipulated), and reading restriction.period.end — "the period over which fulfillment is sought" — as a proposal expiry is an interpretation, not a definition. Both are the kind of thing a profile should pin down (see the profiling note near the end).
Note the direction of authority, too — the Task's human-facing fields (description, reasonCode, for) should be derived from metadata the Bundle already carries (proposal-summary, proposal-reason, and the patient references in the entries), not authored independently, so the proposal remains one self-describing artifact and the envelope stays disposable. More on that split next.
Synchronous vs. asynchronous: does this proposal need a Task?
The Bundle and the Task solve different problems, and it's worth separating them, because not every workflow needs both. The Bundle answers what will change; the Task answers who reviews it, and when. Whether you need the second depends on where the human is when the proposal is born:
- Synchronous (copilot). A clinician is chatting with the agent; the agent drafts a Bundle mid-conversation; the UI renders the diff inline; the clinician approves; it executes immediately — in the clinician's own security context, which resolves the delegation question trivially (the approver is the session user). No
Taskneeded: the conversation is the review envelope, the proposal's lifetime is seconds, and drift preconditions trivially pass. This is essentially the CDS Hooks suggestion card, upgraded to a real transaction Bundle. - Asynchronous (autonomous). The agent runs on a schedule or a trigger — post-visit documentation finalized overnight, inbound result routing — with no human present at generation time. Now the full envelope earns its keep:
Taskfor routing and assignment, TTL and supersession, drift handling across a review window measured in hours or days, and the delegation machinery, since author, approver, and executor are separated in both identity and time.
The design payoff of naming this split: the artifact is identical in both modes; only the envelope differs. Make the Bundle fully self-contained — the proposal's summary, reason, per-entry intents, evidence pointers, and pinned versions all travel on the Bundle (extensions on Bundle.meta and on the entries; more in the rendering section), never only on the Task. The Task is then a pure envelope: created only when review is deferred, its fields (description, reasonCode, for) projected from the Bundle's own metadata (proposal-summary, proposal-reason, entry targets) at wrap time rather than authored independently. Generation, validation, rendering, execution, and Provenance become one shared pipeline; the sync path is the degenerate case where proposal lifetime approaches zero and reviewer equals requester's session.
Build it that way and wrapping becomes a free, orthogonal move — any synchronously generated Bundle can be promoted into the async queue by minting a Task around it, for any of three reasons: the session user lacks approval authority ("this dose change needs pharmacist sign-off"), the user wants to defer the decision ("not now, put it in my queue"), or the user wants to delegate it to someone with more context. Nothing about the Bundle changes in the promotion; the envelope is the only thing that appears. And in the sync path, skipping the Task must never mean skipping the record — the approved Bundle and its Provenance persist regardless.
Why FHIRPath Patch for updates
Why not PUT with the full modified resource? Three reasons that compound:
- The patch says what the agent means. A
replaceonMedicationRequest.dosageInstruction[0].doseAndRate[0].doseQuantity.valueis a statement of intent. A full-resourcePUTis a snapshot in which the intended change is buried among dozens of unchanged fields — the reviewer has to reconstruct the diff, and so does your rendering code. - Smaller blast radius under drift. A
PUTbuilt against Tuesday's version of the resource silently reverts Wednesday's edits by someone else. A patch touches only the paths it names. (Drift still bites patches — more below — but it bites less, and more legibly.) - It's reviewable as data. Patch operations are
Parametersresources: you can validate them, allowlist the paths they may touch, and render them mechanically.
FHIR also supports JSON Patch, but its index-based paths (/telecom/2/value) are the wrong tool for a proposal that executes later against a resource that may have changed — an index is a bet that element order is stable. FHIRPath Patch paths select by predicate, and the spec's constraint that a path must return a single element turns ambiguity into a hard error rather than a silent wrong-target write. That failure mode — error, not misfire — is exactly what you want in a deferred write.
One honesty note before committing to it: FHIRPath Patch is Trial Use, and server support is uneven — some servers implement only JSON Patch, some support neither inside transactions. The pattern survives the gap, but the fallback order matters. If the server supports JSON Patch natively on PATCH, translate the FHIRPath Patch to JSON Patch at execution time and let the server apply it — you keep a partial update and ifMatch protection without owning a read-modify-write cycle. Only when neither patch dialect is available should the executor apply the FHIRPath Patch in memory and submit a PUT with ifMatch. That last path reintroduces the blast-radius problem this pattern argues against: the executor reads version N, computes a full resource, and a concurrent write to version N+1 means either a 412 (safe, but noisy) or, on a server that ignores If-Match, a silent clobber. If you're forced to PUT, treat the target resource as locked for the read-modify-write window — pessimistic lock, lease, or a server-specific concurrency token — and never submit a whole-resource replacement without ifMatch. Same review semantics either way; one more moving part you own, with sharper edges.
Lifecycle: the state machine you're actually building
Before the interesting design questions, name the states. Change-proposal systems converge on roughly this machine, and being explicit about it early prevents the "what does rejected-then-edited mean?" conversations later:
happy path:
draft -> pending-review -> approved -> executing -> applied
exit pending-review:
-> rejected
-> expired (TTL elapsed)
-> superseded (newer proposal for same concern)
exit executing:
-> failed -> pending-review (world changed; re-render and re-review)This is the full machine, and it's the asynchronous one. In synchronous mode it collapses: pending-review lasts the seconds the diff is on screen, expired and superseded never arise, and the state lives in the session rather than in a Task. The collapse is a feature — same states, shorter path — but rejected and failed still exist even in a chat (the clinician says no; the transaction 412s), so don't model the sync path as fire-and-forget.
For the async machine, map states onto Task.status (requested, accepted, in-progress, completed, rejected, cancelled, failed) plus a businessStatus for the finer distinctions. Two transitions deserve design attention because they're where systems quietly go wrong:
- Terminal
failedreturns to review, not to silent retry. Transient failures (5xx) are the executor's problem — retry with backoff, no human involved. A version conflict runs the automatic re-basing classifier first; only if that fails does the human see it again. A validation failure means the world changed in a way that matters: re-render, re-review. (The execution section has the full failure taxonomy.) supersededexists so agents can change their mind. If the agent runs nightly and Tuesday's proposal is still pending when Wednesday's run reaches a different conclusion, the right behavior is to supersede, not to stack two contradictory drafts in the reviewer's queue.
Drafts are immutable once submitted for review. An edited proposal is a new proposal — regenerate (or let the human apply their version directly under their own authority) rather than mutating a draft after the agent's reasoning and the validation pass no longer describe it.
Rendering: generic vs. bespoke
Whether the diff appears inline in a chat turn or in a work queue, someone has to read it — the renderer is shared infrastructure across both modes. The first fork in the road is whether it understands Bundles or understands your use cases.
A generic Bundle renderer walks entries and renders each by method: creates show the new resource, deletes show what dies, patches show a computed before/after — fetch the current resource, apply the patch in memory, diff. (This presumes a FHIRPath Patch engine in your stack; you need one anyway for the semantic dry-run in the validation section, and it's the same machinery the executor fallback uses.) This is a one-time investment that covers every future use case, and it can't lie: it renders what will execute, not what some product surface believes will execute.
Its ceiling is comprehension. "Replace MedicationRequest.dosageInstruction[0].doseAndRate[0].doseQuantity.value" rendered as a structural diff is legible to an integration engineer and hostile to a nurse. Clinical reviewers reason in domain terms — "changes the dose from 10 mg daily to 20 mg daily" — and a generic renderer doesn't know which paths matter or how to phrase them.
Bespoke renderers per use case (one for medication changes, one for order-and-scheduling bundles, one for demographic corrections) speak the reviewer's language, but each is code you write and — worse — code that can drift from the Bundle it summarizes. A bespoke renderer that says "titrate lisinopril to 20 mg" while the Bundle also orders a BMP is a review process that approves things nobody saw.
There's a stopgap tier between the two that's worth building first: generated intent summaries. A summarization pass — separate from the proposing agent, with the Bundle and its dry-run diff as its only input — produces a one-line global summary and a per-entry line for each change — the proposal-summary and entry-intent extensions in the example Bundle at the top. Carrying them on the Bundle itself (on meta and the entries, for the spec reason noted there) keeps the proposal self-contained whether or not a Task ever wraps it; if one does, Task.description is projected from the summary extension and Task.reasonCode from proposal-reason, not written separately. Render summaries as headers above the generic diff. This gets you most of what bespoke renderers buy — reviewers triage in domain language instead of structural paths — for zero per-use-case code, and it generalizes to entry shapes you haven't anticipated.
Two rules keep the stopgap honest. First, summarize the artifact, not the intent: the input is the Bundle, never the proposing agent's own account of what it meant to do — a summary of the agent's stated intent will cheerfully describe the change the agent thinks it made, which is exactly the divergence review exists to catch. Second, closure applies here too: every entry gets a summary line or the proposal renders with a visible "unsummarized changes" warning. The summaries are model output — narrative, not evidence — so they're labels on the diff, never a substitute for it; approval semantics attach to the diff.
The full synthesis, then, is a three-tier stack — generic diff always, generated summaries as the default comprehension layer, bespoke renderers where a use case earns the investment — with the invariants:
- The generic diff is always available, and arguably always visible, collapsed.
- The bespoke renderer refuses to render a Bundle containing entry shapes outside its vocabulary — unknown resource type, unexpected patch path, extra entries — and falls back to the raw diff. This closure check is the whole trick: a friendly summary is only safe if it's provably complete.
- The rendered view (or a hash of it) is persisted with the approval, so "what was on the reviewer's screen" is answerable later.
Drift: the resource moved while the draft sat in the queue
A change proposal has a lifetime, and the resources it targets keep living during it. A synchronous proposal's drift window is seconds and preconditions all but never fire; everything in this section is about the asynchronous gap. Between draft creation and approval, someone edits the patient's record. Now what executes is not what the agent reasoned about, and possibly not what the reviewer approved.
There's a spectrum of strictness:
- Pin and fail. Record each target's
versionIdat draft time and execute withrequest.ifMatchpreconditions. Any drift fails that entry — and in a transaction, the whole Bundle. Maximally safe, operationally noisy on busy resources unless you add automatic re-basing (below). - Detect and re-review. At approval time, compare current
versionIds against the pinned ones. If drifted, recompute the rendered diff against the current version and put it back in front of the reviewer. The approval covers the rendered effect, so the effect must be recomputed when the base changes. At execution time, a 412 triggers the re-basing classifier before escalating here. - Trust the predicate. Let FHIRPath Patch's predicate paths resolve against whatever version exists at execution. Tolerable for low-stakes fields; dangerous as a default.
The uncomfortable subtlety: FHIRPath Patch feels drift-tolerant, and that feeling erodes discipline. A dose-quantity path selects fine after unrelated edits — until a second dosageInstruction appears and an index-based path would have misfired, or the predicate CarePlan.goal.where(description.text='Blood pressure control') now matches zero or many goals. The error cases fail loudly; the wrong-element case does not. So: pin versions with ifMatch, treat drift as a review event rather than an execution-time surprise, and add a TTL — a draft old enough is stale by definition, whatever the version comparison says.
Belt-and-suspenders for the highest-stakes changes: store the expected current value alongside the patch (a test operation, in JSON Patch terms — FHIRPath Patch lacks a native one, so encode it as a precondition your executor checks during the dry-run). The executor-side check has a time-of-check/time-of-use gap on its own, so it complements ifMatch rather than replacing it: the version pin makes the check race-free, the value check makes the failure legible. "Replace 10 mg with 20 mg" is a safer statement than "replace whatever is there with 20 mg."
Automatic re-basing when version pins fail
Pin-and-fail is the right default, but the "operationally noisy" caveat on it above hides a real cost. In a busy EHR, versionId advances constantly — vitals sync, background routing, another clinician adjusting the same order. A proposal that sat in the queue for a day will almost certainly 412 on execution even when the pending change is still correct and the intervening edits are unrelated. Sending every one of those back to a human for full re-review is how review queues drown: the reviewer already approved "increase lisinopril to 20 mg," and now they're staring at the same diff because an unrelated status note changed underneath.
The fix is an automatic re-basing layer between the 412 and the review queue. When execution fails on ifMatch, don't route to a human immediately — first classify the failure:
- Fetch the current resource at the version that rejected the pin.
- Dry-run the stored FHIRPath Patch against it in memory (the same engine the renderer uses).
- Evaluate whether the patch still means the same thing:
- Does the predicate still resolve to exactly one element? (If not — zero or many matches — stop. Ambiguity is never auto-rebased.)
- Does the expected-current-value precondition, if present, still hold on the element the predicate selected? (If the dose the agent saw is no longer 10 mg, something material changed — stop.)
- Would the resulting resource pass the same validation profile the proposal passed at draft time?
If all three pass, the semantic intent is unchanged; only the base version moved. Rebase the entry's request.ifMatch to the current versionId, re-run the approval-time drift check and rendered diff (so the persisted approval record reflects the rebased base), and retry execution — without pulling a human back in. Log the rebase: which version pin failed, what the unrelated intervening change was, and why the classifier deemed it safe.
If any check fails, then it's a genuine drift event: recompute the diff, attach the OperationOutcome, return to review. The human is deciding whether the change still makes sense against a world that moved in a way that matters.
This is deliberately narrow automation. It does not "smart merge" arbitrary concurrent edits — it only auto-approves re-execution when a predicate-based patch would have hit the same field with the same intended value had the unrelated version bump not happened. That's the common case in long review windows; the dangerous case — predicate now matches a different element, or the field the agent targeted was itself edited — still fails closed to a human. Build the classifier before you scale async volume; without it, version pinning's safety becomes review fatigue's fuel.
Event-driven re-basing: react to drift, don't wait for it
The classifier above is lazy — it runs when execution hits a 412, which is to say at approval time or later. That's the safe floor, but it means a proposal invalidated at 2 p.m. sits in the queue looking alive until someone opens it at 5. FHIR's Subscriptions framework lets you run the same classifier eagerly — the moment a watched resource changes, not the moment a human gets to the proposal.
The mechanism: for each pending proposal, subscribe to changes on the resources its validity depends on, and re-run the classifier when one fires. That watch set is broader than the entries' pinned targets — it's the SubscriptionTopic coverage of the cross-referenced resources the proposal reasoned about. A proposed MedicationRequest doesn't only depend on the Patient it patches; it depends on the absence of a conflicting active order, on the AllergyIntolerance list the agent checked, on the Observation that justified the dose. When a new Observation lands, a subscription keyed to that patient's relevant topics fires, and the handler re-evaluates every pending proposal that referenced the changed resource. The outcomes map onto states you already have:
- Mechanical drift only (a pinned target's version bumped, patch still resolves cleanly) → auto-rebase silently, exactly as the 412 path does. The churn never surfaces.
- Hard conflict (predicate now ambiguous, expected value gone, post-patch validation fails) → move it to review now, diff recomputed, instead of letting it masquerade as healthy until approval time.
- Clinical obsolescence (the new observation means the proposed change no longer makes sense) → not the subscription's call. Trigger regeneration — hand the changed world back to the generating agent, which either reproduces the proposal or supersedes it (
supersededexists for exactly this). A subscription can detect that the world moved; deciding the proposal is now clinically wrong is the agent's job, then a human's.
The payoff is upstream fatigue reduction: dead proposals get pruned or re-based before a clinician ever opens the queue, so the queue trends toward things that genuinely need a human and notification volume drops. It's the difference between a queue full of stale drafts the reviewer must re-triage and one that self-heals as the record changes underneath it.
Three caveats keep this from becoming a footgun:
- Subscriptions are an optimization, not the correctness boundary. Delivery is best-effort — notifications can be dropped, delayed, or duplicated, and topic-based subscriptions are R5 with uneven server support. The pull-based checks at approval and execution stay authoritative;
ifMatchremains the thing that actually prevents a stale write. A missed notification must degrade to "caught later at the 412," never to "executed stale." - Fan-out will bite. A patient on continuous vitals writes
Observations every few seconds; naively re-evaluating every referencing proposal per write rebuilds the thundering herd you were avoiding. Debounce, coalesce by patient, and filter to material changes (the topic granularity andSubscriptionTopicfilter criteria are where this lives) rather than firing on every version bump. - The watch set is the hard part, and FHIR won't compute it for you. Which resources a proposal's validity depends on is the same cross-resource dependency problem the validation section flagged as having no first-class enforcement — the subscriptions are only as good as the dependency graph you hand-maintain. Under-subscribe and you miss the invalidating event; over-subscribe and you drown in fan-out.
Build this after the lazy classifier, not instead of it: eager re-basing is a latency-and-fatigue win layered on a correctness floor that has to hold on its own.
Access policies and delegation: who is doing this write?
Three identities touch a change proposal: the agent that authored it, the human who approved it, and the service that executes it. In synchronous mode the last two collapse — the approver is the session user and execution runs in their context, which is most of why that mode is simpler. Asynchrony pulls the three apart in both identity and time, and getting the authorization story wrong turns the review queue into a privilege-escalation device — an agent that can't write MedicationRequest shouldn't get one written by routing it through an approval UI rubber-stamped by someone who also can't write MedicationRequest.
The rule that keeps this coherent: the write executes under the reviewer's authority, and the reviewer must hold write access to every resource the Bundle touches, evaluated at execution time. The agent's permissions govern what it can propose (and read, to build the proposal); the reviewer's permissions govern what can happen. FHIR itself doesn't standardize impersonated execution — this is deployment architecture, assembled from whatever your platform provides: Medplum's AccessPolicy model, SMART scopes, on-behalf-of headers, token exchange. Whatever the mechanism, execute the Bundle in the reviewer's security context rather than a god-mode service account, so policy evaluation is enforcement rather than a pre-check you hope stays in sync.
Delegation is then representable rather than implicit. Provenance supports multiple agents with roles and onBehalfOf — the AI as author, the human as verifier, the human's organization as the responsible party. That's not just audit hygiene; it's the data structure that answers, two years later, "who decided this?"
Two asymmetries worth encoding explicitly:
- Approval authority can be narrower than write authority. A clinician who freely edits demographics may still need a second signature on controlled-substance changes. That's review policy, not access policy, and it belongs on the
Task(routing rules, required reviewer qualifications, two-reviewer requirements) rather than in the access-control layer. - The agent's read scope shapes proposal quality but is also a leak surface. A draft Bundle embeds data the agent read. If reviewers with narrower read access can open any draft, the queue itself becomes a disclosure channel — apply read policy to drafts like any other resource.
Validation: how much do you trust the generator?
A draft Bundle comes into existence somewhere on a spectrum of AI involvement, and each point needs a different validation posture. Worth stating plainly before the tiers: more programmatic is safer, because the model does not inherently know your data model's quirks — which identifier system is canonical, which extension URL your profiles expect, that disenrollment implies closing the care team, cancelling open orders, and ending the coverage period, in that dependency order. Those invariants live in your code and your team's heads; typed builders encode them, prompts merely gesture at them.
Fully programmatic (no AI at all): a deterministic system event triggers a code-built changeset — disenrollment fires a cleanup proposal that closes the CareTeam, cancels open ServiceRequests/Appointments/etc., and ends the Coverage. No model anywhere, yet the change-proposal machinery still earns its place: the changeset is wide, consequential, and cascades through resources a human should eyeball before it executes. This is the underappreciated case — HITL review is for consequential automation, not just probabilistic automation, and side-effect handlers are where programmatic changesets quietly get big.
AI via constrained tools: the agent doesn't emit FHIR — it calls typed tools ("titrate medication dose," "order labs," "schedule follow-up") and code builds the Bundle. The model decides what to propose; your code decides how that becomes FHIR, with all the data-model quirks handled once, correctly, in the builder. The Bundle is correct by construction. This is the right default for AI workflows. Its limit is expressiveness: every new mutation shape is a tool you have to build, and the agent can only propose what you anticipated.
AI-emitted FHIR: the model emits Bundle entries or patch operations directly. Maximally flexible — it can propose shapes you never anticipated — and now your validation layer is load-bearing, because every quirk the builders would have encoded is a mistake the model is free to make. At minimum:
- Structural validation —
$validateevery entry resource against its profiles; validate patchParametersshape against the defined operation types and their required parts. - Path allowlisting — an explicit list of resource types and FHIRPath prefixes the agent may touch.
Patient.telecom, yes;Patient.link(record merging!), no. Deny by default: the model will eventually discover an element you didn't think about. - Semantic dry-run — apply patches in memory and validate the result, not just the operations. A structurally valid patch can produce a profile-invalid resource. The dry-run output doubles as the renderer's before/after.
- Reference integrity — resolve every literal reference; verify each
urn:uuidreference points at an entry actually present in the Bundle. - Terminology checks where they bind — a syntactically valid
CodeableConceptwith a hallucinated RxNorm code passes structural validation and fails reality. - Cross-resource dependency checks — yours to build, because this is where FHIR's validation machinery genuinely runs out. See below.
That last bullet deserves expansion. Profiles constrain one resource at a time; $validate checks instances, not relationships. The invariants that make a changeset complete — an ACE-inhibitor dose increase should pair with renal monitoring in the same proposal; a follow-up Appointment should reference the lab it is meant to review; disenrollment implies cancelling open ServiceRequests and ending the Coverage — are model-level dependencies, and FHIR has no first-class way to declare or enforce them. The near-misses: a Bundle profile can slice entries and write FHIRPath invariants across entries ("a BMP ServiceRequest entry requires an Appointment whose reasonReference points at it"), which covers dependencies that travel together — but validator support for cross-entry invariants is spotty, and it cannot see server state, so "…or an active one already exists" is beyond it. GraphDefinition exists precisely to describe resource graphs and their rules, but it's Trial Use with almost no enforcement tooling. In practice these rules live in your own validation code (or CQL, if you already run it for quality measures).
This gap bites AI-emitted FHIR hardest, because incompleteness is precisely the mistake a model makes most readily — a dose titration without the paired monitoring order, a follow-up appointment with no reason link to the lab it should review, a disenrollment that forgets the open orders — and per-resource validation waves it all through. The builders in the programmatic tiers encode these dependencies implicitly; when the model authors the FHIR directly, nothing does unless you write the checker.
In-memory semantic dry-run catches profile violations on individual post-patch resources, but it cannot see cross-resource clinical rules that live in the server — duplicate therapy detection, formulary checks, required Practitioner linkage on a MedicationRequest, implicit constraints your profiles never encoded. Custom validation code and CQL can approximate some of this, but they're a heavy lift and they drift from what the production server actually enforces. For AI-emitted proposals especially, treat a dry-run target environment as load-bearing architecture, not a nice-to-have: fork or snapshot the patient's current record into an ephemeral database (or a server-scoped sandbox partition your FHIR stack supports), apply the transaction Bundle against that fork, and run the same server-side validation pipeline — $validate, terminology services, any business-rule hooks — against the resulting state. What the reviewer sees should be the diff and any OperationOutcomes the fork produced. A proposal that passes structural checks but fails duplicate-therapy detection in the sandbox never reaches the queue. The fork is throwaway; the production record is untouched until a human approves. This is the closest you get to "validate the changeset as a whole" without waiting for execution, and it's the right bar when the generator is a model that can assemble individually valid resources into a clinically incoherent bundle.
Run validation twice: at draft creation (reject garbage before it wastes reviewer time) and at execution (the world, the profiles, and the policies may all have changed). The sandbox pass belongs at draft creation; the execution pass re-runs against live state because the fork may be hours stale. Even with all of it, validation answers "is this well-formed and permitted," never "is this clinically right." The reviewer is the semantic validator — which is why the rendering section matters more than it first appears. Validation you can automate; judgment you can only inform.
Atomicity and partial approval
FHIR transactions are all-or-nothing — the spec requires servers to accept all entries or reject all entries — and mostly that's what you want: a proposal that creates a ServiceRequest and an Appointment referencing it must not half-apply. But all-or-nothing collides with a natural reviewer behavior: "approve the lab order and follow-up, reject the dose titration."
If you allow per-entry approval, the unit of atomicity and the unit of approval diverge, and you need entry groups: subsets of entries that must execute together — computed as connected components of the urn:uuid reference graph, plus any groups the generator declares explicitly (an extension on the entry, or a manifest in the wrapping Task, works). Approval operates on groups; execution builds one transaction Bundle per approved group.
The cheaper alternative pushes the problem upstream: keep Bundles all-or-nothing and make the generator emit smaller, single-concern proposals — several small Bundles rather than one omnibus. Reject-and-regenerate replaces partial approval. Less flexible, dramatically simpler, and the right place to start; entry groups are what you add when reviewer friction demands it, not before.
Execution: the mechanical details that bite
Approval fires an executor, and the executor is where several small decisions determine whether the system is robust or merely demo-ready:
- Idempotent execution. The executor will crash after the transaction commits but before the proposal's status updates; it will be retried; the retry must not double-apply.
request.ifNoneExiston creates gives conditional-create idempotency (include a proposal-scoped identifier on created resources and keyifNoneExiston it). Patches pinned withifMatchself-protect: the first application bumps the version, so a replay fails with 412 — which the executor must then distinguish from genuine drift by checking whether the target already reflects the patch. - Failure taxonomy. A 412 means version conflict → run the automatic re-basing classifier first (drift section); only if re-basing fails does the proposal recompute its diff and return to review. A 422 means validation regression (profile changed, reference vanished) → back to review with the
OperationOutcomeattached. A 5xx means retry with backoff. Collapsing these into "execution failed" guarantees a human eventually re-approves something they can't see the reason for. - Provenance inside the transaction. Add a
Provenanceentry to the executed Bundle whosetargetuses the sameurn:uuidreferences as the rest of the changeset — the server's reference rewriting links it to the real ids, and the audit record cannot be skipped by a crashed post-processing step. Deletes are the awkward case: after execution the target is gone, and how servers handle references to deleted resources (and their history) varies — deletion audit generally needs anAuditEvent, or reliance on retained history, in addition to theProvenance. - Result capture. Persist the transaction-response Bundle with the proposal record (
Task.outputin async workflows; alongside the approved Bundle in sync ones). It's the mapping from proposal entries to created ids and the ground truth of what actually happened.
The human is a failure mode too
A review gate's stated purpose is catching agent mistakes. Its dominant real-world failure is the reviewer who stops looking. Automation bias is well documented — reviewers defer to machine suggestions and miss errors they'd catch unaided — and a gate that's 98% approvals trains rubber-stamping. The synchronous mode is, if anything, more exposed: the approval is one click standing between the user and the thing they were already trying to do. Design for it in both modes:
- Tier the friction to the risk. Care plan goal edits might be one-click approvals; dose titrations can require the reviewer to expand the diff before the approve button enables. Uniform friction gets uniformly ignored.
- Watch the approval telemetry. Time-to-approve trending toward zero and approval rate trending toward 100% is either a very good agent or pure ceremony — the numbers alone can't tell you which; the canaries below can. In async workflows the
Tasktimestamps everything for free; give the sync path equivalent instrumentation rather than letting inline approvals go unmeasured. - Seed the queue. Occasional known-bad proposals (in non-production or clearly-marked training contexts) are the disambiguator: if nobody ever catches the canary, the high approval rate is theater, not precision.
- Feed rejections back. Rejected drafts with reasons are labeled examples of the agent being wrong — the most valuable eval data the system produces. If rejection reasons go nowhere, the error rate never improves and reviewer trust never stabilizes.
Related: the agent's inputs are attack surface. An agent that drafts proposals from visit notes, faxed documents, patient messages, or external records can be steered by whatever those contain — prompt injection with a clinical payload. The review gate is precisely the mitigation boundary for that: injected instructions can propose anything, but nothing executes without review. That defense only holds if the rendered diff shows everything (the bespoke-renderer closure check again) and if reviewers can see why — surface the source material or the agent's cited evidence alongside the diff, so "discontinue this allergy" buried in a suspicious note reads as suspicious.
Prior art, so you don't feel novel
This pattern isn't exotic; healthcare IT has been circling it for a while:
- CDS Hooks suggestions are exactly this shape at card scale: a suggestion carries an array of create/update/delete actions, "all actions are logically AND'd together," and clients are told to apply them per FHIR's transaction-processing rules. A synchronous change proposal is a suggestion card made durable and transaction-shaped (CDS Hooks defines the actions; how clients actually apply them varies); the asynchronous form is the suggestion that outgrew the card — persisted, routed, and reviewed later.
- FHIR's own request/event split.
intent: proposal | plan | orderon request resources, andTaskas the workflow spine, exist because the spec's authors assumed intent and execution would be separated by humans and time. - Pending-change queues in EHRs — lab-result verification, order co-signature, chart-correction workflows — are the same review-then-commit loop, minus the machine-generated changeset.
- Terraform's plan/apply, per the opening: reify intent as data, review the data, apply the data. The FHIR version has better types.
Profile it
Several load-bearing pieces of this pattern are conventions, and this post has been flagging them as they appeared: the stored-but-inert transaction Bundle, the meta extensions for summary, reason, and evidence, per-entry intents and expected-current-values, Task.focus pointing at a Bundle, restriction.period.end as expiry. Conventions held in engineers' heads don't survive a second team, let alone a second vendor.
The bridge from pattern to implementation is FHIR's own machinery: publish a ChangeProposalBundle profile (required meta summary, reason, and evidence extensions, per-entry intent extension, the allowlisted request methods, ifMatch required on updates) and a ChangeProposalReviewTask profile (fixed focus target type, the status/businessStatus vocabulary from the lifecycle section, the expiry convention). Then every rule the profiles encode is checked by the same $validate machinery the proposals already pass through — the conventions become conformance, testable at draft time and at execution time, and the pattern becomes something another implementation can interoperate with rather than an essay it has to reverse-engineer.
Where this earns its complexity
This is a real amount of machinery: a draft store, a review queue, a renderer with a closure check, a two-phase validation layer, drift handling, an idempotent executor, telemetry on the humans. For an agent that files documents into a folder, skip all of it.
It earns its keep incrementally. The Bundle-as-proposal core — generation, validation, rendering, execution — pays for itself as soon as the writes are consequential (clinical data, anything regulated) and the generator is one a human should check: probabilistic because a model wrote it, or deterministic but wide, like a disenrollment handler cascading through half a dozen resources. That's true even in a purely synchronous copilot. The rest of the machinery is bought by asynchrony: when the agent works at 2 a.m. and the clinician reviews at 9, you add the Task envelope, drift handling, TTLs, delegation, and reviewer telemetry. The pending proposal is what makes that gap safe: a complete, typed, validated, diffable statement of intent that waits, unexecuted, for someone accountable to say yes.
If your agent's writes go straight to the API, the model's judgment is your last line of defense. If they go through change proposals, the model's judgment is a draft — and drafts are allowed to be wrong.
Appendix: implementation checklist
Changeset representation
- Transaction
Bundleper proposal; creates/deletes as entries, updates as FHIRPath PatchParameters -
urn:uuidfullUrls + internal references for intra-proposal dependencies -
request.ifMatchpinned to draft-timeversionIdon every update/patch/delete -
request.ifNoneExist(keyed on a proposal-scoped identifier) on creates, for idempotent retry - Expected-current-value preconditions on high-stakes patches, executor-checked — complements
ifMatch, doesn't replace it - Bundle self-contained: summary, reason, per-entry intents, and evidence pointers carried as extensions on
Bundle.metaandBundle.entry(Bundle takes no root extensions) — never only on theTask - Single-concern Bundles by default; entry groups only when partial approval is demanded
- Conventions published as profiles (
ChangeProposalBundle,ChangeProposalReviewTask) and enforced via$validate, not tribal knowledge - Verify
POST /Bundleaccepts stored transaction blueprints on your server; if not,Binarywrapper or sidecar draft store - Patch fallback order: native FHIRPath Patch → native JSON Patch → executor-applied
PUT+ifMatchwith pessimistic lock on read-modify-write
Review envelope (async workflows; sync copilot review happens in-session, but still persists the approved Bundle + Provenance)
-
Taskwithfocus→ draft Bundle; human-facing fields (description,reasonCode,for) projected from the Bundle'sproposal-summary,proposal-reason, and entry targets at wrap time - Promotion path: any sync proposal can be wrapped in a
Task— insufficient approval authority, deferred decision, or delegation to another reviewer - TTL via
restriction.period; expiry is a terminal state - Approval-time drift check: compare current
versionIds against pinned; drifted proposals re-render and re-review, never execute stale - Automatic re-basing on 412: dry-run patch against current version; auto-retry only when predicate is unambiguous, expected value holds, and validation still passes — otherwise return to review with recomputed diff
- Optional event-driven re-basing:
Subscriptions on a proposal's dependency resources run the classifier eagerly (auto-rebase / re-review / regenerate) — an optimization layered on the approval/execution checks, never a replacement - Drafts immutable after submission; supersede, never edit
- Read access policy applied to drafts (they embed data the agent read)
Rendering
- Generic renderer: dry-run patches, before/after diff, always available
- Generated intent summaries (global + per-entry) from the Bundle only — never from the proposing agent's stated intent; unsummarized entries flagged visibly
- Bespoke renderers fail closed on unrecognized entry shapes
- Persist rendered view (or hash) with the approval
Validation (at draft time and execution time)
- Prefer the most programmatic generation tier available: code-built > tool-constrained > AI-emitted; data-model quirks belong in builders, not prompts
-
$validateentries against profiles; validate patch operation shapes - Deny-by-default allowlist of resource types + FHIRPath path prefixes
- Semantic dry-run: validate the post-patch resource, not just the patch
- Dry-run target environment: apply the transaction to an ephemeral patient-record fork; run full server-side validation against resulting state before the proposal reaches review
- Reference + terminology integrity checks
- Cross-resource dependency rules as custom checks — profiles/
$validateare resource-scoped; changeset completeness is invisible to them
Authorization
- Execute in the reviewer's security context; reviewer needs write access to every touched resource
- Review-policy layer on
Task(routing, qualifications, N-reviewer rules), separate from access policy
Execution & audit
- Idempotent executor; distinguish 412-rebase / 412-drift / 422-validation / 5xx-retry
- Drift and validation failures return to review with the diff recomputed
-
Provenance(AIauthor, humanverifier,onBehalfOf) inside the transaction, targets viaurn:uuid - Persist transaction-response Bundle with the proposal record (
Task.outputwhen async) - Retain rejected/expired drafts deliberately, per your retention policy — they're PHI-bearing eval data, not disposable scratch
-
AuditEventfor execution attempts and deletions (Provenance can't target what's gone)
The humans
- Risk-tiered approval friction
- Telemetry: time-to-approve, approval rate, per-reviewer distributions — instrument the sync path too, not just
Tasktimestamps - Seeded canary proposals to distinguish reviewer precision from rubber-stamping
- Rejection reasons captured and fed to agent evals
- Source material / evidence visible next to the diff (prompt-injection defense)