You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Bundle entries cannot perform FHIR conditional interactions. #503 fixed the parser that made
them corrupt data and made the batch path refuse them explicitly with a 400; this issue is the
work of actually resolving them.
The machinery already exists and is unused from the bundle path:
ConditionalStorage (crates/persistence/src/core/storage.rs:955) provides conditional_create, conditional_update, conditional_delete and conditional_patch, returning Created / Updated / Exists / NoMatch / MultipleMatches.
Five types implement it, and create_fhir_router already bounds S with it
(crates/rest/src/routing/fhir_routes.rs:209) — batch_handler (batch.rs:63) just declares a
narrower bound. But S3's impl is a stub: conditional_create (s3/storage.rs:1534), conditional_update (:1548) and conditional_delete (:1563) each return BackendError::UnsupportedCapability, and it does not override conditional_patch. So four
backends resolve conditionals; one satisfies the bound and refuses at runtime — the same
bound-satisfied/runtime-refuses shape as transaction: s3-elasticsearch advertises and accepts transaction Bundles it cannot honour — a timed-out bundle commits 466 of 473 entries behind a 408 #489.
The single-resource handlers are line-for-line reference implementations: conditional_update_handler (update.rs:262), conditional_delete_handler (delete.rs:239),
conditional create at create.rs:114-125, conditional_patch_handler (patch.rs:177).
crates/rest/README.md used to advertise ifNoneExist as a supported bundle-entry header; that was
corrected by PR #512's third commit and now states the position accurately and points here. The
CapabilityStatement half is still true. The spec's own transaction example — vendored in this repo at crates/fhir/tests/data/json/R4/bundle-transaction.json — uses all three conditional forms.
What is required
Widen batch_handler / process_batch / process_batch_entry to + ConditionalStorage. Note
the test module's run_batch<S> (batch.rs:1883-1889) must be widened in lockstep, and both
test mocks (MockStorage ~:1373, DelayStorage ~:1745) need impl ConditionalStorage —
otherwise the whole #[cfg(test)] mod tests fails to compile.
Three executors on the batch path — conditional update (upsert = true, matching update.rs:321),
conditional delete (Deleted | NoMatch -> 204 per R4 §3.1.0.7.1 and delete.rs:275-279),
conditional create (via request.ifNoneExist, which process_batch_entry does not read today). MultipleMatches -> 412, which is binding because capabilities.rs:373 elects conditionalDelete: "single".
Decide the percent-decoding question. request.url arrives encoded; the backends' parse_simple_search_params (sqlite/storage.rs:2585-2597) splits on & then splitn(2, '=')
with no decoding, while the HTTP handlers get a decode for free from axum's Query. The HTTP
handlers also round-trip through HashMap<String, String> (update.rs:282-286), which silently
drops repeated keys — so "mirror the HTTP path exactly" and "be correct" are not the same choice.
ifMatch on a conditional entry has no defined meaning (the URL names no version). Refuse it.
Concurrency: conditional_update is read-then-write inside the backend, not a compare-and-swap.
Two conditional writes in one bundle can resolve against the same pre-bundle state and both
write. Extend the batch_concurrency carve-out (batch.rs:157-192) the way the
StructureDefinition case already does.
Audit attribution: ConditionalDeleteResult::Deleted is a unit variant carrying no id
(core/storage.rs:902-909), and emit_entry_audit (batch.rs:885-902) derives the entity from
the URL id — empty on a conditional entry — or from result.resource, absent on a delete. A
delete-by-criteria would emit an AuditEvent naming nothing, under a server whose audit story is
IHE BALP. The same gap exists on the HTTP path (delete.rs:266-283 inserts no AuditResponseContext, unlike the instance delete at :215-223).
The transaction path is a separate, harder problem
#503 refuses conditional URLs on the transaction path outright. Resolving them there is blocked:
SqliteBackend::conditional_update (sqlite/storage.rs:2634-2686) calls self.update(...), not
the open Transaction, whose trait (core/transaction.rs:147-189) exposes only
create/read/update/delete. Calling it from inside process_transaction would commit outside the
bundle's atomic scope — a rolled-back bundle would leave the conditional write behind.
R4 §3.1.0.11.2 additionally requires that overlapping identities resolved from conditional
update/delete SHALL fail the transaction, which needs a pre-resolution pass over the ordered
entries — the shape fix(transaction): resolve conditional references before execution #467 chose for conditional references.
MongoDB is the exception and must not be regressed. It already resolves ifNoneExistinside
the session: process_bundle_entry_transaction (mongodb/storage.rs:2675) calls find_matching_resources_in_bundle_transaction, which is session-scoped
(.session(&mut *session), :3323-3345), returning 201 / 200 / 412. Covered by mongodb_integration_transaction_bundle_conditional_headers
(crates/persistence/tests/mongodb_tests.rs:1059-1105). #503's transaction refusal is therefore
scoped to URL-borne criteria only and deliberately leaves ifNoneExist alone. SQLite and Postgres
silently ignore ifNoneExist in a transaction and create a duplicate at 201 — that gap wants a
backend capability predicate, not a REST-layer constant.
Related, deliberately not folded in
Gating the CapabilityStatement per backend. This turned out to be bigger than a conditional-flag
fix — the literal at capabilities.rs:353-374 claims ~16 capabilities unconditionally, and CapabilityProvider (the trait designed to replace it) is implemented only by CompositeStorage,
whose resource_caps is an empty HashMap that is never populated. Filed separately as capabilities: /metadata is a hardcoded literal claiming ~16 capabilities unconditionally, and CapabilityProvider is a stub that cannot yet replace it #514;
neither issue blocks the other. Landing this one makes the conditional flags true on the batch
path for sqlite/postgres/mongodb, leaving S3 the backend the literal still misrepresents.
BundleEntry.if_none_match (core/transaction.rs:266-268) is populated by parse_bundle_entry
and read by nothing. PR fix(rest): parse bundle entry URLs without their query string #512 corrected the README to say so; the field itself is still
unimplemented rather than pretend-supported.
The three backend parse_url copies (sqlite/storage.rs:3277, postgres/storage.rs:2943, mongodb/storage.rs:3475) are byte-equivalent and query-blind. #503 makes them unreachable from
bundles for non-GET query-bearing URLs; converging them into one shared helper in helios_persistence::core (alongside the existing bundle_if_match_gate precedent) is a clean
follow-up.
[type]/$op entry URLs (e.g. POST ValueSet/$lookup, present in the spec's own vendored fixture)
parse to ("ValueSet", "$lookup") and the POST arm ignores the id, creating a bogus ValueSet.
Same family, different symptom; needs an operation-dispatch design.
Where it came from
Split out of #503 during implementation, to keep that fix to the parser defect and the data-corruption
guards it required.
Summary
Bundle entries cannot perform FHIR conditional interactions.
#503fixed the parser that madethem corrupt data and made the batch path refuse them explicitly with a
400; this issue is thework of actually resolving them.
The machinery already exists and is unused from the bundle path:
ConditionalStorage(crates/persistence/src/core/storage.rs:955) providesconditional_create,conditional_update,conditional_deleteandconditional_patch, returningCreated/Updated/Exists/NoMatch/MultipleMatches.create_fhir_routeralready boundsSwith it(
crates/rest/src/routing/fhir_routes.rs:209) —batch_handler(batch.rs:63) just declares anarrower bound. But S3's impl is a stub:
conditional_create(s3/storage.rs:1534),conditional_update(:1548) andconditional_delete(:1563) each returnBackendError::UnsupportedCapability, and it does not overrideconditional_patch. So fourbackends resolve conditionals; one satisfies the bound and refuses at runtime — the same
bound-satisfied/runtime-refuses shape as transaction: s3-elasticsearch advertises and accepts transaction Bundles it cannot honour — a timed-out bundle commits 466 of 473 entries behind a 408 #489.
conditional_update_handler(update.rs:262),conditional_delete_handler(delete.rs:239),conditional create at
create.rs:114-125,conditional_patch_handler(patch.rs:177).conditional_updatetakessearch_params: &stras a rawk=v&k=vstring and runs the searchitself (
SqliteBackend::find_matching_resources,sqlite/storage.rs:2761-2792), so the bundlepath needs no
SearchProviderbound and never holds a registry read guard across an await —which matters because batch entries now run concurrently under
buffered()(batch: entries are processed sequentially and BundleProvider::process_batch is dead on every backend — a batch over ~90 entries times out on S3 #501).So the query string in
Patient?identifier=xis already in exactly the shape the trait wants.Why this is honesty-relevant
crates/rest/src/handlers/capabilities.rs:370-373hard-codes, for every resource type:crates/rest/README.mdused to advertiseifNoneExistas a supported bundle-entry header; that wascorrected by PR #512's third commit and now states the position accurately and points here. The
CapabilityStatement half is still true. The spec's own transaction example — vendored in this repo at
crates/fhir/tests/data/json/R4/bundle-transaction.json— uses all three conditional forms.What is required
batch_handler/process_batch/process_batch_entryto+ ConditionalStorage. Notethe test module's
run_batch<S>(batch.rs:1883-1889) must be widened in lockstep, and bothtest mocks (
MockStorage~:1373,DelayStorage~:1745) needimpl ConditionalStorage—otherwise the whole
#[cfg(test)] mod testsfails to compile.upsert = true, matchingupdate.rs:321),conditional delete (
Deleted | NoMatch-> 204 per R4 §3.1.0.7.1 anddelete.rs:275-279),conditional create (via
request.ifNoneExist, whichprocess_batch_entrydoes not read today).MultipleMatches-> 412, which is binding becausecapabilities.rs:373electsconditionalDelete: "single".request.urlarrives encoded; the backends'parse_simple_search_params(sqlite/storage.rs:2585-2597) splits on&thensplitn(2, '=')with no decoding, while the HTTP handlers get a decode for free from axum's
Query. The HTTPhandlers also round-trip through
HashMap<String, String>(update.rs:282-286), which silentlydrops repeated keys — so "mirror the HTTP path exactly" and "be correct" are not the same choice.
ifMatchon a conditional entry has no defined meaning (the URL names no version). Refuse it.conditional_updateis read-then-write inside the backend, not a compare-and-swap.Two conditional writes in one bundle can resolve against the same pre-bundle state and both
write. Extend the
batch_concurrencycarve-out (batch.rs:157-192) the way theStructureDefinition case already does.
ConditionalDeleteResult::Deletedis a unit variant carrying no id(
core/storage.rs:902-909), andemit_entry_audit(batch.rs:885-902) derives the entity fromthe URL id — empty on a conditional entry — or from
result.resource, absent on a delete. Adelete-by-criteria would emit an AuditEvent naming nothing, under a server whose audit story is
IHE BALP. The same gap exists on the HTTP path (
delete.rs:266-283inserts noAuditResponseContext, unlike the instance delete at:215-223).The transaction path is a separate, harder problem
#503refuses conditional URLs on the transaction path outright. Resolving them there is blocked:SqliteBackend::conditional_update(sqlite/storage.rs:2634-2686) callsself.update(...), notthe open
Transaction, whose trait (core/transaction.rs:147-189) exposes onlycreate/read/update/delete. Calling it from inside
process_transactionwould commit outside thebundle's atomic scope — a rolled-back bundle would leave the conditional write behind.
update/delete SHALL fail the transaction, which needs a pre-resolution pass over the ordered
entries — the shape fix(transaction): resolve conditional references before execution #467 chose for conditional references.
MongoDB is the exception and must not be regressed. It already resolves
ifNoneExistinsidethe session:
process_bundle_entry_transaction(mongodb/storage.rs:2675) callsfind_matching_resources_in_bundle_transaction, which is session-scoped(
.session(&mut *session),:3323-3345), returning 201 / 200 / 412. Covered bymongodb_integration_transaction_bundle_conditional_headers(
crates/persistence/tests/mongodb_tests.rs:1059-1105).#503's transaction refusal is thereforescoped to URL-borne criteria only and deliberately leaves
ifNoneExistalone. SQLite and Postgressilently ignore
ifNoneExistin a transaction and create a duplicate at 201 — that gap wants abackend capability predicate, not a REST-layer constant.
Related, deliberately not folded in
fix — the literal at
capabilities.rs:353-374claims ~16 capabilities unconditionally, andCapabilityProvider(the trait designed to replace it) is implemented only byCompositeStorage,whose
resource_capsis an emptyHashMapthat is never populated. Filed separately as capabilities: /metadata is a hardcoded literal claiming ~16 capabilities unconditionally, and CapabilityProvider is a stub that cannot yet replace it #514;neither issue blocks the other. Landing this one makes the conditional flags true on the batch
path for sqlite/postgres/mongodb, leaving S3 the backend the literal still misrepresents.
BundleEntry.if_none_match(core/transaction.rs:266-268) is populated byparse_bundle_entryand read by nothing. PR fix(rest): parse bundle entry URLs without their query string #512 corrected the README to say so; the field itself is still
unimplemented rather than pretend-supported.
parse_urlcopies (sqlite/storage.rs:3277,postgres/storage.rs:2943,mongodb/storage.rs:3475) are byte-equivalent and query-blind.#503makes them unreachable frombundles for non-GET query-bearing URLs; converging them into one shared helper in
helios_persistence::core(alongside the existingbundle_if_match_gateprecedent) is a cleanfollow-up.
[type]/$opentry URLs (e.g.POST ValueSet/$lookup, present in the spec's own vendored fixture)parse to
("ValueSet", "$lookup")and the POST arm ignores the id, creating a bogus ValueSet.Same family, different symptom; needs an operation-dispatch design.
Where it came from
Split out of #503 during implementation, to keep that fix to the parser defect and the data-corruption
guards it required.