Skip to content

fix(rest): render batch entry failures through the single-resource error mapping - #516

Open
aacruzgon wants to merge 6 commits into
mainfrom
fix/504-batch-entry-issue-codes
Open

fix(rest): render batch entry failures through the single-resource error mapping#516
aacruzgon wants to merge 6 commits into
mainfrom
fix/504-batch-entry-issue-codes

Conversation

@aacruzgon

@aacruzgon aacruzgon commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Every error a batch entry could produce was built by one helper that hardcoded its OperationOutcome
issue code, so a scope denial, a missing resource, a malformed entry and an unsupported method all
reached the client as code: "processing" — distinguishable only by response.status and free-text
English. OperationOutcome.issue.code is bound required to http://hl7.org/fhir/ValueSet/issue-type
in all four bundled versions, and the ElementDefinition adds an unqualified SHALL: "The system that
creates an OperationOutcome SHALL choose the most applicable code from the IssueType value set."

Nineteen call sites emitting one code is the absence of a choice.

The issue proposes an issue-code argument on create_error_result. That treats the symptom. The
defect is that create_error_result existed at all: a second renderer of failure-to-OperationOutcome,
written beside the one IntoResponse already uses. entry_error (batch.rs:1533) is the proof —

let (status, _code, message) = RestError::from(err).client_response();

— it computed the correct code and bound it to _code, after which the wrapper stamped processing
over the result. And client_response's own doc comment already claims the parity that was missing:
"the single source of truth for how a RestError is surfaced to callers, shared by IntoResponse
and the batch/transaction handler so both sanitize identically
."
That sentence was false.

So the second renderer is deleted rather than parameterised. Giving it an argument keeps two mappings
agreeing by hand — the arrangement #502 died of.

This branch now carries #481

The diff below is #504's four commits plus #481's one. #481 (fix/478-bundle-get-search, "execute
search-style GET bundle entries as searches") was merged into this branch on 2026-08-07 and its branch
deleted. A later rebase onto current main flattened that merge, so its work is the top commit here —
+393/-16 across 5 files, including handlers/search.rs, which nothing else in this PR touches.

#481 reads MERGED on GitHub, but it was merged into this branch, not into main. Its code reaches
main through this PR and the four below it. Reviewing #516 means reviewing that commit too.

The conflict the Notes below anticipated resolved exactly as predicted: both of #481's _
code-discard call sites became entry_failure(e), and deleting create_error_result made that a
compile error rather than a silent reintroduction of processing. #518 pins both with tests.

Changes

One funnel. RestError::client_outcome() is now the only place a RestError becomes an
OperationOutcome. IntoResponse renders the pair as an HTTP body; handlers::batch renders it as a
Bundle.entry.response.outcome. Neither builds its own. Four functions deleted
create_error_result, entry_error, validation_failure_message, EntryMethodRefusal::status()
two added. Each call site constructs the same RestError its transaction twin already constructs,
so the arms cannot report different codes because there is no second table to disagree with.

Two new RestError variants, children of BadRequest's invalid in the issue-type hierarchy
rather than alternatives to it: MissingElement (400 + required) and InvalidElementValue
(400 + value). The crate could say neither before. MissingElement is used only where the SD gives
min=1; deliberately not for an absent Bundle.entry.resource, which is 0..1 with only R5/R6's
bdl-3c requiring it — a call site serving four versions must not assert a rule two of them lack.
Invariant keys stay in doc comments and off the wire, because bdl-3 does not exist in R5 or R6.

Entry failure Status Was Now
request absent 400 processing required
request.method absent 400 processing required
request.method not an http-verb code 400 processing value
request.url absent 400 processing required
request.url names nothing 400 processing value
resource absent on POST/PUT 400 processing invalid
PUT/DELETE URL names no instance 400 processing value
conditional criteria in the URL 400 processing not-supported
insufficient scope 403 processing forbidden
target not found 404 processing not-found
HEAD 405 processing not-supported
write validation failed 422 processing the validator's own issues
PATCH 501 processing not-supported
storage errors (×5 sites) unchanged processing whatever client_response computed
ifMatch failed 412 conflict untouched — already correct

