Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Resolve OCL canonicals via $resolveReference instead of guessing - #267

Closed
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference
Closed

Resolve OCL canonicals via $resolveReference instead of guessing#267
italomacedo wants to merge 9 commits into
mainfrom
ocl-resolve-reference

Conversation

@italomacedo

Copy link
Copy Markdown
Collaborator

What

Teaches the OCL integration to ask OCL which repo holds a canonical URL, using OCL's
$resolveReference
operation, instead of guessing. This is the client half of OCL issue #2261, "replace repo/repo
version requests with $resolveReference"
.

Today cm-ocl answers "which repo holds this canonical?" heuristically: it derives a search
token from the URL, pages through /orgs/{org}/sources/?q=..., and matches canonical_url on
whatever the search surfaces. $resolveReference answers it authoritatively in one call.

Scope is deliberately narrow.$resolve is not touched: that is Terminology Ecosystem
infrastructure at the coordination layer, and nothing here may make OCL's resolution path depend
on a FHIRsmith facade being in front of it. registry/ is untouched and its tests still pass.

Nothing changes without a token

$resolveReference is authenticated on every OCL instance probed, while the /orgs/ enumeration
it replaces is public. So the resolver is constructed disabled when no token= is configured,
and every caller falls back to the path it used before. Existing ocl: source lines behave
exactly as they do today; adding a token is opt-in. token= is not new — tx/library.js has
always parsed it — it simply carries weight it did not before.

ConfigurationCanonical → repoBehaviour
ocl:https://ocl.example.org/orgs/{org}/sources/?q= searchunchanged
ocl:https://ocl.example.org|token=...$resolveReferenceauthoritative, falls back on failure

The resolver also disables itself permanently on 404 (not implemented) or 401/403
(credentials rejected), so an instance without the operation degrades to today's behaviour rather
than failing.

Commits

Each stands alone and is separately reviewable:

$resolveReference clientnew tx/ocl/resolve/reference-resolver.js
user-owned reposcm-ocl filtered candidates with startsWith('/orgs/') in three places, silently discarding every /users/{user}/... repo. Needs no token.
wire into ConceptMapresolver first, search as fallback
docsthe token's role, and what the operation does not do
log which path ranresolver and search return the same thing, so success was unobservable
canonical casingsearchConceptMaps lower-cased every param. #norm() lower-cases anyway so nothing noticed, and the text search tolerated it — but $resolveReference matches exactly, so every lookup silently fell back
source mappingssee below
canonical_urlthe repo's own canonical was being discarded in favour of echoing the request back

The concept×mappings loop

searchConceptMaps has no concept code — it asks "what mappings does this source have?" — but
answered that with the per-concept endpoint: list the concepts, then one request per concept.

  • Correctness. The concept listing is capped at maxSearchPages (10 × 100 = 1000). LOINC has
    184,683 concepts, so it only ever saw 0.5% of them; any mapping past the first 1000 was
    silently invisible. No error — just fewer results.
  • Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and cmed
    all timed out and returned nothing at all.

{source}/mappings/ answers the actual question in one paginated call. Verified equivalent before
switching — for AlcoolSPA_uso_Mangara both paths return the identical mapping set, one in 1
request instead of 4.

canonicalbeforeafter
http://loinc.orgtimeout (45s+)0.44s, 1 ConceptMap
http://snomed.info/scttimeout (45s+)0.98s, 5 ConceptMaps
v3-ActCodetimeout (45s+)0.18s
cmedtimeout (45s+)0.16s

loinc and snomed previously returned nothing, so this restores results rather than merely
speeding them up.

The per-concept endpoint stays where it belongs: findConceptMapForTranslation has a sourceCode
and asks about that one concept, so its single targeted request is already right. Untouched.

Testing

170 tests, positive and negative. tx/ocl/resolve/reference-resolver.js is at 100% statements /
lines / functions, 96.8% branches
:

npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js"

