Replies: 1 comment
-
|
Thanks @smunini , I really appreciate both the detailed write-up and the acknowledgement of my earlier work. It was encouraging to see that we independently converged on many of the same architectural principles—compile-time generation, local-first terminology validation, version-specific dispatch, FHIRPath for invariants, and OperationOutcome as the public contract. I also think the move towards FHIR Schema as the intermediate representation is a sensible direction for HFS. It fits well with the existing code generation pipeline and should make the implementation cleaner and easier to maintain upstream. One area where my implementation ended up evolving a little differently was around implementation guides and profile management. Once I started looking at real-world deployments (particularly national IGs that are revised independently of server releases), I found it useful to introduce a runtime profile registry and package materialization layer alongside the generated core validators. That allows servers to load curated IG packages without requiring a rebuild every time a profile package changes. For an upstream open source HFS server, compile-time profiles are probably the right place to start. I do wonder whether, as HFS matures, you’ll eventually encounter the same requirement from users who need to support multiple customer-specific or frequently updated IGs. If that happens, some form of runtime profile/package registry may become a natural extension rather than a replacement for the generated validators. In any case, I’m glad to see validation becoming a first-class part of HFS, and I’m happy that my earlier work could help shape the direction. I’m looking forward to seeing how the implementation evolves and would be happy to contribute ideas or experience as those more advanced profile-management scenarios come into scope. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Introduction
This document shares my thoughts on how to approach FHIR resource validation for the Helios FHIR Server. Like the persistence layer discussion, the auth discussion, and the bulk export discussion, this is an architectural strategy document rather than a comprehensive specification. It explains the motivating direction, the key building blocks, the validation model we intend to adopt, and the Rust trait and code-generation designs that will shape the validation subsystem.
Who should read this? Anyone with an interest in FHIR conformance, profile-driven validation, terminology binding, or the mechanics of FHIR validation generally. Feedback is very much welcome — this is open source, developed in the open, and your perspective matters.
A note on scope. Validation in FHIR is a large surface — the FHIR Validation spec enumerates eight distinct aspects, from structure to business rules. This document commits to a concrete plan for the computable, profile-expressible aspects: structure, cardinality, value domains, terminology bindings, FHIRPath invariants, profiles, slicing, and references. Two things are explicitly deferred and get their own treatment later: Questionnaire/QuestionnaireResponse validation (its own animal), and application business rules (duplicate detection, referential existence against the live store) — the latter belongs to the persistence and REST layers, not the validation engine.
The central architectural bet of this document is this: we will not walk
StructureDefinitionsnapshots at runtime. We will compile them — ahead of time, into Rust — using an intermediate representation borrowed from the FHIR Schema project, and we will lean on the code-generation machinery this project already owns (helios-fhir-genandhelios-fhir-macro) to do it.The Lay of the Land: What "Validation" Means in FHIR
The FHIR specification is unusually candid that validation is incomplete — it can only address "computable aspects of conformance," and even a green light from a validator is "a statement about how likely the resource is to be acceptable," not a guarantee. With that caveat framed, the spec enumerates the aspects a validator checks. It is worth reproducing them, because each one maps to a distinct piece of machinery we have to build:
StructureDefinitionmin..maxbounds.min/maxdateis a date, acodematches its regex).binding+ terminologyconstraintStructureDefinitionQuestionnaireResponsematches itsQuestionnaire.The spec also ranks the methods of validation by capability. XML/JSON schema and Schematron are the least capable — they handle structure and some cardinality but "slicing is partially supported," and they are, in the spec's own words, "not connected to a terminology server," which makes binding validation impossible. At the top sits the validator: a program that reads the full profile graph, resolves terminology against a real terminology server, and evaluates FHIRPath invariants.
The kick-off surface for validation is the
$validateoperation:It accepts a resource (and, optionally, a
modeand one or moreprofilecanonicals) and returns anOperationOutcomeenumerating every issue found, each with aseverity(fatal/error/warning/information), a machine-readablecode, anexpression(the FHIRPath location of the problem), and human-readablediagnostics. ThatOperationOutcomeis the contract. Everything this document proposes is in service of producing a correct, complete, and fast one.Binding strengths, because they change everything
One subtlety that deserves front-loading: terminology bindings have a strength, and the strength determines whether a failed binding is an error, a warning, or nothing at all.
requiredextensiblepreferredexampleThis table is why binding validation cannot be a boolean. The engine has to know the strength, has to be able to expand the ValueSet (or ask a terminology server to
$validate-code), and has to downgrade its own severity accordingly. It is the single most operationally demanding aspect of validation, and it is the one schema-only validators simply cannot do.Why Not Just Walk StructureDefinitions?
The obvious implementation of a FHIR validator reads
StructureDefinitionresources directly. Load the snapshot, walk theelement[]array, and for each element check the instance againstmin,max,type,binding,constraint, andslicing. Every reference implementation started here. Almost none of them stayed here, and the FHIR Schema project's motivation section explains why with unusual bluntness: "there are only few implementations of FHIR validation — why? because it's hard, no unit-tests, esoteric knowledge."The
StructureDefinitionformat is a poor runtime data structure for validation, for reasons that compound:Patient.contact.name.given). The resource is a tree. Every validator that walks a snapshot first has to reconstruct the tree from the path strings — the exact "convert SD to a nested data structure" step that the FHIR Schema project observes "most of implementers do," duplicating the same fiddly transformation in every language.value[x]is present asvalueQuantityorvalueString, whether an element is an array or a scalar, whether a primitive carries anid/extensionsidecar — none of this is a first-class field in a snapshot. It has to be inferred fromtype[]andmax. Every inference is a place to get it subtly wrong.The
$validatespec's own note that schema-based methods only "partially support" slicing is a symptom of the same disease: the source format does not represent the thing you need to check in a shape that makes checking it natural.So the question is not "should we transform StructureDefinitions into something better?" — everyone does. The question is "into what, and when?" This document's answer: into FHIR Schema, at code-generation time, emitted as Rust.
The Model: FHIR Schema
FHIR Schema is a re-representation of
StructureDefinition, "heavily inspired by the design of JSON Schema," whose entire purpose is to be the developer-friendly intermediate form that everyone was hand-rolling anyway — but standardized, unit-tested, and language-neutral. It has reference implementations, a growing conformance test suite modeled on the JSON Schema test corpus, and — critically for us — a documented, mechanical conversion fromStructureDefinition. We adopt it as our internal validation IR.The shape is exactly what you would design if you sat down to make validation easy. A schema is an object with a small header and a nested
elementstree that mirrors the resource. Here is the top-level surface, from the FHIR Schema reference:baseis the inheritance pointer — a schema "will inherit all elements and constraints" from its base, so a US Core Patient profile carries abaseof the core Patient schema, which carries abaseofDomainResource, and validation walks that chain.derivationdistinguishes a brand-new type (specialization) from a profile that constrains an existing one (constraint) — the same distinction the$validateoperation makes when you pass aprofileparameter.Everything interesting is in
elements. Each element is an object whose properties are, individually, trivial to check. Consider this (lightly trimmed) excerpt of the real R4 Patient schema — note how each concept that a StructureDefinition leaves implicit is here an explicit key:Read that against the aspects table from earlier and the mapping is one-to-one:
array: truevsscalar: trueare mutually exclusive first-class flags. "Only arrays accepted" / "arrays rejected." No more inferring cardinality-of-one frommax: "1". (Empty arrays are always rejected, matching FHIR.)min/maxintegers, permitted only when the shape isarray. Absent means unconstrained.typenames a FHIR type. Primitive types validate the value; complex types pull in that type's schema and recurse.choiceslist enumerates the polymorphic members andchoiceOfback-links each concrete member to its base. The rule "exactly one ofmultipleBirthBoolean/multipleBirthInteger, never both" falls straight out.bindingcarriesvalueSetandstrength, exactly the two facts the strength table above says we need.referslists the allowed target profile canonicals for aReference.constraintsis a map of constraint-id →{ expression, human, severity }, each a FHIRPath expression evaluated with%contextbound to the node the constraint is attached to (top-level constraints see the whole resource; thecontactconstraint above sees eachcontactelement).elementsrecurses for backbone elements, andelementReferencehandles genuinely recursive structures likeQuestionnaire.itemwithout infinite expansion.Slicing gets its own object, and it is far more legible than the StructureDefinition discriminator machinery:
rules: closedmeans every array item must match some slice;openimposes no extra restriction;openAtEndrequires unmatched items to come last. Each slice carries its own nested constraints. This is the aspect that schema/Schematron validators famously botch, and here it is a plain object with amatchpredicate.The validation algorithm over this IR is refreshingly small. Paraphrasing the FHIR Schema algorithm notes: start with a context (the set of applicable schemas — base plus every asserted profile) and the data node; for each schema, run each keyword's validator; most keywords are pure functions over a single value (
type,pattern,fixed,min/max,binding); onlyelementsandslicingneed cooperative validation across a whole collection. Walk the data tree once, and at each node union the schemas that apply, collect issues, recurse. "Onlyslicesandelementsneed cooperative validation" — everything else is embarrassingly parallel and, more to the point for us, embarrassingly compilable.A Previous Attempt: What PR #73 Taught Us
We do not start from a blank page. In PR #73, @sandhums contributed an in-progress "FHIR validation stack" that is genuinely instructive — both in what it got right and in why, as-is, it will not merge. I want to be explicit and appreciative here: that PR is a serious piece of work and it materially informs this design. It will not be merged because it carries a contributor's company-specific code (the generator crate is named
atrius-fhir-valueset-gen, after their fork), not because the approach was wrong. Several of its instincts are exactly right and we will keep them.What PR #73 built, at a glance:
fhir-validation-typesfhir-validation-genStructureDefinitionbundles, builds an index, emits generated Rust.fhir-validationatrius-fhir-valueset-genvaluesets.json.What it got right, and we adopt:
fhir-validation-genreads the sameStructureDefinitionbundles thathelios-fhir-genalready consumes (profiles-types.json,profiles-resources.json) and emits Rust ahead of time. The invocation was literallycargo run -p fhir-validation-gen --release -- r5 <inputs...> <out-dir>. This is the right axis, and it is the axis this document doubles down on.CodeSystems and smallValueSets were compiled into typed membership checks from the bundledvaluesets.json; large or intensional value sets fell back to an async$validate-codecall. This is exactly the right layering, and HFS is better positioned than the fork was — we havehelios-htsin-tree as the fallback, so the "remote" call can be an in-process one.r4/r4b/r5/r6with a dispatch layer, mirroring the version-feature pattern the rest of the workspace uses.OperationOutcome-shaped reporting. Issues mapped toOperationOutcome, the correct and only sane public contract.What kept it from merging, and what we change:
atrius-fhir-valueset-genis a fork artifact.StructureDefinitiondirectly, into bespoke per-type Rust. PR FHIR validation stack + FHIRPath primitive id / extension support #73 emitted 18–24part_*.rsfiles per version of hand-shaped validation models. That works, but it re-invents an intermediate representation that FHIR Schema already standardizes and unit-tests. We interpose the FHIR Schema IR so our generator's input is a documented, conformance-tested artifact rather than an ad-hoc in-memory index — which means our generator is simpler and its output is checkable against an external test suite.PrimitiveMetato carry primitiveid/extensionthrough FHIRPath. That need is real — invariants likeele-1(hasValue() or (children().count() > id.count())) depend on it — but HFS's FHIRPath engine already models this:EvaluationResultcarries aPrimitiveElement { id, extension }slot today. We thread through the existing type, not a new one.-types,-gen,-valueset-gen, runtime) is more surface than we need. This document proposes one new runtime crate plus extensions to the two codegen crates we already own.In short: PR #73 validated the strategy (generate, don't interpret; local-first terminology; per-version dispatch) and we thank it for that. This document re-homes that strategy on top of the FHIR Schema IR and the Helios codegen crates, with no vendor code.
The Architecture: Compile the Spec, Don't Interpret It
The single conviction behind this design is that a
StructureDefinitionis source code, and validation is its compiled output. HFS already treats the spec this way for data models —helios-fhir-genturnsStructureDefinitions into Rust structs at build time; nobody reflects over a StructureDefinition at runtime to figure out thatPatient.nameis aVec<HumanName>. Validation should work identically. The profile graph is known at build time. The FHIRPath invariant strings are known at build time. The binding ValueSet URLs are known at build time. There is no reason to discover any of it while a request is in flight.The pipeline has three stages, and each reuses machinery HFS already has:
Stage 1 —
StructureDefinition→ FHIR Schema. A converter (living inhelios-fhir-gen, following the FHIR Schema project's documentedfhir2schematransformation) reads the same bundleshelios-fhir-genalready parses and emits FHIR Schema JSON documents. This stage is where the leaky snapshot problem is solved once: differentials are merged, choice types are made explicit, arrays are labeled, slicing is normalized. The output is a set of.fhirschema.jsonfiles — a checkable, standard artifact we can run the FHIR Schema conformance suite against before we ever generate a line of Rust.Stage 2 — FHIR Schema → Rust. A generator (also in
helios-fhir-gen) reads those schema documents and emits, per FHIR version, a compiled validator: static tables of element rules, a compiled list of invariant expressions per type, binding descriptors, and slice matchers. This is exactly the shape PR #73 proved out with itspart_*.rssplit — we keep that packaging (many small files + a dispatch module keep incremental compilation sane) but feed it from the FHIR Schema IR.Stage 3 — runtime. A new
helios-validationcrate walks a resource against the compiled tables, delegating the two hard sub-problems to existing subsystems: FHIRPath invariants tohelios-fhirpath(whoseIntoEvaluationResultimpls are already generated for every FHIR type by theFhirPathderive), and terminology to a local-first table withhelios-htsas the$validate-codefallback.The reason this is worth the codegen investment is the same reason the data-model codegen was worth it: the compiler becomes your conformance partner. A binding that references a ValueSet we failed to bundle is a build error, not a 3 a.m. pager. An invariant expression that no longer parses is caught by the FHIRPath parser at generation time. A choice type with a member we do not have a Rust variant for does not compile. The class of "the validator silently skipped a rule" bug — endemic to interpreted validators — largely evaporates.
Where Code Generation Comes In
This is the heart of the proposal, and it is where HFS has an unfair advantage: the two crates that would do this work,
helios-fhir-genandhelios-fhir-macro, already exist, already parse fullStructureDefinitions, and already emit per-type Rust for every FHIR version. We are not building a code generator. We are teaching one we own two new tricks.What the generator already knows (and currently throws away)
helios-fhir-gendeserializes the completeElementDefinition— not a subset. Its bootstrap model (crates/fhir-gen/src/initial_fhir_model.rs) carries every field a validator needs:Here is the striking part. Today, the generator uses every one of these fields — but only to write doc comments. Walk the generated
crates/fhir/src/r5.rsand you will find, on thePatient.contactfield, the full text of invariantpat-1, its cardinality, and its binding strength — rendered as human-readable Markdown in a///block. The generator computeselement.min/max, formatselement.constraintinto a## Constraintssection, formatselement.bindinginto a## Bindingsection, and then throws the structured values away, keeping only the prose.That prose is a validation table that has not been asked to compile. The entire codegen ask of this document is: emit those same values a second time, as machine-readable metadata instead of only as documentation.
The two macros HFS already ships
helios-fhir-macroexports three derives, and their design is the template for what we add. Every FHIR type already derives them:#[derive(FhirSerde)]— the FHIR-correct JSON codec, including the primitiveid/extensionsidecar (_field) pattern and the split primitive-array pattern.#[derive(FhirPath)]— generatesimpl IntoEvaluationResult, the bridge that lets the FHIRPath engine evaluate expressions against any resource. This is the key that unlocks invariant validation for free: every type is already FHIRPath-addressable.#[derive(TypeInfo)]— namespace + type name for.ofType()resolution.Crucially, these macros already demonstrate the exact pattern a validation macro needs: static metadata emitted from a comma-list attribute. Look at how resource-level metadata already works today:
The
FhirPathderive parses those comma-joined strings (via helpers likeextract_resource_summary_fields) and emits:That is precisely the shape validation metadata wants. We are not inventing a mechanism; we are instantiating one that already carries
summary_fieldsand will now also carry cardinality, bindings, and invariants.The proposal:
#[derive(FhirValidate)]The plan is a two-part change, both parts small because the hard machinery exists:
helios-fhir-gento emit, alongside each generated field and type, structured validation attributes — the samemin,max,constraint,bindingvalues it already formats into docs. This is a localized change: the values are already in hand at the exact line where the doc string is built.#[derive(FhirValidate)]tohelios-fhir-macro, which parses those attributes with the same helper style asextract_resource_*and emits animpl FhirValidateplus a static element-definition table.The generated surface would look like this — an ordinary FHIR struct, now carrying its own conformance rules as attributes:
And the derive would emit a static, zero-runtime-cost description of the type plus its
validateentry point:Everything the runtime validator needs is now a
&'statictable baked into the binary. No StructureDefinition is parsed at runtime. No profile graph is walked. The cardinality check forgenderis a comparison against aconst. The binding check knows its ValueSet URL and its strength without a lookup. The invariant is a pre-parsed FHIRPath handle away from evaluation.This mirrors the FHIR Schema
elementstree one-to-one — which is the point. Stage 2 of the pipeline is a near-mechanical transliteration of a.fhirschema.jsondocument into these attributes, and because the intermediate form is standardized and unit-tested, the transliteration is checkable.Designing the Rust Traits
With the model and the codegen strategy established, here are the runtime abstractions. I present them the way #104 did — the vocabulary first, then the contracts — and I flag which pieces are new versus reused.
The vocabulary of a validation result
The output is an
OperationOutcome, but internally we accumulate typed issues so that the engine, the tests, and the REST layer all speak the same language. Nothing here is FHIR-version-specific; these are the stable currency of the subsystem.The compiled type description
This is the static shape the
FhirValidatederive emits — the runtime face of the FHIR Schemaelementstree. It is deliberately all&'static: the whole point of compiling is that describing aPatient's rules costs zero allocations.The validator trait
The engine itself is a trait, because — exactly as in #104 and #45 — the concrete implementation is a deployment choice. The default resolves terminology locally and falls back to HTS; a stricter deployment might reject on any
preferredmiss; a lenient one might treat unknown profiles as warnings. The handler does not care which is wired in.The two delegated sub-problems
The design's leverage comes from delegating the two hardest aspects to subsystems HFS already owns. Both are traits so that tests can stub them and deployments can swap them.
Invariants → FHIRPath. Because every FHIR type already derives
FhirPathand thusIntoEvaluationResult, evaluating a constraint is: convert the node to anEvaluationResult, evaluate the pre-parsed expression against it, expect a boolean. The engine binds%resourceand%rootResourceper the FHIR Schema constraint semantics.Bindings → local-first terminology, HTS fallback. This is the trait that lets us honor binding strength correctly and keep the single-binary deployment terminology-capable without a network hop.
Two design notes that always come up in review:
Why is
BindingResultthree-valued instead of abool? Because "I could not check" is a distinct and common outcome — an intensional ValueSet we did not bundle, or a terminology server that timed out — and it must not be silently conflated with "the code is invalid." The binding strength then decides the severity: anUndecidableon arequiredbinding is a warning ("unable to confirm"), while a definiteNotInValueSeton the same binding is an error. Collapsing this to a boolean is how validators end up either failing valid data or passing invalid data.Why delegate invariants rather than compile them to Rust? Because FHIRPath is a real language with a real evaluator we already ship, and re-implementing its semantics in generated match-arms would be a second, divergent FHIRPath engine — the exact duplication PR #73's
PrimitiveMetataught us to avoid. We pre-parse the expressions at generation time (so a broken expression fails the build) but evaluate them through the one canonical engine.The REST Layer: The
$validateOperationThe public surface is the standard
$validateoperation, wired in the established HFS handler style: generic over the storage/validator, taking aTenantExtractor, returningRestResult<Response>. Note that$validateis unusual among FHIR operations in that a "failed" validation is still an HTTP200 OK— theOperationOutcomeis the successful response; the issues live inside it.Two observations that are easy to miss:
It does not resolve references against the live store. Whether
Observation.subjectpoints at a Patient that actually exists is a business rule, not structural validation, and it belongs to the persistence layer's transaction path — not here. This handler validates the resource in isolation, which is what$validatewithout amodemeans.The same validator can run inline on writes. The interesting future wiring is not the standalone operation — it is an optional
HFS_VALIDATE_ON_WRITEposture wherecreate/updaterun the resource through the exact sameValidatorbefore it is persisted, rejecting non-conformant data at the door with a422 Unprocessable Entityand theOperationOutcomeas the body. That is a policy decision (validation is expensive; not every deployment wants it on the hot path), which is why it is a configuration flag and not a hardcoded behavior — but the engine is identical either way.Slicing and Profiles: The Hard Part
Just as group export was where the bulk spec's edges showed, slicing is where validation's edges show — and it is the aspect the spec itself admits schema-based validators only "partially" handle. The FHIR Schema representation makes it tractable, but it does not make it trivial, and honesty compels me to name where the difficulty concentrates.
The core of slicing is: an array element carries a
slicingobject with namedslices, each amatchpredicate (a pattern or a discriminator), a set of nested constraints, and arulesmode. Validating it means partitioning the actual array into slice-buckets by evaluating each item against each slice'smatch, then checking each bucket against its slice's cardinality and constraints, then applying therules:closedrejects any unmatched item,openAtEndrequires unmatched items to trail,openallows anything extra. The FHIR Schema algorithm notes are explicit that this is the one place — along withelements— that needs "cooperative validation" over the whole collection rather than element-by-element.Three things make this genuinely hard, and each gets an explicit position:
Discriminators beyond
pattern. FHIR discriminators come in flavors —value,pattern,type,profile,exists. The FHIR Schemamatchobject handlespatterncleanly;typeandprofilediscriminators require recursively deciding whether an item validates against a target schema, which means slice-matching and validation are mutually recursive. We will implementpattern,value, andexistsfirst (they cover the overwhelming majority of real profiles, including all of US Core's common slices) and stagetype/profilediscriminators behind them.Re-slicing. A profile can slice a slice. The FHIR Schema model nests this naturally (a slice's element can itself carry
slicing), and our compiledElementDef→Slicing→Slicetree nests the same way — but the runtime partition logic has to recurse, and the error messages have to name the full slice path (Patient.identifier:mrn/type) or they are useless to the user.Profile composition. When a resource asserts multiple profiles (or a profile whose base is itself a profile), the context at each node is the union of all applicable schemas, and their constraints all apply simultaneously. The FHIR Schema algorithm's "set of schemas in context" is exactly this union, and our engine models it as a
Vec<&'static [ElementDef]>gathered by walking thebasechain of every asserted profile at generation time — so the union is precomputed wherever the profile set is statically known, and assembled at runtime only for dynamically-assertedmeta.profilecombinations.The Bulk-Cohort-style dynamic case — a resource asserting an arbitrary profile we compiled but did not anticipate combining — is handled, but the fully general "validate against a profile the server has never seen, supplied in the request" case (the
$validateoperation technically permits a containedStructureDefinition) is out of scope for the first cut. It requires a runtime SD→FHIRSchema→validator path — essentially shipping Stage 1 and 2 of the pipeline as a runtime library — and it deserves its own design once the compiled path is solid.Configuration: What Operators Will Touch
HFS_VALIDATION_ENABLEDtrue$validateoperation. Whenfalse, the endpoint returns501 Not Implemented.HFS_VALIDATE_ON_WRITEfalsetrue,create/updatevalidate before persisting and reject non-conformant resources with422. Off by default — validation is not free.HFS_VALIDATION_UNKNOWN_PROFILEwarningmeta.profilewe did not compile:error,warning, orignore.HFS_VALIDATION_TERMINOLOGYlocal-firstlocal-first(bundled tables, HTS fallback),local-only(no remote calls), orremote-required(fail if HTS is unreachable).HFS_VALIDATION_TERMINOLOGY_URL$validate-codefallback when distinct from the in-process HTS.HFS_VALIDATION_TERMINOLOGY_TIMEOUT2000$validate-codebefore returningUndecidable.HFS_VALIDATION_BEST_PRACTICEwarningbpr-*best-practice invariants:error,warning, orignore.HFS_VALIDATION_MAX_DEPTH50elementReferencestructures.The default posture — validation available as an operation, off on the write path, lenient on unknown profiles, local-first terminology — is the one that makes a fresh HFS install useful without surprising anyone. A deployment tightens into
HFS_VALIDATE_ON_WRITE=trueandHFS_VALIDATION_UNKNOWN_PROFILE=errorwhen it is ready to enforce, by changing configuration, not code.Conformance Testing
Validation has an unusually good conformance story, because two independent test corpora exist and we intend to be green on both:
$validatebehavior end-to-end for US Core and other IGs; we fold validation into the same nightly Inferno job the bulk-export discussion proposed.The plan, mirroring #104's testing posture:
cargo xtask fhirschema-conformancetarget that runs the FHIR Schema corpus against the Stage-1 converter output.helios-validationthat validate the official invalid-example corpus per version and assert the exactOperationOutcomeissue set..github/workflows/inferno.ymlas a nightly job, with a conformance badge in the crate README.These are slow and authoritative — nightly CI, not per-PR.
What's Not in Scope (Yet)
A handful of things are deliberately deferred. None are blockers; each is a follow-up.
Questionnaire/QuestionnaireResponsevalidation. Validating a response against its questionnaire (enable-when logic, answer-option constraints, item cardinality) is a distinct engine with its own spec surface. It reuses the FHIRPath dependency but little else.StructureDefinitionsupplied in the request (rather than compiled ahead of time) requires shipping the Stage 1/2 pipeline as a runtime library. Worth doing; not first.typeandprofileslice discriminators.pattern/value/existsfirst; the recursive discriminators after.Validatorproducing a structural verdict and the write path layering existence checks on top.$expandof large intensional ValueSets at build time. We compile finite code systems and small value sets; large intensional sets resolve at runtime via HTS. Pre-expanding them into the binary is a size/speed trade we will revisit.mustSupportenforcement.mustSupportis a producer/consumer contract, not an instance-validity rule in the base spec; we surface it as metadata and let IG-specific policies act on it, rather than baking a severity in.Proposed Next Steps
The traits and codegen sketched above are a starting point. To move toward implementation:
StructureDefinition→ FHIR Schema converter inhelios-fhir-genand get it green against the FHIR Schema conformance suite for R4 first. This is the highest-risk stage; prove it in isolation before generating any Rust..fhirschema.jsonartifacts per version, the way the models and the compartment tables are already generated — so the IR is reviewable in PRs.helios-fhir-gento emit structured#[fhir_element(...)]/#[fhir_validate(...)]attributes alongside the existing doc comments. The values are already computed; this is promotion from prose to tokens.#[derive(FhirValidate)]tohelios-fhir-macro, following theFhirResourceMetadataprecedent, emitting the&'staticElementDef/Invarianttables.helios-validationcrate with theValidator,InvariantEvaluator, andTerminologyBindertraits and the version-feature layout (R4/R4B/R5/R6) the rest of the workspace uses.valuesets.json(re-homing PR FHIR validation stack + FHIRPath primitive id / extension support #73's capability under Helios naming, backed byhelios-htsfor the fallback).helios-fhirpath, binding%resource/%rootResource/%contextper the FHIR Schema constraint semantics, pre-parsing expressions at generation time.BindingResultand strength-aware severity, local-first with the HTS fallback.pattern/value/existsdiscriminators,open/closed/openAtEnd), then profile composition over thebasechain.$validateREST handler inhelios-rest, returning200 OK+OperationOutcome, honoring?profile=and?mode=.Closing Thoughts
Validation is the API that turns a FHIR server from a store that accepts data into a platform that stands behind it. Every downstream consumer — the SQL-on-FHIR views, the terminology lookups, the bulk exports feeding research and AI pipelines — inherits the quality of what validation let through. A server that cannot validate is a server that has quietly outsourced its data-quality guarantees to whoever happened to write the ingesting client, which is to say, to no one.
The architecture proposed here is built around two convictions. First, that a
StructureDefinitionis source code, and the right time to process it is build time — compiled into&'staticRust tables via the code-generation machinery HFS already owns, so that the compiler becomes a conformance partner and "the validator silently skipped a rule" stops being a class of bug. Second, that the two genuinely hard aspects — FHIRPath invariants and terminology bindings — should be delegated to the subsystems HFS already ships (helios-fhirpathandhelios-hts) rather than re-implemented, so the validation engine stays small and the hard parts stay canonical.The Rust type system and its macro machinery make both convictions enforceable. Because every FHIR type already derives
FhirPath, every resource is already FHIRPath-addressable, and invariant evaluation is nearly free. Becausehelios-fhir-genalready parses everyElementDefinitionfield — and today spends them only on doc comments — teaching it to emit those same values as#[derive(FhirValidate)]attributes is promotion, not invention. And because FHIR Schema gives us a standardized, unit-tested intermediate representation, the riskiest stage of the whole enterprise — turning the spec's leaky snapshots into something checkable — can be verified against an external oracle before we generate a single line.I want to close by thanking @sandhums, whose PR #73 did the unglamorous work of proving this strategy out end to end. That PR will not merge as-is — it carries a fork's vendor-specific code — but its instincts were right: generate, don't interpret; local-first terminology with a remote fallback; per-version dispatch;
OperationOutcomeas the contract. This document re-homes those instincts on the FHIR Schema IR and the Helios codegen crates, with no vendor code, and points them at the conformance suites that will tell us, every night, whether HFS is a validator the rest of the ecosystem can trust.Thank you for reading. I look forward to the discussion.
Beta Was this translation helpful? Give feedback.
All reactions