The 403 is the sharpest row: forbidden is a child of security, and processing is not an ancestor
of it in any version, so a client filtering code is-a security to trigger re-auth got a false
negative. Four other emitters in this codebase already say forbidden for the identical denial.

The 422 is the lossiest case. check_write returns a fully-formed multi-issue outcome — per-issue
code, severity and expression (the FHIRPath location). The batch arm joined issue[].details.text
with "; " and re-wrapped it under processing. The transaction arm never did: it propagates with a
bare ?, reaching the branch whose comment already states the rule. So an identical bundle returned
typed codes and FHIRPath expressions as a transaction and one English sentence as a batch, decided
purely by Bundle.type. The interception now lives in client_outcome above client_response,
which is load-bearing: client_response's own ValidationFailed arm returns (422, "processing", "Resource validation failed"), so routing the 422 through the code table would reproduce the defect
with a shorter message.

Two exposed inconsistencies (commit 4): status_text had no arm for 413/429/503/504, all reachable,
so an exhausted pool rendered "503 Unknown" beside a correct transient; and
extract_outcome_description read only details.text, while the 412 gate writes diagnostics — so a
failed ifMatch produced an AuditEvent with no outcomeDesc at all.

On the element this writes into

Bundle.entry.response.outcome carries a comment, byte-identical in all four bundled versions and
generated into the model (crates/fhir/src/r4.rs:10011):

This outcome is not used for error responses in batch/transaction, only for hints and warnings. In a
batch operation, the error will be in Bundle.entry.response, and for transaction, there will be a
single OperationOutcome instead of a bundle in the case of an error.

Four things. It is a comment, not an invariant — no bdl-* constrains that element, verified by
enumerating every Bundle constraint in all four versions. HFS has placed error outcomes there since
before this stack, pinned by test_batch_error_outcome_in_response_not_resource, so this changes the
code and not the placement. "The error will be in Bundle.entry.response" holds: response.status
still carries it and outcome is a sibling under the same response. And the SHALL applies to any
OperationOutcome the system creates — if HFS creates one here it must code it correctly regardless of
whether it was obliged to create one.

Testing

cargo test -p helios-rest   →  1103 → 1109 passed, 0 failed
cargo fmt --all --check     →  clean

Six net-new tests, four compile-forced rewrites, five assertion-only extensions.

Verified non-vacuous, with the two disable runs isolated from each other:

  • Disable 1entry_failure reverted to a hardcoded processing outcome, ValidationFailed
    short-circuit kept: 8 unit + 3 integration tests fail, e.g. left: String("processing"), right: "forbidden" / "not-found" / "required" / "exception". The 422 leg stays green (7 passed),
    which is what makes the two runs independent.
  • Disable 2validation_failure_message restored at both sites: exactly one test fails,
    left: [("processing", "")], right: [("structure", "Patient.bogusElement")].
  • The refusal tests keep their teeth for free: DelayStorage's writes are unimplemented!() and
    peak() == 0 is still asserted, so a refusal moved after dispatch panics rather than reporting a
    different code.

batch_if_match.rs:152 passing untouched is the evidence of restraint — the 412's conflict was
already correct and was deliberately not routed through the funnel.

Clippy: CI's invocation (ci.yml:497, which carries -A collapsible_if and seven sibling allow-flags) passes clean — exit 0, zero errors. Worth correcting explicitly, because this PR previously repeated the parent PRs' claim that the gate "still exits 101 on pre-existing lints tracked in #513": that is true of the bare cargo clippy --all-targets --all-features -- -D warnings, but not of what CI runs. All 42 crates/rest warnings and all six files that fail the bare form sit inside the allowed lint families, so the Linting job was never going to fail. Separately measured: crates/rest lint set is 42 → 42, zero introduced, zero removed against the base with forced recompilation.

Notes

Closes #504

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.61224% with 46 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/rest/src/handlers/batch.rs 91.38% 38 Missing ⚠️
crates/rest/src/error.rs 80.95% 4 Missing ⚠️
crates/rest/src/handlers/search.rs 77.77% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@aacruzgon
aacruzgon force-pushed the fix/504-batch-entry-issue-codes branch from 5605ed9 to 6891b70 Compare August 16, 2026 16:28
@smunini
smunini force-pushed the fix/504-batch-entry-issue-codes branch from 6891b70 to a162abc Compare August 19, 2026 19:02
Base automatically changed from fix/502-batch-method-matching to main August 19, 2026 21:46
aacruzgon and others added 5 commits August 19, 2026 17:46
…ror mapping

