Skip to content

test(rest): pin the issue codes on #478's two search-entry failure paths - #518

Closed
aacruzgon wants to merge 6 commits into
fix/504-batch-entry-issue-codesfrom
fix/478-search-entry-issue-codes
Closed

test(rest): pin the issue codes on #478's two search-entry failure paths#518
aacruzgon wants to merge 6 commits into
fix/504-batch-entry-issue-codesfrom
fix/478-search-entry-issue-codes

Conversation

@aacruzgon

@aacruzgon aacruzgon commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

#481 added search-entry dispatch to both bundle arms, and with it two new failure sites — one per
arm — with no coverage on either.
All five of its tests assert 200 OK.

Both sites were written as:

Err(e) => {
    let (status, _, details) = e.client_response();
    create_error_result(status.as_u16(), &details)
}

That is the same code-discard #504 removed from every other call site in the file: client_response
computes the correct FHIR issue code, the _ throws it away, and the wrapper stamps processing over
the result. Left alone, a search entry would have been the one path in the handler still answering
processing after #504.

The rebase below this commit migrated both to entry_failure. This PR pins that, so they cannot
drift back.

Changes

Three tests in batch_entry_issue_codes.rs:

Test Drives Asserts
a_failed_search_entry_reports_its_issue_code batch: Patient?_query=byName, Patient?code:not-in=… 400 invalid, 501 not-supported
a_failed_transaction_search_entry_reports_its_issue_code transaction: Patient?_query=byName 400 invalid, per-entry, bundle still 200
a_failed_search_is_described_identically_by_both_surfaces both surfaces, same bad search severity, code and details.text all agree, plus a literal pin

The parity test's literal pin is load-bearing: processing == processing satisfies the three
equalities on its own, so without it a regression that flattened both surfaces would pass.

The transaction case corrects a claim #504 made

#504 stated that no per-entry outcome is reachable on the transaction arm — the backends discard an
entry result at their status >= 400 guard and return TransactionError::BundleError, which carries
neither a status nor a code. That was true of the tree #504 landed on. It is false here. #481's
search loop bypasses the backend executor entirely and surfaces a search failure as that entry's own
outcome, deliberately — a failed search cannot roll back writes that already committed.

So this is the first reachable per-entry outcome on the transaction arm, and the first place its issue
code is asserted. #504's PR body and README say otherwise; the README bullet is corrected below and
the claim is narrowed to the tree it was true of.

Documentation

Removes a Current Limitations bullet #504 added:

Bare type-level GETGET Patient in a batch entry is read as an instance read with an empty
id and answers 404 not-found with the message Resource Patient/ not found. Executing it as a
search is #478.

That work is the commit directly below this one, so the bullet is now false. Removed rather than left
to mislead. The per-entry code table gains a row for search-entry failures, and the parity sentence
gains the search surface.

Testing

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

Verified non-vacuous. With both sites reverted to their original code-discard, all three fail:

left: String("processing")   right: "invalid"
left: String("processing")   right: String("invalid")
left: String("processing")   right: "invalid"

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

Refs #478, #504

@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@aacruzgon
aacruzgon force-pushed the fix/478-search-entry-issue-codes branch from 6abd69d to 0a1dcea Compare August 6, 2026 17:25
Base automatically changed from fix/478-bundle-get-search to fix/504-batch-entry-issue-codes August 7, 2026 14:48
@aacruzgon
aacruzgon force-pushed the fix/478-search-entry-issue-codes branch from 0a1dcea to ff60e9a Compare August 7, 2026 14:52
@aacruzgon
aacruzgon force-pushed the fix/478-search-entry-issue-codes branch from ff60e9a to 61af249 Compare August 9, 2026 20:32
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@aacruzgon
aacruzgon force-pushed the fix/478-search-entry-issue-codes branch from 61af249 to eba740c Compare August 16, 2026 16:28
@smunini
smunini force-pushed the fix/478-search-entry-issue-codes branch from eba740c to 39a5319 Compare August 19, 2026 19:02
aacruzgon and others added 6 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
The search-entry dispatch added two failure sites, one per bundle arm, and
covered neither: all five of its tests assert `200 OK`. Both sites were written
as `let (status, _, details) = e.client_response(); create_error_result(
status.as_u16(), &details)` — the same code-discard #504 removed from every
other call site. The rebase migrated them to `entry_failure`; this pins that so
they cannot drift back.

