Skip to content
osbytes
← back to blog

Change proposals in FHIR: human-in-the-loop review for AI-driven writes

2026-07-05by@dillonstreator28 min read
#fhir #healthcare #ai #architecture #workflow #interoperability #hitl

When I wire an agent that touches clinical data, reads worry me less than writes. Scope, consent, and audit get you most of the way on disclosure. A write is different. The failure mode is mutation, and someone downstream will treat the record as truth. Somewhere between "the model drafted a medication list update" and "the medication list changed," a human with authority needs to see the actual mutation. Not a summary. Not a chat transcript that gestures at it.

Terraform's plan/apply split is the useful analogy. Plan computes a diff without applying changes; you approve the plan, then apply runs. Diffs, policy checks, drift detection, audit. Most of that goodness follows from the plan being data, not prose.

FHIR can do the same thing, and the types are already there. Instead of letting the agent call the FHIR API directly, have it produce a change proposal: a transaction Bundle that captures the proposed changeset, persisted but not executed. Creates and deletes are plain entries. Updates go through FHIRPath Patch rather than whole-resource PUTs. When review can't happen on the spot, wrap the Bundle in a Task. Reviewer approves or rejects. On approval, the server executes the Bundle atomically.

The proposal is data in the same type system as the data it modifies. That gets you rendering, validation, diffing, deferral, and audit. It also surfaces a pile of design questions, most of which only show up after the happy path works.

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."
        }
      ],
      "resource": {
        "resourceType": "Parameters",
        "parameter": [{
          "name": "operation",
          "part": [
            { "name": "type", "valueCode": "replace" },
            { "name": "path", "valueString": "MedicationRequest.dosageInstruction.where(timing.repeat.periodUnit = 'd').doseAndRate.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 BP goal to <130/80 mmHg."
      }],
      "resource": {
        "resourceType": "Parameters",
        "parameter": [{
          "name": "operation",
          "part": [
            { "name": "type", "valueCode": "replace" },
            { "name": "path", "valueString": "Goal.description.text" },
            { "name": "value", "valueString": "Blood pressure <130/80 mmHg" }
          ]
        }]
      },
      "request": {
        "method": "PATCH",
        "url": "Goal/901",
        "ifMatch": "W/\"7\""
      }
    }
  ]
}

Five mechanisms matter here: three from FHIR transaction rules, two from conventions this pattern adds.

  • 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 goal patches.
  • Internal references. The urn:uuid: in fullUrl lets entries reference each other before ids exist; the server SHALL rewrite those references to real ids as it assigns them. That's how the Appointment above points at the ServiceRequest for the BMP that doesn't exist yet.
  • Per-entry preconditions. request.ifMatch pins an entry to a specific resource version (412 on mismatch); request.ifNoneExist makes creates conditional. Both PATCH entries above pin versions; ifNoneExist on the POSTs matters when you need idempotent retry.
  • Self-describing metadata. The meta extensions carry the proposal's summary, clinical reason, and evidence pointer; each entry carries a one-line intent. Pattern convention, not spec: Bundle has no root extension element (it inherits from Resource, not DomainResource), so meta and the entries are the standard-legal anchors. Reviewers read these first; a wrapping Task should project its human-facing fields from them, not invent parallel copy.
  • The old value is derivable, not stored. The dose-titration entry pins ifMatch: W/"12", a pointer to the exact version the agent reasoned about. To recover what the field held at draft time, fetch MedicationRequest/789/_history/12 and evaluate the patch path against it. Where history reads work, you don't need a separate literal, and the check covers every path a multi-field patch touches. Store a literal only when _history isn't available on your server.

Persist the Bundle as a resource (POST /Bundle), not through the transaction endpoint (POST / on the server base), which is what would execute it. That's the whole trick, and it's a local convention. The server stores a Bundle whose type happens to be transaction like any other resource. Nothing in the spec makes it inert except which endpoint you hit. The same Bundle id can be revised with PUT while review is pending; approval and execution pin a specific versionId, not "whatever is current."

