The Problem with Runtime Resolution
Before the 2511 release, Aidbox resolved all canonical references at runtime. A StructureDefinition referencing http://hl7.org/fhir/us/core/StructureDefinition/us-core-race without a version? Aidbox had to figure out which version to use. And even when the result was cached, the cache was fragile โ someone installs a new package, a new canonical appears, caches reset, and now that same reference might resolve to a completely different version.
This is how most FHIR packages are published. Canonicals contain outgoing references to other canonicals โ ValueSets pointing to CodeSystems, profiles pointing to base StructureDefinitions โ and the vast majority of these references carry no version. The assumption is that the consumer will figure it out from context.
That assumption pushes a hard problem onto the consumer.
The real issue wasn't even performance โ it was the context itself. At runtime, all you have is a flat set of installed packages and their canonicals. But which packages were explicitly installed by the user? Which ones are transitive dependencies? Which ones depend on what? That information was effectively lost after installation. Every package was just a bag of canonicals dumped into the same pool. And because the resolution result depended on whatever happened to be in that pool, installing or removing any package could silently change which version a reference resolved to โ pieces of the dependency graph just appeared or disappeared, and the resolver had no way to notice.
This made complex dependency scenarios impossible to handle correctly. Take hl7.fhir.us.davinci-hrex โ it pulls in three different versions of hl7.fhir.us.core. When a profile from HRex references http://hl7.org/fhir/us/core/StructureDefinition/us-core-provenance without a version, which of the three US Core versions should Aidbox pick? It's impossible to deduce at runtime โ all three are present simultaneously, and there's no information left to disambiguate. This is a choice that needs to be made before the system even starts working, not on the fly during a validation call.
We needed a way to resolve all these ambiguities once, upfront, when the full dependency graph and user intention are still available โ not at runtime when they're gone.
The FHIR community had already been moving in this direction. The FHIR IG Guidance on Pinning laid the groundwork โ the spec describes the intended resolution algorithm:
- Find the package that contains the resource with the reference
- Compile the full list of that package's dependencies (transitive)
- Find all resources matching the canonical URL within those packages
- Select the most recent version
This sounds straightforward, but the complications pile up fast. "Most recent" is ambiguous when you have semver, date-based, and arbitrary version strings. Multiple resources can have the same URL and version. And in a RESTful API context, canonicals arrive without package metadata โ the server may not know which package "owns" a given canonical at all.
The spec is deliberately light on these implementation details โ it leaves a lot up to implementers. So we reverse-engineered what we could from existing tools that attempt pinning โ the IG Publisher and UploadFIG โ and finalized the resolution strategy at FHIR Camp 2025 in collaboration with Grahame Grieve, Gino Canessa, and Lloyd McKenzie.
The result is a two-phase approach to package handling: all the hard work happens before actual server runtime, and runtime gets a flat, fully resolved canonical set.
Two-Phase Approach: Configuration and Runtime
Configuration phase. When you install a package โ via the API, the UI, or the BOX_BOOTSTRAP_FHIR_PACKAGES config โ Aidbox downloads the package and all its dependencies, then walks the entire dependency graph. Every unversioned canonical reference gets rewritten with an explicit |version suffix:
before: http://hl7.org/fhir/ValueSet/observation-status
after: http://hl7.org/fhir/ValueSet/observation-status|4.0.1
At the same time, canonicals from dependency packages that nobody actually references are dropped โ this is tree-shaking.
Runtime phase. All canonicals are already resolved. Every outgoing reference carries an explicit version. For Aidbox, resolving a canonical is now a simple database query by <url>|<version> โ no graph traversal, no context-dependent resolution, no ambiguity. Packages as a concept no longer exist at runtime; only fully resolved canonicals remain.
How Pinning Works in Aidbox
The pinning process walks every canonical in the installed packages and rewrites its outgoing references with exact versions.
Collecting Outgoing References
For each canonical resource, Aidbox collects all outgoing canonical references โ URLs that point to other canonicals. It does this for all five canonical resource types that can contain outgoing references:
- StructureDefinition (excluding
snapshotโ onlydifferentialmatters) - CodeSystem
- ValueSet
- SearchParameter
- CapabilityStatement
For example, a StructureDefinition might reference a ValueSet for a binding, a base StructureDefinition it derives from, and extension definitions it uses. Each of these references needs a pinned version.
Building the Candidate List
For each outgoing reference, Aidbox builds a candidate list: all canonicals with the matching URL available across the package's dependency tree (including transitive dependencies). If a package depends on hl7.fhir.us.core@5.0.0, and US Core depends on hl7.terminology.r4, then terminology canonicals are valid candidates.
The Candidate Selection Algorithm
When multiple candidates exist โ different versions, different packages, same canonical URL โ Aidbox selects the best one using a deterministic, multi-stage comparison chain:
| Priority | Criterion | Rule |
|---|---|---|
| 1 | Status | active > draft > retired > unknown |
| 2 | Terminology over Core | Canonicals from hl7.terminology packages take precedence over identically-named canonicals from core packages |
| 3 | Version | Compared using the detected algorithm: semver, integer, date, or alphabetic |
| 4 | lastUpdated | Final tiebreaker using meta.lastUpdated |
Before comparison, candidates are filtered. Aidbox excludes canonicals from packages matching known noise patterns โ expansions, examples, search-related, elements, corexml โ and CodeSystem resources whose content is not "complete".
Version algorithm detection deserves a note. Aidbox first checks versionAlgorithmString or versionAlgorithmCoding on the resource (an R5+ feature). If neither is present โ which is most of the time with R4 content โ Aidbox infers the scheme by inspecting the version string: does it look like semver (1.2.3)? An integer (4)? A date (2024-01-15)? Fallback is alphabetic comparison. This matches the practical approach recommended in the FHIR IG Guidance.
Recursive Pinning
Pinning is recursive. Once a reference is pinned to a specific canonical, that canonical's own outgoing references are pinned too โ and so on, down the entire dependency chain. Each <url>|<version> pair is processed only once (memoized), so the process terminates even with circular references.
This recursive walk is also what drives tree-shaking.
Tree-Shaking: Only What You Need
A typical FHIR package carries hundreds or thousands of canonicals. But when you install a package, you don't need all the canonicals from its dependencies โ you only need the ones that are actually referenced.
Consider installing hl7.fhir.us.core@5.0.0. It depends on hl7.terminology.r4 (4,158 canonicals) and us.nlm.vsac (8,918 canonicals). But US Core only references a small subset of each. Why load the rest?
Tree-shaking is the answer. During the recursive pinning walk, Aidbox tracks which dependency canonicals are actually reached by following outgoing references from the target package. Only these reachable canonicals โ and their own recursive dependencies โ make it into the final canonical set.
The result:
- Target package: all canonicals are included (this is what the user asked for)
- Dependency packages: only referenced canonicals are included (tree-shaken)
The reduction is dramatic. Compare the installedCanonicals counts from a real installation:
POST /fhir/$fhir-package-install
Content-Type: application/json
{
"resourceType": "Parameters",
"parameter": [
{ "name": "package", "valueString": "hl7.fhir.us.core@5.0.0" }
]
}
The response shows how dramatically tree-shaking reduces the canonical count. Out of 4,158 canonicals in hl7.terminology.r4, only 7 survive. Out of 8,918 in us.nlm.vsac, only 20:
| Package | Installed Canonicals | Intention |
|---|---|---|
hl7.fhir.us.core@5.0.0 | 194 | direct |
us.nlm.vsac@0.3.0 | 20 | transitive |
hl7.fhir.uv.sdc@3.0.0 | 11 | transitive |
hl7.terminology.r4@3.1.0 | 7 | transitive |
Every canonical in the system is unique by <url>|<version>. No duplicates, no conflicts, no ambiguity.
Package Sources
Aidbox supports three ways to obtain packages:
NPM registries. The default registry is https://fs.get-ig.org/pkgs, which mirrors packages2.fhir.org with daily syncs. You can point Aidbox to any NPM-compatible registry โ Simplifier, a private Verdaccio instance, or any custom mirror.
Local filesystem. Mount a directory with .tgz packages to /srv/aidbox-fhir-packages in the Aidbox container. Packages must follow the naming convention {name}#{version}.tgz โ for example, hl7.fhir.r4.core#4.0.1.tgz. Aidbox checks local packages first before hitting the remote registry. This is essential for air-gapped environments and also speeds up startup significantly.
Direct URLs. You can pass an https:// URL pointing to a .tgz tarball or a file:// path to a local file. Useful for installing packages that aren't published to any registry โ like custom IGs built locally with SUSHI.
Dependency Overrides
Remember the hl7.fhir.us.davinci-hrex problem from earlier โ three versions of US Core, impossible to disambiguate at runtime? With overrides, the user makes this choice explicitly at configuration time.
HRex pulls in hl7.fhir.us.core@7.0.0, hl7.fhir.us.core.v610, and hl7.fhir.us.core.v311. If you only need the 7.0.0 version, skip the other two:
POST /fhir/$fhir-package-install
Content-Type: application/json
{
"resourceType": "Parameters",
"parameter": [
{ "name": "package", "valueString": "hl7.fhir.us.davinci-hrex@1.1.0" },
{
"name": "override",
"part": [
{ "name": "from", "valueString": "hl7.fhir.us.core.v610" },
{ "name": "to", "valueBoolean": false }
]
},
{
"name": "override",
"part": [
{ "name": "from", "valueString": "hl7.fhir.us.core.v311" },
{ "name": "to", "valueBoolean": false }
]
}
]
}
Three types of overrides:
| Override | Syntax | Use Case |
|---|---|---|
| Skip dependency | "to": false (valueBoolean) | Exclude a dependency โ like dropping unwanted US Core versions |
| Pin version | "to": "0.17.0" | Force a specific version of a dependency |
| Replace package | "to": "npm:alt.package@1.0.0" | Swap one package for another entirely |
The from field supports both name-only matching (hl7.fhir.us.core โ matches any version) and version-qualified matching (hl7.fhir.us.core@6.1.0 โ matches only that exact version). Version-qualified overrides take priority.
Overrides also solve registry gaps. hl7.fhir.us.core@8.0.0 depends on us.nlm.vsac@0.23.0, but Simplifier stopped hosting VSAC versions after 0.17.0. Pin it to 0.17.0, skip it entirely, or replace it with an alternative โ without modifying the source packages.
What About Runtime Installation?
Does this mean you lose the ability to install packages and canonicals at runtime? No โ but with a few clarifications.
Packages installed at runtime are still pinned automatically. The difference is that pinning happens in an isolated context: only the package's own dependency tree is involved. Already-installed content is unreachable during this pinning pass, so the new package gets a self-consistent canonical set without interfering with what's already loaded.
Individual canonicals that you upload directly โ outside of any package โ are your responsibility to pin. If you provide a canonical with unversioned outgoing references, Aidbox won't attempt to pin them for you. It does, however, retain a fallback mode for unpinned references: it will use the same candidate selection algorithm described above โ status, terminology priority, version comparison โ to resolve to the best available match. So nothing breaks โ you just don't get the determinism guarantees that package-level pinning provides.
What This Means in Practice
Here's what this gives you in Aidbox:
Predictability. The same package installation input always produces the same canonical set. There's no runtime ambiguity, no order-dependent behavior, no hidden state affecting resolution.
Performance. Canonical lookups are simple database queries by <url>|<version>. No graph traversal, no dependency walking, no caching heuristics.
Debuggability. Every canonical in the system has an explicit version. When validation fails, you see exactly which version of which StructureDefinition was used โ not "whatever happened to resolve at that moment."
Smaller footprint. Tree-shaking means transitive dependencies contribute only the canonicals that are actually needed. Installing US Core pulls in 7 canonicals from hl7.terminology.r4 instead of 4,158.
The tradeoff is that installation takes a bit longer โ Aidbox needs to walk the full dependency graph, build candidate lists, and recursively pin references. But for the initial package set, this happens only once, on first start.
References
Spec:
Aidbox docs:
Tools:
- IG Publisher โ HL7's official Implementation Guide publisher
- UploadFIG โ Brian Postlethwaite's canonical upload tool
- SUSHI โ FHIR Shorthand compiler for building custom IGs
- Verdaccio โ lightweight NPM proxy/registry for private hosting
Events:
- FHIR Camp 2025 โ where the resolution strategy was finalized