jest.config.js is untouched. Note it globs collectCoverageFrom: ['**/*.js'] and not
**/*.cjs, so the .cjs modules in tx/ocl are invisible to coverage and the command documented
in tx/ocl/README.md reports on their one-line .js re-export stubs. That is why the new module
is plain .js — it makes the number real without changing the config. The cm-ocl.cjs edits are
covered behaviourally but cannot contribute to a coverage figure. README now says so.

Verified end to end against a live OCL instance, not just mocks:

[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)

Worth stating plainly: the casing bug, the 0.5% ceiling and the echoed canonical were all
invisible to the mocked tests
— a mock returns what you told it to return. All three surfaced
only by running the server against real OCL. The response-shape tests are now built from a
captured payload rather than from the documented example, which omits most fields and reports
type: "Source Version" where OCL actually returns "Source".

Notes for reviewers

  • Namespace is derived from the existing org= (/orgs/{org}/), else / (global, OCL's own
    default). A FHIR request carries no namespace, so it is bound per source entry. Explicit
    namespace= would need parseOclConfig in tx/library.js and is left for later.
  • Namespace is a preference, not a boundary: OCL falls through to the Global URL Registry, so
    a namespaced resolve can still return another owner's repo. Sandboxing would have to be enforced
    client-side; not implemented, and documented as such.
  • Discovery is unchanged.$resolveReference resolves a known reference and cannot list, so
    the /orgs/ enumeration stays. This PR does not make the integration scale to all of OCL Online
    — that needs lazy provider registration in tx/library.js.
  • Batching groups references by namespace and uses the request-level query parameter; OCL
    discourages the per-reference namespace field, so it is never emitted.
  • Results are positional, so a count mismatch discards the whole group rather than risk
    attributing a resolution to the wrong canonical.

🤖 Generated with Claude Code

italomacedoand others added 9 commits July 15, 2026 14:38
Adds a client for OCL's $resolveReference operation, which resolves a canonical
URL (or relative OCL path) to the repo that holds it. This is the building block
for OCL #2261 ("replace repo/repo version requests with $resolveReference"):
today tx/ocl finds repos by hand-building /orgs/{owner}/... paths or by
heuristic text search.
Design notes:
- Namespace is derived from the existing `org=` source config (/orgs/{org}/),
falling back to `/` (global). A FHIR request carries no namespace, so it is
bound per source entry. A malformed namespace throws rather than silently
degrading to global, which would look fine while resolving in the wrong
context.
- References are grouped by namespace and sent one POST per group using the
`namespace` query parameter. OCL discourages the per-reference `namespace`
field, so it is never emitted in the body.
- Results are positional; if OCL returns a different count than we sent, the
whole group is discarded rather than risk attributing a resolution to the
wrong canonical.
- $resolveReference is auth-gated on every instance probed, while the /orgs/
enumeration it replaces is public. Without a token the resolver stays disabled
so callers keep their existing path, and 404/401/403 disable it permanently.
Plain .js rather than the .cjs+stub convention used elsewhere in tx/ocl: jest's
collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here would
be invisible to coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-ocl filtered candidate repo paths with startsWith('/orgs/') in three places,
which silently excluded every user-owned repo. OCL repos are owned by an org OR
a user, so /users/{user}/sources/{id}/ is equally valid and was being discarded:
mappings on a user-owned source simply never resolved, with no error to explain
why.
Replaces the three checks with isOclRepoPath(), which accepts both owner types.
This needs no token and no $resolveReference support on the instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#candidateSourceUrls answers "which OCL repo holds this canonical?" by heuristic:
it derives a search token from the URL, pages through /orgs/{org}/sources/?q=...,
and matches canonical_url on the results. That is guesswork, it costs several
requests, and it only finds what the search surfaces.
$resolveReference answers the same question authoritatively in one call, so ask
OCL first and keep the search as the fallback. This is the ConceptMap half of
OCL #2261.
Deliberately unchanged:
- Discovery (/orgs/ -> /orgs/{org}/sources/ enumeration) still stands.
$resolveReference resolves a known reference; it cannot list what exists.
- The hand-built path builders in cs-ocl/vs-ocl are left alone. They are
synchronous third-choice fallbacks behind OCL-supplied concepts_url /
expansion_url, so routing them through an async resolver would restructure
snapshot building for a path that rarely runs.
Behaviour is unchanged unless a token is configured: without one the resolver
stays disabled and #candidateSourceUrls keeps using the search exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`token=` is not a new parameter -- tx/library.js has always parsed it and the
README has always listed it -- but it now carries weight it did not before: it is
what enables $resolveReference. The docs said nothing about that, so a reader had
no way to know why the operation was or wasn't being used.
Adds a "Canonical resolution via $resolveReference" section covering:
- why a token is needed at all: $resolveReference is authenticated on every OCL
instance probed, while the /orgs/ enumeration it replaces is public
- that the token stays optional and no existing config breaks: with no token the
resolver is disabled and callers fall back to the previous path, shown as a
before/after table
- when the resolver disables itself (404/401/403) versus logs and continues (400)
- how the namespace is derived from `org=`, and that OCL's fallthrough to the
Global URL Registry means a namespace is a preference, not a boundary
- that it resolves a known reference and cannot list, so discovery is unchanged
Also warns against committing a real token (data/library.yml is tracked and there
is no env-var interpolation), and notes that the documented coverage command
reports on the one-line .js stubs rather than the .cjs implementations, since
collectCoverageFrom globs **/*.js only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous wording told readers to keep the token uncommitted, full stop. That
is wrong for a private deployment repository that exists to carry environment
config, which is a normal way to run this server.
Describe the actual risk instead -- no env-var interpolation exists, so the token
lives in the tracked library YAML; committing it puts a live credential in
history, readable by anyone with repo access and revocable only by rotating it at
OCL -- and let the deployer judge. Keep the hard line where it belongs: not in a
public repo, and never travelling upstream with a contribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
$resolveReference and the source search return the same thing, so there was no
way -- from the HTTP response or from the logs -- to tell which one ran. The
resolver only logged on failure, which made the feature unobservable in exactly
the case you care about: success.
Logs one line per outcome:
- resolved: canonical -> repo, with namespace and whether an OCL url registry
entry was involved
- tried but unresolved: says it is falling back to the search
- not enabled (typically no token): logged once per provider, not per lookup,
since for a tokenless deployment that is the expected steady state rather than
an error
The registry-entry detail also gives us the first real evidence of what
url_registry_entry looks like in practice, which the namespace sandbox design
currently rests on assumption for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
searchConceptMaps lower-cased every search param value. Every consumer compared
through #norm(), which lower-cases anyway, so nothing noticed -- and the source
search is a text query, which tolerated it too.
$resolveReference matches the canonical exactly. OCL has no
.../fhir/codesystem/alcoolspa_uso_mangara, only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara,
so every lookup came back unresolved and silently fell back to the search:
[OCL] $resolveReference did not resolve
https://mangara.hsl.org.br/fhir/codesystem/alcoolspa_uso_mangara;
falling back to source search
Found by running the server against the live OCL instance -- the mocked tests
could not catch it, because they assert on whatever casing the test itself feeds
in. findConceptMapForTranslation was never affected: it takes the system from its
caller and never went through this map.
With the casing preserved, the same request now resolves:
[OCL] $resolveReference resolved
https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara
-> /orgs/HL7/sources/AlcoolSPA_uso_Mangara/ (namespace /, via namespace)
and in 1.3s rather than 5.5s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cept
searchConceptMaps has no concept code -- it asks "what mappings does this source
have?" -- but it answered that with the per-concept endpoint: list the concepts,
then issue one request per concept and union the results.
Two problems, measured against the live OCL instance:
- Correctness. The concept listing is capped at maxSearchPages (10 x 100 = 1000).
LOINC has 184683 concepts, so it only ever saw 0.5% of them, and any mapping on
a concept past the first 1000 was silently invisible.
- Cost. Up to 1000 sequential requests per source. loinc, snomed, v3-ActCode and
cmed all timed out (>45s) and returned nothing at all; in production the same
thing shows up as 504s on BRCBO/BRCIAP2.
{source}/mappings/ answers the actual question in one paginated call. Verified
equivalent before switching -- for AlcoolSPA_uso_Mangara both paths return the
identical mapping set (2 mappings), one in 1 request instead of 4:
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAOINFORMADO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|1157031005
/orgs/HL7/sources/AlcoolSPA_uso_Mangara/|NAO -[NARROWER-THAN]-> /orgs/SNOMED/sources/gps/|373067005
Measured end to end:
http://loinc.org timeout(45s+) -> 0.44s, 1 ConceptMap
http://snomed.info/sct timeout(45s+) -> 0.98s, 5 ConceptMaps
v3-ActCode timeout(45s+) -> 0.18s
cmed timeout(45s+) -> 0.16s
loinc and snomed previously returned nothing, so this restores results rather
than merely speeding them up.
The per-concept endpoint stays where it belongs: findConceptMapForTranslation has
a sourceCode and asks about that one concept, so its single targeted request is
already the right call. Untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elling
I built the resolver's result handling from the documented example, which shows
result as {type, short_code, url}. A real response from oclapi2.ips.hsl.org.br
for ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"] carries far more:
"result": {
"short_code": "BRTabelaSUS", "name": "BRTabelaSUS",
"url": "/orgs/MS/sources/BRTabelaSUS/",
"owner": "MS", "owner_type": "Organization", "owner_url": "/orgs/MS/",
"version": "HEAD", "source_type": "Dictionary", "type": "Source",
"canonical_url": "https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS",
"checksums": { "standard": "...", "smart": "..." }
}
Note "type": "Source", not "Source Version" as the doc's example shows.
Three consequences:
- canonical_url is authoritative and was being thrown away. cm-ocl recorded the
repo's canonical by echoing back whatever the caller asked with, so a caller
using a different spelling (http vs https, say) poisoned _canonicalBySourceUrl
with a canonical the repo does not actually claim. Now surfaced as .canonical
and used for that bookkeeping.
- owner_type is surfaced too: OCL states the owner kind rather than leaving us to
infer it from the path.
- The test fixture was modelled on the doc's example -- i.e. on a shape OCL does
not return. Rebuilt from the captured response, plus a test asserting the
verbatim payload so the doc drifting from reality cannot quietly mislead us
again.
This also retires a claim I made earlier: that vs-ocl's owner/source -> canonical
lookup could not use $resolveReference because result had no canonical. It does.
Not worth switching (a direct GET of the source is one request either way, and
the URL registry is empty on this instance), but the stated reason was wrong.
The raw result object is still passed through untouched, so version and checksums
remain available to callers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@italomacedo