Test this early on your target server. Many stacks (HAPI FHIR, Azure API for FHIR, Google Cloud Healthcare API) run the same validator on POST /Bundle that they run on clinical instances. A stored transaction blueprint can fail validation anyway: urn:uuid: references inside entries, Parameters carrying PATCH operations, request metadata that only makes sense at execution time. The identical payload often sails through the transaction endpoint. If storage is refused, wrap the proposal JSON in a Binary (opaque to $validate, awkward to diff) or keep drafts in a sidecar store keyed by a DocumentReference or Basic pointer. Neither is as clean as a first-class Bundle; both beat fighting a validator built for patient data, not integration artifacts.

When review is deferred, a Task references the stored Bundle and carries the review workflow (sync vs async below):

{
  "resourceType": "Task",
  "status": "requested",
  "intent": "proposal",
  "code": {
    "coding": [{
      "system": "http://hl7.org/fhir/CodeSystem/task-code",
      "code": "fulfill"
    }],
    "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" } }
}

Task.description matches the Bundle's summary extension; Task.reasonCode matches proposal-reason; Task.for comes from the entries' subjects. The envelope shouldn't invent its own narrative.

Task.for is the one field that isn't a straight copy. A PATCH entry names a target (MedicationRequest/789) but not its subject, so resolving Patient/456 may take a server read. A single for also assumes one patient across all entries, which holds for a per-visit proposal but should be asserted, not assumed. code=fulfill is required whenever restriction is present (invariant tsk-1); this profile treats restriction.period.end as proposal expiry, not a clinical fulfillment window.

Task is deliberately boring here. Review lands in the same work-queue machinery the app already uses (assignment, Task.owner, business status, due dates via restriction.period), not a bolted-on approval inbox. Two usages are conventions a profile should pin. 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 as proposal expiry is an interpretation of "the period over which fulfillment is sought," not a spec definition.

Synchronous vs. asynchronous: does this proposal need a Task?

The Bundle and the Task solve different problems. Not every workflow needs both. The Bundle is what will change; the Task is who reviews it, and when.

  • Synchronous (copilot). Clinician chats with the agent; agent drafts a Bundle mid-conversation; UI renders the diff inline; clinician approves; it executes immediately in their security context. No Task: the conversation is the review envelope, lifetime is seconds, drift preconditions rarely fire. Think CDS Hooks suggestion card, but the payload is a real transaction Bundle.
  • Asynchronous (autonomous). Agent runs on a schedule or trigger (post-visit documentation overnight, inbound result routing) with no human present. Now the envelope earns its keep: Task for routing, TTL and supersession, drift handling across hours or days, delegation when author, approver, and executor are different people at different times.

The artifact is identical in both modes; only the envelope differs. Keep the Bundle self-contained: summary, reason, per-entry intents, evidence pointers, pinned versions live on the Bundle (meta and entry extensions), not only on the Task. When review is deferred, create a Task whose human-facing fields are projected from that metadata at wrap time. Generation, validation, rendering, execution, and Provenance share one pipeline. Sync is the degenerate case where lifetime approaches zero and reviewer equals session user.

Any sync Bundle can be promoted to async by minting a Task around it: insufficient approval authority, deferred decision, or delegation. The Bundle doesn't change; only the envelope appears. Skipping the Task in sync mode must not mean skipping the record. Approved Bundle and Provenance persist either way.

Why FHIRPath Patch for updates

Why not PUT the full modified resource?

  1. The patch states intent. One entry, one operation: replace the daily dose quantity with 20. A full-resource PUT resubmits the entire MedicationRequest. The titration is one field among dozens the agent did not mean to rewrite, and reviewer and renderer must reconstruct the diff from the two full snapshots.
  2. Smaller blast radius under drift. A PUT built against Tuesday's version silently reverts Wednesday's edits on fields the patch never mentioned. A patch touches only the paths it names. Drift still bites patches, but less and more legibly.
  3. Reviewable as data. Patch operations are Parameters resources. Validate them, allowlist paths, render them mechanically.

Each PATCH entry targets one resource. Paths apply within that resource; they do not follow references to patch something else.

FHIR also supports JSON Patch. Both formats can express "change the daily dose," but they pick the target differently. JSON Patch uses array indices: /dosageInstruction/0/doseAndRate/0/doseQuantity/value assumes the dose you care about stays at slot zero. Insert a PRN line ahead of it and the same patch hits the wrong instruction without complaining. FHIRPath Patch selects with predicates: dosageInstruction.where(timing.repeat.periodUnit = 'd').doseAndRate.doseQuantity.value names the daily sig, not its position. The spec requires a path to return a single element, so zero matches or two matches fail loudly instead of silently writing the wrong row.

Honesty note. FHIRPath Patch is Trial Use, and server support is uneven. Some servers implement only JSON Patch; some support neither inside transactions. The pattern survives, but know your fallback order. If the server supports JSON Patch natively on PATCH, translate at execution time and keep partial updates plus ifMatch. 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: concurrent writes mean 412 (safe, noisy) or, on a server that ignores If-Match, silent clobber. If you're forced to PUT, lock the target for the read-modify-write window and never submit without ifMatch.

Lifecycle: the state machine you're actually building

Name the states before the edge cases pile up. Change-proposal systems tend to converge on something like this:

happy path:
  draft -> pending-review -> approved -> executing -> applied
 
exit pending-review:
  -> rejected
  -> expired           (TTL elapsed)
  -> content-revised   (Bundle updated in place; stay in review on the new version)
  -> superseded        (a *different* proposal lineage replaces this one)
 
exit executing:
  -> failed -> pending-review   (world changed; re-render, re-summarize, re-review)

This is the full async machine. Sync mode collapses it: pending-review lasts as long as the diff is on screen, expired and superseded rarely matter, state lives in the session. Same states, shorter path. rejected and failed still exist in chat (clinician says no; transaction 412s). Don't treat sync as fire-and-forget.

For async, map states onto Task.status plus a businessStatus for finer distinctions. Two transitions trip people up:

  • Terminal failed returns to review, not silent retry. Transient 5xx: executor retries with backoff. Version conflict: run the automatic re-basing classifier first; human only if that fails. Validation failure: world changed in a way that matters; re-render and re-review.
  • superseded replaces lineage, not in-place revise. If Tuesday's pending proposal should die in favor of an unrelated Wednesday draft, cancel Tuesday's Task with businessStatus=superseded and link the replacement: proposal-replaces on the successor Bundle's meta (valueReference → abandoned Bundle), and successor Task basedOn → parent Task when Tasks wrap both sides. Ordinary revise (same proposal, changed contents) updates the existing Bundle; it does not burn a supersession link.

Versioned Bundles, not immutable drafts

The proposal Bundle can be mutable. Each PUT to Bundle/draft-proposal-42 mints a new meta.versionId; prior snapshots stay at Bundle/draft-proposal-42/_history/{vid}. What must stay immutable is the snapshot that was reviewed or executed. Approval records, persisted rendered view (or its hash), execution Provenance, and the transaction response all pin to that versioned reference. "What did the reviewer approve?" and "what did the executor run?" are vread questions, not "never edit the Bundle id."

Revise gets simpler: Task.focus keeps pointing at Bundle/draft-proposal-42. Bundle history is the revise chain; "open prior version" is _history, not a hand-maintained replaces graph for one-line tweaks. Cross-proposal supersession still needs an explicit extension link.

The invariant: approval and execution are version-conditioned. Reviewer approves version N (record the versioned id alongside approval). Executor runs N, not whatever is current on Bundle/42. If content moved to N+1 after the approval screen rendered, that approval is stale. Same TOCTOU shape as target-resource drift, applied to the proposal itself. Mutating the Bundle without regenerating summaries, re-validating, and clearing prior approval is still wrong.

Accept, reject, or revise

Accept and reject are straightforward. Reviewers also want a middle path: "approve most of this, but change the follow-up to six weeks" or "drop the dose titration, keep the lab."

