Skip to content

fix(iris_interop_query): pin search_table to the extent's document class - #412

Open
PYDuquesnoy wants to merge 1 commit into
masterfrom
fix/409-search-table-pins-doc-class
Open

PYDuquesnoy wants to merge 1 commit into
masterfrom
fix/409-search-table-pins-doc-class

Conversation

@PYDuquesnoy

Copy link
Copy Markdown
Contributor

Closes #409.

iris_interop_query(what=search_table) joined SearchTable.DocId = Ens.MessageHeader.MessageBodyId
and stopped there. MessageBodyId is unique only within one document class's extent, so a custom
%Persistent body whose numeric ID collided with an indexed document's ID came back too — the
reporter saw Pkg.MSG.* headers returned next to the HL7 ones.

This was a sibling asymmetry, not a missing idea

Both joins in interop.rs were added by #4. One pinned the class; the other did not.

builder pinned h.MessageBodyClassName?
build_body_join_sql (body-class join) yes, since #4
build_search_table_sql (search-table join) no

And the one that did carries a doc comment giving the reporter's own reason:

The join also pins h.MessageBodyClassName to the body class: MessageBodyId is only unique per body
table, so without it same-numbered rows of OTHER body classes would match.

So the hazard was understood when the code was written and only one of the two siblings got the
guard. neither_join_leaves_the_body_class_unconstrained therefore asserts the property of both
builders — a test covering only the one I fixed would let the pair drift apart again, which is how
this arrived.

The MessageBodyClassName filter that did exist on this path comes from header_filters and only
fires when the caller passes message_class. That is why the issue's workaround ("always pass
message_class and source") worked, and why it read as a caller obligation rather than a defect.

Why the pin is a list, not the equality the report suggested

The report asked for MessageBodyClassName = 'EnsLib.HL7.Message'. That is right for HL7 and wrong
in general. The document class is declared as the DOCCLASS class parameter, and
%Dictionary.CompiledClass.PrimarySuper is a ~-delimited list that includes the class itself,
so one LIKE returns the document class and every subclass of it in a single round trip, with no
HL7 special case. Measured on IRIS for Health 2026.1 Build 235U:

extent DOCCLASS classes in the family
EnsLib.HL7.SearchTable EnsLib.HL7.Message 1
EnsLib.EDI.X12.SearchTable EnsLib.EDI.X12.Document 1
EnsLib.EDI.EDIFACT.SearchTable EnsLib.EDI.EDIFACT.Document 1
EnsLib.XML.SearchTable Ens.StreamContainer 5
Ens.MessageHeader (no DOCCLASS) — 0 rows, not an error

The EnsLib.XML.SearchTable row is the reason: a bare equality would have dropped
Ens.MFT.StreamContainer, EnsLib.HTTP.GenericMessage, EnsLib.REST.GenericMessage and
EnsLib.SOAP.GenericMessage — trading over-matching for under-matching, which is the worse of the
two because the caller cannot see it. A body class that extends the document class is still a
document.

Two details that only a live instance tells you: the column is _Default, not Default (Default
is reserved — the wrong name fails at runtime as SQLCODE -29, which no unit test would catch), and
the extent resolved from Ens_Config.SearchTableProp.ClassExtent is the base extent, so a
custom search table subclassing EnsLib.HL7.SearchTable inherits the right DOCCLASS with no
per-class registration.

An unresolvable document class is reported, not turned into a predicate

Ens.MessageHeader has no DOCCLASS and that query returns 0 rows rather than an error, so
"cannot pin" is a state this code reaches. It emits the join unpinned — exactly the pre-change
behaviour — and adds a warning naming the extent, the namespace, why other classes can appear, and
message_class as the way to constrain it. Emitting IN () or a 1=0 instead would answer with
zero rows, and zero rows from a search read as no message matched: the negative-fact shape CLAUDE.md
is about, and the defect this PR exists to remove, reintroduced one layer down.
an_unresolved_document_class_is_not_an_impossible_predicate asserts the absence of all five shapes
that would do that.

Verification

Read-only, against IRIS for Health 2026.1 on the verification instance (namespace APP):

  1. The mechanism — the four DOCCLASS resolutions in the table above, including the 5-class
    family and the 0-row no-DOCCLASS case.
  2. The generated SQL is valid — the pinned statement this PR produces runs on the server without
    error.
  3. The pin is not inert — a positive control was required, because APP holds no
    Ens.MessageHeader rows at all
    , so every row count in this namespace is zero and a zero proves
    nothing. Running the identical JOIN … WHERE … IN ('…') form over dictionary tables that do
    have rows: unpinned 7, pinned to one name 1, pinned to a 5-name family 1, pinned to a
    name that matches nothing 0. The form filters, and a 5-name IN list resolves.