Every error a batch entry can produce was built by one helper that hardcoded
its issue code, so a scope denial, a missing resource, a malformed entry and an
unsupported method all reached the client as `"code": "processing"`,
distinguishable only by `response.status` and free-text English.
`OperationOutcome.issue.code` is bound `required` to
`http://hl7.org/fhir/ValueSet/issue-type` in all four bundled versions, and the
ElementDefinition adds an unqualified SHALL: "The system that creates an
OperationOutcome SHALL choose the most applicable code from the IssueType value
set". Nineteen call sites emitting one code is the absence of a choice.

The fix is not an issue-code argument on `create_error_result`. The defect is
that `create_error_result` existed at all: a second renderer of
failure-to-OperationOutcome, written beside the one `IntoResponse` already
uses. Giving it an argument keeps two renderers agreeing by hand — the
arrangement #502 died of. So the second renderer is deleted.

`RestError::client_outcome` becomes the only place a `RestError` becomes an
OperationOutcome; `IntoResponse` renders the pair as an HTTP body and
`handlers::batch` as a `Bundle.entry.response.outcome`. Each call site now
constructs the same `RestError` its transaction twin already constructs, so the
two arms cannot report different codes for one failure because there is no
second table to disagree with. `entry_error` goes with it — it called
`client_response()`, bound the correct code to `_code` and discarded it, after
which the wrapper stamped `processing` over the result. Deleting that one
underscore corrects five sites by construction and makes `client_response`'s
own doc comment ("shared by IntoResponse and the batch/transaction handler so
both sanitize identically") true for the first time.

`EntryMethodRefusal::status()` is deleted too. #515 made the arms agree on the
refusal status by writing the number twice — once there, once implicitly in
`into_rest_error`'s choice of variant — and pinning the copies with a test.
There is now one function, so the status and the code are decided once. The
same move gives HEAD one message instead of two: `into_rest_error` computed a
message and discarded it on that arm, so the batch arm printed guidance the
transaction arm never showed. It now rides in `resource_type`, which
`MethodNotAllowed` renders, and both arms print it.

`EntryParseError::Malformed(String)` becomes `MissingRequest`/`MissingUrl`,
moving its two strings into helpers both arms call — an absent `request.url`
was caught by `parse_bundle_entry` on one arm and by `unwrap_or("")` on the
other, with different text for the same input.

Two new `RestError` variants, both children of `BadRequest`'s `invalid` in the
`issue-type` hierarchy rather than alternatives to it: `MissingElement` (400 +
`required`) for an absent mandatory element, and `InvalidElementValue` (400 +
`value`) for one present and unusable. The crate could say neither before.
`MissingElement` is used only where the SD gives `min=1` — `request` (mandatory
by bdl-3 in R4/R4B and transitively by bdl-3c in R5/R6), `request.method`
(1..1), `request.url` (1..1) — and deliberately NOT for an absent
`Bundle.entry.resource`, which is 0..1 with only R5/R6's bdl-3c requiring it: a
call site serving four versions must not assert a rule two of them lack. The
invariant keys stay in doc comments and off the wire, because `bdl-3` does not
exist in R5 or R6.

On the element this writes into. `Bundle.entry.response.outcome` carries a
comment, byte-identical in all four bundled versions and generated into the
model at `crates/fhir/src/r4.rs:10011`: "This outcome is not used for error
responses in batch/transaction, only for hints and warnings. In a batch
operation, the error will be in Bundle.entry.response". Four things about it.
It is a `comment`, not an invariant — no `bdl-*` constrains that element. HFS
has placed error outcomes there since before this stack, pinned by
`test_batch_error_outcome_in_response_not_resource`, so this commit changes the
code and not the placement. "The error will be in Bundle.entry.response" holds:
`response.status` still carries it and `outcome` is a sibling under the same
`response`, not a substitute. And the SHALL applies to any OperationOutcome the
system creates — if HFS creates one here it must code it correctly regardless
of whether it was obliged to create one.

Tests: a twelve-row table over the mapping, a cross-arm test asserting both
arms agree on status, code AND message for every method refusal, four
assertion-only extensions to the existing #515/#512 refusal tests, and a new
integration file asserting the code on the wire for every reachable class plus
byte-identical parity with `GET [base]/Patient/ghost`.

Verified non-vacuous. With `entry_failure` reverted to a hardcoded `processing`
outcome while every call site keeps its new argument, 8 unit tests and 3
integration tests fail — `left: String("processing"), right: "forbidden"`,
`right: "not-found"`, `right: "required"`, `right: "exception"`. The refusal
tests keep their teeth for free: `DelayStorage`'s write methods are
`unimplemented!()` and `peak() == 0` is still asserted, so a refusal moved after
dispatch panics rather than merely reporting a different code.

Closes #504
…tch entry

`check_write` returns `RestError::ValidationFailed { outcome }` carrying a
fully-formed multi-issue OperationOutcome: one issue per validator finding,
each with a code computed precisely (`Required` -> `required`,
`FixedValue`/`PatternValue`/`PrimitiveValue` -> `value`, `FhirpathConstraint`
-> `invariant`, `TerminologyBinding` -> `code-invalid`,
`UnknownSchema`/`UnknownProfile` -> `not-supported`, and twelve structural
kinds -> `structure`), its own severity, and an `expression` giving the
FHIRPath location of the element that failed.

The batch arm flattened all of it. `validation_failure_message` walked
`issue[].details.text`, joined the strings with "; ", and handed one sentence
to a wrapper that stamped `processing` over it. N coded, located issues became
one uncoded, unlocated issue.

The transaction arm never did this. It propagates the same error from the same
call with a bare `?`, reaching the branch whose comment already states the
rule: "ValidationFailed carries a fully-formed OperationOutcome (potentially
many issues from the write-path validator); surface it verbatim rather than
collapsing it to the generic single-issue shape." So an identical bundle
carrying an identical invalid resource returned typed codes and FHIRPath
expressions as a `transaction` and one English sentence as a `batch`, decided
purely by `Bundle.type`. This is not a new capability; it is the batch arm
being brought into line with a decision this crate documented and then applied
to two of its three write paths.