Three revise paths, from lightest to heaviest rewrite:

  • Reject and regenerate. Reviewer rejects with a reason; agent (or human under their own authority) writes a new version of the same Bundle, or a new Bundle with proposal-replaces if the lineage closes. Same-id regenerate keeps Task.focus unchanged; history retains the rejected snapshot.
  • Human applies directly. Reviewer drops out and writes under their own authority. Fine for trivial corrections; bypasses validation, rendering, and Provenance for whatever they touch by hand.
  • Inline revise against the proposal. Reviewer chats with (or structurally edits) the pending Bundle: "keep the BMP and appointment, change the dose target to 15 mg." An editing agent, constrained like the proposing agent, PUTs updated mutations onto the same Bundle id, regenerates summaries from the new dry-run diff, re-validates, clears approval bound to the prior version. Task stays; businessStatus signals content-revised. "What changed since I looked?" is a diff of Bundle _history versions.

The third path is where product pressure usually lands once accept/reject alone produces reject-regenerate loops for one-line tweaks. Three invariants: every reviewable snapshot is a Bundle version; approval/Provenance pin to that version, not "current by id"; summaries regenerate from Bundle + dry-run, never hand-edited in the UI. Editing the narrative without changing the mutations is how you get a friendly label on a hostile diff.

Use proposal-replaces and successor-Task basedOn for lineage changes only (abandon one proposal for another id, promote a sync draft that replaces a different pending Task). In-lineage revise is mutation + version pins.

Rendering: generic vs. bespoke

Someone has to read the diff, whether it appears inline in chat or in a work queue. First fork: does the renderer understand Bundles, or your use cases?

A generic Bundle renderer walks entries by method: creates show the new resource, deletes show what dies, patches show computed before/after (fetch current, apply patch in memory, diff). You need a FHIRPath Patch engine anyway for semantic dry-run and executor fallback; rendering reuses it. One-time investment, covers future use cases, renders what will execute.

Its ceiling is comprehension. That dose-quantity path rendered as a structural diff is legible to an integration engineer and hostile to a nurse. Clinical reviewers think in domain terms ("10 mg daily → 20 mg daily").

Bespoke renderers per use case speak the reviewer's language but drift from the Bundle they summarize. A renderer that says "titrate lisinopril to 20 mg" while the Bundle also orders a BMP is a review process that approves things nobody saw.

Start with a middle tier: generated intent summaries. A separate summarization pass, with the Bundle and dry-run diff as its only input, produces the global summary and per-entry lines (proposal-summary, entry-intent in the example above). Carrying them on the Bundle keeps the proposal self-contained. If a Task wraps it, project Task.description and Task.reasonCode from those fields. Render summaries as headers above the generic diff.

Two rules keep summaries honest. Summarize the artifact, not the agent's stated intent. Input is the Bundle, never the proposing agent's chat. And every entry gets a summary line, or show a visible "unsummarized changes" warning. Summaries are labels on the diff, not a substitute for it.

When drift or revise moves the dry-run base, regenerate summaries onto the new Bundle version or mark them stale. Mechanical auto-rebase of target resources that leaves semantic intent unchanged can keep existing summaries; anything that changes what the reviewer would read in the diff must not.

Full stack: generic diff always visible; generated summaries as default comprehension layer; bespoke renderers only where a use case earns the investment. Bespoke renderers refuse entry shapes outside their vocabulary and fall back to raw diff. Persist rendered view (or hash) with approval, keyed to the Bundle versionId it was computed from.

Drift: the resource moved while the draft sat in the queue

Proposals have a lifetime; the resources they target keep changing. Sync proposals: drift window is seconds, preconditions rarely fire. Everything below is the async gap. Someone edits the record between draft and approval. What executes may not match what the agent reasoned about, or what the reviewer thought they approved. The same shape applies to the proposal Bundle itself. If entries change under a pending review (PUT bumps versionId), an approval pinned to an older version is stale.

Most of this section covers target-resource drift, mostly patches (ifMatch, derived expected values, automatic re-basing). Creates and deletes drift too, differently, and the renderer has to surface those cases even when execution would silently succeed.

World drift on creates and deletes