What is not verified, stated plainly: the row-level symptom itself — HL7 headers returned beside
colliding Pkg.MSG.* ones — was not reproduced. It needs an HL7-indexed namespace with colliding
body IDs; the instance that has EnsLib.HL7.* is read-only here, and the writable instances are
Community edition with no HL7 at all (EnsLib.HL7.Message absent). What is verified is the mechanism
the fix turns on, the validity of the SQL it emits, and that the predicate form filters rather than
being a no-op.

Mutation check — and the one that mattered

13 mutants, one per assertion plus a break of the sibling builder. 12 killed by the predicted
assertion on the first pass. One survived, and it was the important one.

Mutant 6 deleted the body-class join's pin outright — the exact defect this PR fixes, in the sibling
builder — and neither_join_leaves_the_body_class_unconstrained stayed green. The assertion was
sql.contains("h.MessageBodyClassName"), and MessageBodyClassName is one of HEADER_COLS, which
both builders prefix with h.. So it was satisfied by the SELECT list and would have passed with
the filter gone entirely.

I had already hit that same trap earlier in this change, in
an_unresolved_document_class_is_not_an_impossible_predicate, and fixed it there — and did not carry
the fix to its sibling. Which is to say the parity test written to catch "one sibling got the guard"
was itself an instance of it. Both are now scoped to the WHERE clause, and the reason is recorded in
the test so the window is not widened again. Mutant 6 re-run: killed by
neither_join_leaves_the_body_class_unconstrained.

Worth stating because it changes how much the rest of the suite is worth believing: a
contains() assertion on generated SQL is weaker than its name suggests whenever the string also
appears in the projection. Every assertion here now names the clause it is about.

After the repair the file was restored byte-identical (sha checked), the post-restore baseline was
green, and the full gate was re-run from scratch rather than carried forward: cargo fmt --check
rc=0, this file 12/12, cargo clippy --workspace --all-targets -- -D warnings rc=0,
cargo build --workspace rc=0, and cargo test --workspace with the IRIS env unset rc=0 —
1854 passed, 0 failed, 69 ignored across 66 binaries (66 test result: lines, so the count is a
measurement and not an empty grep).

Note for review

build_search_table_sql, build_body_join_sql, doc_class_family_sql and
unpinned_search_warning are now pub. That matches the existing convention in this file —
build_add_item_code, build_remove_item_code, build_set_settings_code and
build_message_body_code are already pub for the same reason — and it is what lets the
sibling-parity assertion call both builders instead of grepping the source for a string, which
would pass on a comment.

The new assertions live in their own file rather than being appended to the #[cfg(test)] mod in
interop.rs: measured over 13 PRs in this repo, every cross-branch conflict was an appended test
module, and there are several open PRs touching this file.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N7fLbq3ftb82ub45QCpsPB

A Search Table's DocId IS Ens.MessageHeader.MessageBodyId, but MessageBodyId is
unique only within one document class's extent. Joining on it alone also matched a
custom %Persistent body whose numeric ID collided with an indexed document's ID, so
Pkg.MSG.* headers came back beside the HL7 ones.

This was a sibling asymmetry. Both joins were added by #4; build_body_join_sql pinned
h.MessageBodyClassName and carried a comment giving the reason, and
build_search_table_sql did not. The class filter that did exist on this path comes
from header_filters and only fires when the caller passes message_class, which is why
it read as a caller obligation rather than a defect.

The document class is resolved from the extent's DOCCLASS class parameter, and
%Dictionary.CompiledClass.PrimarySuper is a ~-delimited list that includes the class
itself, so one LIKE returns DOCCLASS and every subclass of it in a single round trip
with no HL7 special case. Measured on IRIS for Health 2026.1: EnsLib.HL7.SearchTable
-> 1 class, EnsLib.XML.SearchTable -> 5. A bare equality, which is what the report
suggested, would have dropped four real body classes for that second extent, trading
over-matching for under-matching — the worse of the two, because the caller cannot see
it.

An extent whose document class cannot be resolved leaves the join unpinned and says so
in a warning naming message_class as the remedy. Three mechanisms reach that state
(no DOCCLASS, DOCCLASS naming an uncompiled class, an empty DOCCLASS) and all three
were measured to return zero rows rather than a pin that silently matches everything.
Emitting IN () or 1=0 instead would answer with zero rows, and zero rows from a search
read as "no message matched" — the negative-fact shape this repo keeps removing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7fLbq3ftb82ub45QCpsPB
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.

[mcp:iris_interop_query] search_table does not pin MessageBodyClassName, so bodies of other classes match — the sibling body-class join does pin it

1 participant