`validation_failure_message` is deleted with no replacement — the flattening
was its entire job. The interception lives in `RestError::client_outcome`,
above `client_response`, and that placement is load-bearing rather than
stylistic: `client_response`'s own `ValidationFailed` arm returns `(422,
"processing", "Resource validation failed")`, so any design that routes the 422
through the code table reproduces this defect with a shorter message. Putting
the special case in the funnel means no future caller can re-flatten it.

Nothing in helios-persistence changes. `BundleEntryResult.outcome` has always
been `Option<Value>` and `BundleEntryResult::error` has always taken an
arbitrary `Value`, so `validation_failure_message`'s doc-comment premise —
"batch entry outcomes are message-based" — was false at the type level, and
that false premise was the bug.

Known amplification, named rather than mitigated: `validation_outcome` is
uncapped, so an entry with N findings now carries N issues where it carried
one. That is exact parity with `POST [base]/[type]`, which has had the same
exposure since enforce mode shipped. Any bound belongs in `validation_outcome`,
covering both surfaces at once; a batch-only cap would re-create the very
divergence this commit closes.

Tests: `the_two_surfaces_report_the_same_validation_issues` posts the same
invalid resource to `POST /Patient` and as a one-entry batch and asserts the
two `(code, expression)` sets are equal, plus the literal pin that the set
contains `("structure", "Patient.bogusElement")` — without the pin, a
regression flattening *both* surfaces would satisfy the equality vacuously.
It is also the first coverage of the batch half of
`enforce_mode_rejects_invalid_writes_with_outcome`, which pinned the
single-resource half and left the batch half asserting only a status. A unit
test adds the ordering guarantee: `DelayStorage::create` is `unimplemented!()`
and `peak() == 0`, so a validation failure moved after dispatch panics rather
than quietly writing.

Verified non-vacuous, and independently of the previous commit's disable run.
With `validation_failure_message` restored at both sites:

    left: [("processing", "")]
    right: [("structure", "Patient.bogusElement")]

The two disable runs isolate different mechanisms: reverting `entry_failure`
to a hardcoded `processing` while keeping the `ValidationFailed`
short-circuit leaves this test green (7 passed), and restoring the flattener
fails only this one.

Refs #504
…iagnostics

Two one-line inconsistencies that threading the issue codes exposes, fixed here
rather than folded into the mechanism so each is reviewable on its own.

`status_text` has no arm for 413, 429, 503 or 504, all four of which a batch
entry can return with a correct issue code:
`BackendError::PoolExhausted`/`Unavailable`/`ConnectionFailed` map to 503
`transient` and `BackendError::Timeout` to 504 `timeout`. An entry hitting an
exhausted pool rendered `"status": "503 Unknown"` beside `"code":
"transient"`, which is incoherent once the code is right. Nothing noticed while
every entry carried `processing`:
`test_status_text_covers_known_and_unknown_codes` enumerates the mapped codes
and asserts 418 and "" fall through, but never checked that a code an entry can
actually produce has a phrase. It does now.

`extract_outcome_description` reads only `issue[0].details.text`, but the one
batch entry outcome #504 deliberately does not touch — the 412 from
`helios_persistence::core::preconditions::precondition_failed_entry` — writes
its text into `diagnostics`. So a failed `ifMatch` produced an AuditEvent with
no `outcomeDesc` at all. A `diagnostics` fallback closes that with no wire
change; `details.text` still wins when both are present.

The underlying shape split stands: eighteen outcomes on `details.text`, one on
`diagnostics`. Unifying it means editing helios-persistence and re-baselining
`batch_if_match.rs`, and is named as a non-goal rather than smuggled in here.
That the 412 gate's own tests pass untouched — `batch_if_match.rs:152` still
asserts `conflict` — is the evidence #504 did not over-reach into a code that
was already correct.

Tests: the `status_text` table gains the four codes plus a loop asserting no
mapped code falls through; the audit fallback is asserted directly against
`precondition_failed_entry`'s outcome. Verified non-vacuous — reverting the
four arms fails with `left: "Unknown", right: "Service Unavailable"`, and
removing the `.or_else` fails with `left: None, right: Some("stale tag")`.

Refs #504
The Error Handling section shows a single-issue OperationOutcome with `"code":
"not-found"` and says nothing about bundle entries, which until now could not
produce that code.

Adds a "Per-entry outcomes" subsection stating the contract the code now
enforces: a failed entry's `response.outcome` carries the same issue code the
equivalent single-resource request would return, because both are rendered by
one mapping; and an entry that fails enforce-mode write validation carries the
validator's own multi-issue outcome, with per-issue codes, severities and
`expression` locations, exactly as `POST [base]/[type]` does. Includes the full
table of codes a batch entry can emit, and notes that `required` and `value`
are children of `invalid` so a reader can see why three 400s carry three codes.

Two Current Limitations bullets gain their codes — conditional interactions are
`400 not-supported` in both arms (the same status *and* code, not merely the
same status), and HEAD is `405 not-supported`.

Two bullets are added for things this work does not fix, so a reader cannot
infer they were closed. A transaction entry that fails after dispatch is still
collapsed to `400 processing` with the real status stringified into the message,
because the backends discard the entry result at their `status >= 400` guard
and return `TransactionError::BundleError`, which carries neither status nor
code. And a bare type-level `GET Patient` in a batch entry is read as an
instance read with an empty id, answering `404 not-found` with the message
"Resource Patient/ not found" — a wart on the arm #478 is about to rewrite as a
search, left there rather than patched around.

Tests: none — documentation only. The CI-skip marker this repo uses on
docs-only pushes is deliberately omitted: this commit is the tip of a branch
carrying code commits, and GitHub reads that directive from the HEAD commit of
the push, so it would suppress CI for the whole PR.

Refs #504
A GET bundle entry may carry a read URL (`Patient/123`) or a search URL
(`Patient?name=x`, or bare `Patient` for an unfiltered type search) — the spec's
"read or search" wording for bundle GETs. Only the read form was implemented;
a search-style entry was dispatched as an instance read against an empty id.

Adds `parse_search_entry_url` and `searchset_result`, dispatches type-level GET
entries through `execute_search_bundle` in both arms, and lets a transaction's
GET searches see the bundle's own committed writes.

Rebased from `main` onto the #501/#489/#503/#502/#504 stack (originally opened
against `main` as #481; base is now `fix/504-batch-entry-issue-codes`). Five
conflict hunks in `batch.rs` and one keep-both append in `batch_conformance.rs`.
The resolution, recorded because it is more than textual:

- **The two new failure sites now render through `entry_failure`.** Both were
  written as `let (status, _, details) = e.client_response(); create_error_result(
  status.as_u16(), &details)` — the same code-discard #504 deleted from every
  other call site, which would have left search entries as the one path still
  answering `processing`. The transaction-arm site is the more consequential of
  the two: its loop bypasses the backend executor, making it the first
  *reachable* per-entry outcome on that arm, where #504 could accurately say
  none existed.
- **`parse_request_url`'s query strip is dropped as redundant.** This commit
  added `url.split('?').next()`; #503 had already landed the same fix with the
  empty-id write guards that make it safe. #512 predicted this exact outcome:
  "if this lands first, #481's strip becomes a no-op on rebase."
- **The GET arm keys off `BundleMethod::Get`**, not the raw `"GET"` string,
  since #502 replaced the string matcher with an exhaustive enum match.
- **The rollback fan-out** keeps #504's status/code threading and gains this
  commit's `.chain(&search_entries)`.
- **The batch unit tests' `DelayStorage` gains `SearchProvider`,
  `IncludeProvider` and `RevincludeProvider`**, and `run_batch`'s bound widens to
  match `process_batch`'s. Every method is `unimplemented!()`, the same lever the
  mock's write methods already use: no unit test drives a search entry, and one
  that started to would panic rather than silently exercise a stub. That pulls
  `parking_lot` in as a dev-dependency, because `search_param_registry` returns
  a `parking_lot::RwLock` and the crate's own code never names the lock type.

Tests: 1114 pass (1109 on the base + this commit's 5). Its own five tests are
happy-path only; the failure paths on both new call sites are covered by the
follow-up commit.

Refs #478
@smunini
smunini force-pushed the fix/504-batch-entry-issue-codes branch from a162abc to 01929ed Compare August 19, 2026 21:46
smunini
smunini previously approved these changes Aug 19, 2026

@smunini smunini left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

The core refactor is genuinely good work. RestError::client_outcome() as the single funnel is the right fix, not the issue's suggested "add an issue-code argument" — the let (status, _code, message) discard in entry_error was the actual defect, and deleting it corrects five call sites by construction. The 403 processing403 forbidden row matters most (forbidden is-a security; processing is not an ancestor of it in any version). Threading real codes also surfaced two latent bugs the PR fixes: status_text had no arm for 413/429/503/504, and extract_outcome_description never read diagnostics, so a failed ifMatch produced an AuditEvent with no outcomeDesc at all. The disable-runs proving the tests non-vacuous are the right discipline.

However, I'd hold the merge on one finding — and it's in the #481 commit this branch carries, not in the #504 work.

Verification I ran

  • cargo fmt --all --check → clean (verified on 55f6fd3a1, not on main).
  • cargo test -p helios-rest --all-features971 passed, 1 failed. The one failure is environmental: sof_conformance_mongodb_tests::test_sof_v2_conformance_in_db_mongodb panics with SocketNotFoundError("/var/run/docker.sock") — no Docker daemon locally. Every new batch/issue-code and search-entry test passes.

Blocking: bundle GET search entries are authorized with the READ scope, not SEARCH

bundle_method_to_fhir_operation (crates/rest/src/handlers/batch.rs:1797) maps every BundleMethod::Get to FhirOperation::Read, and both scope gates use it — the transaction arm at :459 and the batch arm at :809.

Before the 01929ed56 commit this was harmless: a type-level GET Patient?family=X entry was a read of an empty id and answered 404. It now executes a real type-level search.

  • crates/auth/src/policy/mod.rs:31 maps ReadSmartPermissions::READ and SearchSEARCH, and these are distinct bits — SMART v2 .r does not imply .s (crates/auth/src/scope/permissions.rs:41).
  • crates/rest/src/middleware/auth.rs:416 classifies GET /Patient?... (one path segment) as FhirOperation::Search.
  • middleware/auth.rs explicitly defers bundle authorization to the handler ("the same 'defer to handler' pattern batch and $export already use"), so this per-entry check is the only gate.

Concrete scenario: a SMART token with user/Patient.r (read, no search) is denied at GET [base]/Patient?family=X, but gets the full searchset by wrapping that exact URL in a batch entry. Scope escalation. The inverse also breaks — a .s-only token gets a spurious 403 on a bundle search entry.

This is precisely the batch-vs-single divergence class this PR stack exists to close, so it seems in-scope to fix here rather than defer. No existing test asserts that a bundle search entry is gated on the same permission as the equivalent HTTP request.


Should fix

parse_search_entry_url accepts any single path segment as a resource type (batch.rs:1498). The match arm is [resource_type] => Some(...) with no validity check, and nothing on the search path validates the type either. GET metadata (an entry form the spec sanctions), GET $export, or a typo'd GET Patinet now run a search on that string and return 200 OK with an empty searchset. Previously they answered 404. A false 200 is strictly worse for a client — it can't distinguish "nothing matched" from "no such endpoint".

The README limitation added by commit 4 is falsified by commit 5 of the same PR. crates/rest/README.md:579 still reads:

Bare type-level GET - GET Patient in a batch entry is read as an instance read with an empty id and answers 404 not-found … Executing it as a search is #478

01929ed56 implements exactly that. The PR body acknowledges the claim "is simply false here now", but the line wasn't removed.

Prefer: handling=strict is ignored for bundle search entries. Both execute_search_bundle call sites (batch.rs:600, :862) hardcode strict = false, while batch_handler already has the parsed PreferHeader in hand. GET Patient?famly=X (typo) in a strict-handling bundle returns 200 OK with an unfiltered searchset; the identical GET [base]/Patient?famly=X with the same header returns 400. New divergence in the same family this PR closes.

Audit events for search entries name the wrong entity type. emit_entry_audit derives resource_type from the entry URL, then unconditionally overrides it from result.resource["resourceType"] (batch.rs:1190). A searchset serializes as "resourceType": "Bundle", so an AuditEvent for GET Patient?family=X inside a Bundle records the accessed type as Bundle instead of Patient. extract_patient_from_resource("Bundle", …) also returns None, so the patient-compartment linkage is lost. Relevant given the BALP profiles.


Nits / discussion

  • batch.rs:522 — the transaction search pre-validation does map_err(|e| RestError::BadRequest { message: format!("… {}", e.client_response().2) }), discarding the status and issue code the inner error computed and re-coding everything as 400 invalid. That is the exact _code-discard pattern this PR deletes in five other places.
  • batch.rs:600 — a search entry failing after the transaction commits surfaces as a per-entry outcome inside a 200 OK transaction-response. Defensible (the writes already committed; there's no rollback available) and the code comment argues it well, but a client checking only the HTTP status — the normal transaction contract — silently drops the failure. Worth stating in the README's limitations rather than only in a comment.
  • batch.rs:1854return=minimal omits entry.resource, which now strips searchset bodies. Pre-existing for instance reads, but writes + searches in one bundle is exactly the case where a client sends return=minimal.
  • The search-param registry read lock is re-acquired per entry inside the transaction pre-validation loop; it could be hoisted.
  • Untested edge: a transaction whose entries are all searches passes an empty entry list to execute_bundle after the partition. No test covers it.

Process

Two scope observations, offered as a question rather than a request:

  1. The PR body is admirably honest that this branch carries #481 — but a PR titled fix(rest): render batch entry failures through the single-resource error mapping containing a +393/-16 behavioral feature, which is also where the one blocking finding lives, is hard to review as a unit. That the search commit's two _-discard sites became compile errors is a nice validation of the refactor, but it doesn't require shipping both together. Would splitting #481 back out be feasible?
  2. Commit 55f6fd3a1 "style: rustfmt" reformats crates/subscriptions/* and crates/ui/*, unrelated to either PR. Those files already pass rustfmt 1.9.0-stable on main, so this looks like output from a different rustfmt version — worth pinning down before it churns back the other way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

batch: every per-entry error carries OperationOutcome code processing — a 403, 404 and 405 are indistinguishable without parsing English

3 participants