Three tests. A batch search entry failing on `_query` (400 `invalid`) and on
`:not-in` (501 `not-supported`); the same failure inside a `transaction`; and
parity with `GET [base]/Patient?_query=…`, asserting severity, code and
`details.text` all agree plus a literal pin, since `processing == processing`
would satisfy the equalities on its own.

The transaction case is worth naming. #504 stated that no per-entry outcome is
reachable on the transaction arm, because the backends discard an entry result
at their `status >= 400` guard and return `TransactionError::BundleError`. That
was true of the tree #504 landed on and is false here: this search loop bypasses
the backend executor and surfaces the failure as that entry's own outcome —
deliberately, since a search failure cannot roll back writes that already
committed. So this is the first reachable per-entry outcome on that arm, and the
first place its issue code is asserted.

Verified non-vacuous: with both sites reverted to their original code-discard,
all three fail — `left: String("processing"), right: "invalid"`.

Also corrects a README bullet #504 added. It documented `GET Patient` in a batch
entry as an instance read answering `404 not-found` with the message
`Resource Patient/ not found`, and named executing it as a search as future
work. That work is the commit below this one, so the bullet is now false and is
removed rather than left to mislead. The per-entry code table gains a row for
search-entry failures.

Tests: 1117 pass.

Refs #478, #504
@smunini
smunini force-pushed the fix/478-search-entry-issue-codes branch from 39a5319 to efb6a9a Compare August 19, 2026 21:46
angela-helios added a commit that referenced this pull request Aug 21, 2026
…hape

codecov: the read-or-search parser and the embedding result rode only
through the integration happy paths. Unit tests now pin the split
(instance reads and deeper paths are never searches, bare type is an
unfiltered search, leading slash tolerated) and the 200-no-baggage
entry shape. The failure-path issue codes stay #518's scope.
bomanaps pushed a commit to bomanaps/hfs that referenced this pull request Sep 1, 2026
Cherry-picked from the HeliosSoftware#501..HeliosSoftware#504 stack (PR HeliosSoftware#481, commit 32b3766) onto
main, so HeliosSoftware#478's fix ships without waiting for the issue-code refinement
chain (HeliosSoftware#516/HeliosSoftware#518) to clear review. The conflict resolution, recorded
because it is more than textual:

- Search entries partition out of indexed_entries *before* HeliosSoftware#459's
  conditional-reference resolution, which runs on the write entries only
  — a GET entry's query string is a search, not a conditional reference.
- entry_failure lands as a single seam in main's current style: the
  status/details pair from client_response rendered through
  create_error_result. HeliosSoftware#516 upgrades exactly this function to the full
  OperationOutcome issue-code mapping when it lands; its scope is
  untouched.
- The rollback fan-out keeps main's message-based result and gains this
  commit's .chain(&search_entries) — every entry of a failed bundle owes
  the audit trail a record, searches included.
- execute_search_bundle keeps main's ignored-params outcome (HeliosSoftware#460-era
  lenient-handling reporting) and returns the bundle JSON; the HTTP
  wrapper formats.

Verified live: batch and transaction bundles with GET Patient?family=X
entries return 200 searchset entries, and a transaction's search sees
the bundle's own committed writes. Full helios-rest suite green (34
binaries; batch_conformance 59 including this commit's five).

Closes HeliosSoftware#478

Co-authored-by: Angela Valdez <angela@heliossoftware.com>
@aacruzgon

Copy link
Copy Markdown
Contributor Author

Closing: this PR existed only because the search-entry failure sites lived on the #481 commit that #516 carried. #481 reached main through #635, and #516 has been rebased onto that. The three tests here still pin something real — that a failed search entry renders invalid / not-supported through entry_failure rather than processing — so the commit was cherry-picked onto #516 unchanged (36a614b18) instead of being dropped. Nothing is lost; there is just no separate PR for it any more.

@aacruzgon aacruzgon closed this Sep 2, 2026
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.

2 participants