Skip to content

fix(search): contain the abstract-prefix strip to expressions that mean it - #639

Merged
smunini merged 1 commit into
mainfrom
fix/535-abstract-prefix-containment
Aug 22, 2026
Merged

fix(search): contain the abstract-prefix strip to expressions that mean it#639
smunini merged 1 commit into
mainfrom
fix/535-abstract-prefix-containment

Conversation

@smunini

@smunini smunini commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #535 — all ten findings from the #534 review.

Root cause

#534's prefix strip activates on the prefix alone, not on the audited meta set. That set turned out to be the smaller half of what carries a Resource. prefix: R5/R6 ship two more such parameters, and both broke.

It is also worth recording what actually fixed #523, because it was not the strip. SearchParameterRegistry::register rejects a duplicate canonical URL outright, and the embedded fallbacks load before the spec bundle — so the spec's Resource.-prefixed _id/_lastUpdated/_tag/_profile/_security/_source never register at all once a fallback exists. #534's other half, adding the _source fallback, is what made _source index. In a server with a spec bundle the strip's entire net effect was to switch on _in and _language.

Blocking-grade

1. _in indexed the resource's own id. Its expression is Resource.id, but the parameter means "this resource is a member of the referenced List or Group" — the id is a placeholder, not a filter target. Stripped and evaluated it wrote one self-referential reference row per resource, so GET /Patient?_in=42 matched Patient/42 through the ordinary bare-id reference branch, and every $reindex added a junk row per resource.

It is now skipped at extraction (NON_INDEXABLE_PARAM_CODES) and rejected at the REST layer alongside _query. Both halves are needed: un-indexing alone is not safe, because on R5/R6 _in is a registered reference parameter, so Prefer: handling=lenient would not drop it as unknown and the self link would claim it had been applied — while SQLite would answer it with the whole resource type. Implementing it properly is #638.

2. _language diverged by backend. Newly indexed, correct on PostgreSQL, but SQLite routed it to build_special_parameter_condition's _ => None arm, which drops the filter rather than narrowing it, and returned every resource of the type. That is the #474 failure mode, reintroduced on a parameter #534 had just switched on. It joins the exemption list with the meta set.

3. Conditional writes duplicated on _source. Typed as String while the extractor writes Uri rows, so If-None-Exist: Patient?_source=… queried value_string, never matched, and created a duplicate on every request — the exact opposite of what the idempotency guard exists for. The three hand-copied fallback tables collapse into one fallback_param_type.

Fixing _source surfaced the same latent mismatch in _profile: the tables said Token, but the embedded definition that wins registration — and so the extractor — says Uri on every version, including R5/R6 where the spec's own copy is reference. Corrected in the same helper.

Correctness, lower severity

4. split_union_members splits | only at paren depth 0 and outside '…' / backtick literals. The old split('|') cut inside literals too; the unbalanced fragment used to be harmless (it matched no resource-type prefix and was dropped), but the abstract strip accepts a fragment on its prefix alone, so one member's parse error aborted extraction for every member of that parameter, concrete ones included.

5. Leading parens carry across the strip, so (Resource.meta.source) yields (meta.source) rather than falling through and silently extracting nothing.

6. extract() iterates ABSTRACT_BASE_TYPES instead of looking up "Resource" alone. The registry buckets a definition under each declared base, so base: ["DomainResource"] landed in a bucket nothing consulted.

7. $reindex docs note that pre-upgrade resources have no _source or _language rows and under-match until a reindex runs — a failure mode that returns no error, only fewer results.

Robustness

8. Loader and seeding bounds derive from load_embedded().len() and assert exactly one definition per code. <= 10 passed if a fallback was lost — the direction that breaks indexing — and .find() accepted a feature-gated duplicate.

9. ABSTRACT_BASE_TYPES hoisted to helios_fhir::search, consumed by applies_to, the extractor, and ui/editor.rs.

10. strip_abstract_base_prefix returns Option<Cow<'_, str>>, borrowing on the common path. The deeper root cause is untouched: the FHIRPath evaluator still resolves a leading type identifier by exact match with no subsumption, and this remains a patch in one consumer.

Coverage

Both blocking fixes are pinned by tests confirmed to fail without them. Reverting the _language exemption:

_language produced no condition — the filter would be dropped

Reverting the _in skip, showing the junk row itself:

_in must never be indexed, got [ExtractedValue { param_name: "_in",
  param_type: Reference, value: Reference { reference: "p1", ... } }]