Patches pin a version. Creates and deletes pin a world assumption: that the thing does not yet exist, or that it still does.

  • Creates and ifNoneExist. Conditional create is the right idempotency tool for retry. If a matching resource already exists, the server returns it instead of minting a duplicate. Good for double-apply protection; it does not mean the clinical situation is unchanged. Agent proposed a BMP because none was pending; overnight someone else ordered one. At execution the ifNoneExist create no-ops successfully, which is correct for retry and wrong as a silent review outcome. Render "would create" vs "already exists (conditional create would no-op)" as first-class states.
  • Deletes. ifMatch still protects when the target was edited. Two other states matter. The target may already be gone, or it may still exist but no longer match what the agent meant to remove. "Already gone" is often a safe execution no-op and again wrong as an invisible review outcome. Render "will delete (current snapshot)" vs "target already absent."

Preconditions that make execution safe are not a substitute for rendering what will happen. Include create-collision and delete-absent checks in the approval-time drift pass alongside versionId comparison.

Patch strictness spectrum:

  • Pin and fail. Execute with request.ifMatch; any drift fails the entry and, in a transaction, the whole Bundle. Safe, noisy on busy resources unless you add automatic re-basing.
  • Detect and re-review. At approval, compare current versionIds to pinned ones; recompute diff if drifted. Approval covers the rendered effect, so recompute when the base changes.
  • Trust the predicate. Resolve FHIRPath against whatever version exists at execution. Tolerable for low-stakes fields; dangerous as default.

FHIRPath Patch feels drift-tolerant, which erodes discipline. A predicate-based dose path survives reordering until a second daily sig appears and the where matches twice. Pin with ifMatch, treat drift as a review event, add a TTL. A draft old enough is stale by definition.

For highest-stakes patches, confirm the field still holds what the agent saw. The ifMatch pin names the version; fetch _history and evaluate the patch path against it to derive the draft-time value, then compare to current. Complements ifMatch: version pin makes the comparison race-free, value comparison makes failure legible. Verify on your server that weak etags map to fetchable _history; pin resolved versionId at draft time if etags are opaque; store a literal only where history reads fail.

Automatic re-basing when version pins fail

Pin-and-fail is the right default, but "operationally noisy" hides real cost. In a busy EHR, versionId advances constantly. A proposal that sat in queue for a day will almost certainly 412 on execution even when the pending change is still correct and intervening edits were unrelated. Sending all of those back for full re-review drowns the queue. The reviewer already approved "increase lisinopril to 20 mg" and now sees the same diff because an unrelated status note changed underneath.

Put an automatic re-basing layer between the 412 and the review queue. When execution fails on ifMatch, classify before routing to a human:

  1. Fetch the current resource.
  2. Dry-run the stored FHIRPath Patch in memory (same engine as the renderer).
  3. Check whether the patch still means the same thing:
  • Predicate resolves to exactly one element? (Zero or many: stop. Never auto-rebase ambiguity.)
  • Field still holds what the agent saw? Evaluate path against pinned _history, then current; differ on a touched path: stop.
  • Would the result pass the same validation profile as at draft time?

If all pass, semantic intent is unchanged; only the base version moved. Rebase request.ifMatch, re-run approval-time drift check and rendered diff, retry without pulling a human back. Summaries can stay. Log the rebase.

If any check fails, that's genuine drift. Recompute diff, regenerate summaries, attach OperationOutcome, return to review. Keeping draft-time summaries when before/after changed ("10 mg → 20 mg" when dose is already 15) is how the comprehension layer starts lying.

Narrow automation on purpose. No "smart merge" of arbitrary concurrent edits. Only auto-retry when a predicate-based patch would have hit the same field with the same intended value had the unrelated version bump not happened. Build the classifier before you scale async volume.

Event-driven re-basing: react to drift, don't wait for it

The classifier above is lazy: it runs at the 412, at approval time or later. A proposal invalidated at 2 p.m. can sit in queue looking healthy until someone opens it at 5. FHIR Subscriptions let you run the same classifier when a watched resource changes.

For each pending proposal, subscribe to resources its validity depends on (broader than pinned targets: allergies the agent checked, observations that justified the dose, absence of conflicting orders). When something fires, re-evaluate. Mechanical drift only: auto-rebase silently. Hard conflict: move to review now with recomputed diff and regenerated summaries. Clinical obsolescence: hand the changed world back to the generating agent for revise or supersession; subscriptions detect movement, not clinical wrongness.

Three caveats:

  • Subscriptions are optimization, not the correctness boundary. Delivery is best-effort; topic subscriptions are R5 with uneven support. Approval and execution checks stay authoritative; missed notifications degrade to "caught at the 412," never "executed stale."
  • Fan-out bites. Debounce, coalesce by patient, filter to material changes.
  • The watch set is yours to maintain. FHIR won't compute cross-resource dependencies for you.

Build this after the lazy classifier, not instead of it.

Access policies and delegation: who is doing this write?

Three identities touch a proposal: agent, approver, executor. Sync mode collapses the last two (approver is session user). Async pulls them apart in time, and bad authorization turns the review queue into privilege escalation. An agent that can't write MedicationRequest shouldn't get one written because someone rubber-stamped an approval UI.

Rule: execution runs under the reviewer's authority; reviewer must hold write access to every resource the Bundle touches, evaluated at execution time. Agent permissions govern what it can propose (and read). Reviewer permissions govern what can happen. FHIR doesn't standardize impersonated execution; assemble this from your platform (Medplum AccessPolicy, SMART scopes, on-behalf-of headers, token exchange). Execute in the reviewer's context, not a god-mode service account.

Provenance with multiple agents (author, verifier, onBehalfOf) answers "who decided this?" two years later, not just for audit hygiene.

Two asymmetries:

  • Approval authority can be narrower than write authority (controlled substances needing a second signature). That's review policy on the Task, not access control.
  • Agent read scope shapes proposal quality and is a leak surface. Draft Bundles embed data the agent read; apply read policy to drafts like any other resource.

Validation: how much do you trust the generator?

Draft Bundles sit on a spectrum of AI involvement; each point needs a different validation posture. More programmatic is safer. Models don't know your data-model quirks. Canonical identifier systems, extension URLs your profiles expect, that disenrollment closes the care team, cancels open orders, and ends coverage in dependency order. Those invariants live in code and people's heads. Builders encode them; prompts gesture.

Fully programmatic (no AI): deterministic event triggers a code-built changeset. Disenrollment fires cleanup that closes CareTeam, cancels open orders/appointments, ends Coverage. No model, but HITL still earns its place. Wide, consequential changesets deserve a human eyeball. HITL is for consequential automation, not just probabilistic automation.

AI via constrained tools: agent calls typed tools ("titrate dose," "order labs"); code builds the Bundle. Model picks what; code picks how. Right default for AI workflows. Limit: every new mutation shape is a tool you build.

AI-emitted FHIR: model emits entries or patches directly. Flexible; validation carries the weight. Minimum stack:

  • $validate entries against profiles; validate patch Parameters shape.
  • Deny-by-default path allowlist (Patient.telecom yes; Patient.link no).
  • Semantic dry-run: validate post-patch resource, not just operations.
  • Reference integrity for literals and urn:uuid internal refs.
  • Terminology where codes bind (hallucinated RxNorm passes structural validation).
  • Cross-resource dependency checks (FHIR won't do this for you).

Profiles constrain one resource; $validate checks instances, not relationships. Completeness rules (dose increase pairs with renal monitoring; follow-up references the lab it reviews; disenrollment cancels open orders) are yours. Bundle profiles with cross-entry FHIRPath invariants help for dependencies that travel together; validator support is spotty and server state ("or an active order already exists") is out of scope. GraphDefinition describes graphs but has almost no enforcement tooling. Custom code or CQL in practice.

AI-emitted FHIR gets hit hardest: dose without monitoring order, appointment without lab link, disenrollment forgetting open orders. Per-resource validation waves it through.

In-memory dry-run can't see server-side rules. Duplicate therapy, formulary, required Practitioner on MedicationRequest. For AI-emitted proposals, a dry-run target environment is architecture, not nice-to-have. Fork the patient record, apply the transaction, run the same server validation pipeline against resulting state. Reviewer sees diff plus fork OperationOutcomes. Fork is throwaway; production untouched until approval.

Validate at draft creation and at execution. Sandbox at draft; live re-check at execution because the fork may be hours stale. Validation answers "well-formed and permitted," not "clinically right." The reviewer is the semantic validator.

Atomicity and partial approval

FHIR transactions are all-or-nothing (accept all entries or reject all). Usually what you want is a ServiceRequest plus referencing Appointment that must not half-apply. But reviewers naturally say "approve the lab and follow-up, reject the dose."

Per-entry approval means entry groups: connected components of the urn:uuid reference graph, plus any groups the generator declares. Approve by group; execute one transaction Bundle per approved group.

Cheaper start: keep Bundles all-or-nothing and emit smaller single-concern proposals. Reject-and-regenerate replaces partial approval. Add entry groups when reviewer friction forces it, not before.

Execution: the mechanical details that bite

Approval fires an executor. Small decisions here separate robust from demo-ready:

  • Idempotent execution. Crash after commit but before status update will happen. ifNoneExist on creates (keyed on proposal-scoped identifier) gives conditional-create idempotency. Patches with ifMatch self-protect on replay (412); executor must distinguish replay from genuine drift by checking whether the target already reflects the patch.
  • Failure taxonomy. 412: re-basing classifier first, then review if that fails. 422: validation regression, back to review with OperationOutcome. 5xx: retry with backoff. "Execution failed" as one bucket guarantees humans re-approve blind.
  • Provenance inside the transaction. Entry whose target uses same urn:uuid refs as the changeset; server rewrite links audit to real ids. Record which proposal snapshot authorized the write (Bundle/[id]/_history/[vid]). Deletes are awkward. The target is gone; you may need AuditEvent in addition.
  • Result capture. Persist transaction-response Bundle with proposal record (Task.output async; alongside approved Bundle version sync).

The human is a failure mode too

Review gates catch agent mistakes in theory. In practice the dominant failure is the reviewer who stops looking. Automation bias is real. Defer to machine suggestions, miss errors you'd catch unaided. A gate at 98% approval trains rubber-stamping. Sync mode may be worse (one click between you and what you were already trying to do).

  • Tier friction to risk (one-click care-plan goal edits; dose titrations require expanding the diff before approve enables).
  • Watch telemetry. Time-to-approve → 0 and approval rate → 100% is either a great agent or theater. Instrument sync path too, not just Task timestamps.
  • Seed canaries (known-bad proposals in training/non-prod). If nobody catches them, high approval rate is ceremony.
  • Feed rejections back to agent evals. Rejection reasons with labels are the best training data you produce.

Agent inputs are attack surface too. Visit notes, faxes, patient messages can carry prompt injection with clinical payload. Review is the mitigation boundary. Injected instructions can propose anything; nothing executes without review. That only works if the diff shows everything and reviewers see source/evidence alongside it.

Prior art

Nothing exotic here:

  • CDS Hooks suggestions: create/update/delete actions, logically AND'd, applied per FHIR transaction rules. Sync change proposal ≈ suggestion card made durable and transaction-shaped. Async ≈ suggestion that outgrew the card.
  • FHIR's request/event split: intent: proposal | plan | order, Task as workflow spine. Spec authors assumed humans and time between intent and execution.
  • EHR pending-change queues: lab verification, order co-signature, chart correction. Same review-then-commit loop, minus machine-generated changeset.
  • Terraform plan/apply: intent as data, review, apply. FHIR version has better types.

Profile it

Most of this post is conventions: stored-but-inert transaction Bundle, versioned mutability with approval/Provenance/execution pinned to Bundle/[id]/_history/[vid], meta extensions (summary, reason, evidence, proposal-replaces), per-entry intents, _history-derived expected values, Task.focus → Bundle, restriction.period.end as expiry. Conventions in engineers' heads don't survive a second team.

Publish ChangeProposalBundle and ChangeProposalReviewTask profiles; enforce via $validate at draft and execution. Conventions become conformance another implementation can interoperate with instead of an essay to reverse-engineer.

Where this earns its complexity

This is a lot of machinery for an agent that files documents into a folder. Skip it there.

For clinical writes, the Bundle-as-proposal core (generate, validate, render, execute) pays off as soon as changes are consequential and the generator is something a human should check: a model, or deterministic automation that cascades across half a dozen resources. Sync copilot gets that much. Async adds the Task envelope, drift handling, TTLs, delegation, reviewer telemetry: agent at 2 a.m., clinician at 9.

If writes go straight to the API, the model's judgment is your last line of defense. Through change proposals, it's a draft. Drafts are allowed to be wrong.

Appendix: implementation checklist

Changeset representation

  • Transaction Bundle per proposal; creates/deletes as entries, updates as FHIRPath Patch Parameters
  • urn:uuid fullUrls + internal references for intra-proposal dependencies
  • request.ifMatch pinned to draft-time versionId on every update/patch/delete
  • request.ifNoneExist (keyed on a proposal-scoped identifier) on creates, for idempotent retry
  • Expected-current-value check on high-stakes patches: derive the draft-time value by evaluating the patch path against the pinned _history version (reuse the ifMatch versionId — nothing extra carried where history reads work; stored-literal fallback otherwise), compare to current — complements ifMatch, doesn't replace it
  • Bundle self-contained: summary, reason, per-entry intents, and evidence pointers carried as extensions on Bundle.meta and Bundle.entry (Bundle takes no root extensions) — never only on the Task
  • Proposal Bundle is versioned-mutable: revise via PUT on the same id; retain _history; do not mint a new Bundle id for ordinary edits
  • Approval, persisted rendered view (or hash), execution, and Provenance pin to Bundle/[id]/_history/[vid] — never to unversioned "current"
  • Lineage supersession only when abandoning one proposal for another id: proposal-replaces extension on successor meta + Task basedOn / parent marked superseded — not for in-lineage revise
  • 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 /Bundle accepts stored transaction blueprints on your server; if not, Binary wrapper or sidecar draft store
  • Patch fallback order: native FHIRPath Patch → native JSON Patch → executor-applied PUT + ifMatch with pessimistic lock on read-modify-write

Review envelope (async workflows; sync copilot review happens in-session, but still persists the approved Bundle + Provenance)

  • Task with focus → draft Bundle; human-facing fields (description, reasonCode, for) projected from the Bundle's proposal-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; also surface create-collision (ifNoneExist would no-op against a non-lineage match) and delete-absent; 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, patched fields are unchanged since the pinned _history version, and validation still passes — otherwise return to review with recomputed diff and regenerated summaries
  • 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
  • Revise = PUT same Bundle (new versionId) + regenerate summaries + re-validate + invalidate approvals bound to prior versions; Task.focus unchanged
  • Inline revise path (when built): editing agent updates mutations on the same Bundle id; summarization re-runs; content-revised signal on the Task; prior-version approval cleared before re-review
  • Executor runs the approved Bundle version (vread / ifMatch on the proposal), not whatever is newest on that id
  • Read access policy applied to drafts (they embed data the agent read)

Rendering

  • Generic renderer: dry-run patches, before/after diff, always available; create entries show would-create vs already-exists; delete entries show will-delete vs already-absent
  • Generated intent summaries (global + per-entry) from the Bundle only — never from the proposing agent's stated intent; unsummarized entries flagged visibly
  • Regenerate summaries whenever the dry-run base or Bundle version changes materially (hard drift, revise PUT); mechanical target-resource auto-rebase may keep them
  • Bespoke renderers fail closed on unrecognized entry shapes
  • Persist rendered view (or hash) with the approval, keyed to the approved Bundle versionId

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
  • $validate entries 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/$validate are 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 (AI author, human verifier, onBehalfOf) inside the transaction, targets via urn:uuid
  • Persist transaction-response Bundle with the proposal record (Task.output when async)
  • Retain rejected/expired drafts deliberately, per your retention policy — they're PHI-bearing eval data, not disposable scratch
  • AuditEvent for 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 Task timestamps
  • 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)