New tests: r5_membership_parameter_is_not_indexed_but_language_is (R5-gated, so it runs under CI's --all-features), indexed_meta_parameters_are_not_dropped, membership_parameter_has_no_backend_condition, union_split_respects_literals_and_parens, literal_pipe_in_abstract_member_does_not_break_extraction, domain_resource_based_parameters_are_extracted, test_membership_parameter_in_is_rejected, plus parenthesized cases added to the existing strip test.

Verification

  • helios-persistence lib: 845 passed (sqlite,postgres,R4,R4B,R5,R6)
  • SQLite suites: sqlite_tests 85 / search_suite 103 / crud_suite 82 / search_param_seeding 6
  • helios-fhir search 25; helios-rest search_integration 111; full rest/fhir/ui suites green
  • cargo build --workspace --all-features, cargo fmt --check, and clippy under the CI flag set with -D warnings: clean

Not run locally: the PostgreSQL, Elasticsearch and MongoDB container-backed integration tests need Docker; CI covers them.

https://claude.ai/code/session_018FhD4aQRNHkPJ5HXvDSJps

…an it

#534 stripped the `Resource.`/`DomainResource.` prefix from every union
member that carried one, so `_source` would finally index. But the strip
activates on the prefix alone, not on the audited meta set, and the R5/R6
spec bundle ships two more `Resource.`-prefixed parameters.

`_in` was the damaging one. Its expression is `Resource.id`, but the
parameter means "this resource is a member of the referenced List or
Group" — the id is a placeholder, not a filter target. Stripped and
evaluated, it wrote one self-referential reference row per resource, so
`GET /Patient?_in=42` matched `Patient/42` through the ordinary bare-id
reference branch: a membership question answered with an identity test,
plus a junk row per resource per reindex. It is now skipped at extraction
(`NON_INDEXABLE_PARAM_CODES`) and rejected at the REST layer alongside
`_query`, because it cannot safely fall through — on R5/R6 it *is* a
registered `reference` parameter, so lenient handling would not drop it.
Implementing it properly is #638.

`_language` was newly indexed and correct on PostgreSQL, but SQLite
routed it to `build_special_parameter_condition`'s `_ => None` arm, which
drops the filter rather than narrowing it, and returned every resource of
the type — the #474 failure mode, on a parameter #534 had just switched
on. It joins the exemption list with the meta set.

Conditional writes typed `_source` as String while the extractor writes
Uri rows, so `If-None-Exist: Patient?_source=…` queried `value_string`,
never matched, and created a duplicate on every request. The three
hand-copied fallback tables (composite, postgres, sqlite) collapse into
one `fallback_param_type`. Fixing `_source` surfaced the same latent
mismatch in `_profile`: the tables said Token, but the embedded
definition that wins registration — and so the extractor — says Uri on
every version, including R5/R6 where the spec's own copy is `reference`.

Also fixed, from the same review:

- `split_union_members` splits `|` only at paren depth 0 and outside
  string/backtick literals. The old `split('|')` cut inside literals, and
  the unbalanced fragment used to be harmless (it matched no prefix and
  was dropped) but the abstract strip accepts a fragment on its prefix
  alone, so one member's parse error aborted extraction for every member
  of that parameter, concrete ones included.
- Leading parens carry across the strip, so `(Resource.meta.source)`
  yields `(meta.source)` instead of silently extracting nothing.
- `extract()` iterates `ABSTRACT_BASE_TYPES` instead of looking up
  "Resource" alone, which left the `DomainResource` registry bucket
  unreachable and half the constant dead.
- `ABSTRACT_BASE_TYPES` is hoisted to `helios_fhir::search` and consumed
  by `applies_to`, the extractor, and `ui/editor.rs` — it was a third
  hand-copy.
- `strip_abstract_base_prefix` returns `Option<Cow<'_, str>>`, borrowing
  on the common path.
- The loader and seeding bounds derive from `load_embedded().len()` and
  assert exactly one definition per code. `<= 10` passed if a fallback
  was *lost*, which is the direction that breaks indexing, and `.find()`
  accepted a feature-gated duplicate.
- `$reindex` docs note that pre-upgrade resources have no `_source` or
  `_language` rows and under-match until a reindex runs.

Both blocking fixes are pinned by tests confirmed to fail without them:
reverting the `_language` exemption fails with "produced no condition —
the filter would be dropped", and reverting the `_in` skip fails showing
the junk row (`param_name: "_in", value: Reference { reference: "p1" }`).

Fixes #535

Claude-Session: https://claude.ai/code/session_018FhD4aQRNHkPJ5HXvDSJps
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

@smunini
smunini merged commit 7a942a1 into main Aug 22, 2026
22 checks passed
@smunini
smunini deleted the fix/535-abstract-prefix-containment branch August 22, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant