From a0a455216075b5821d4431392b02b1cef9143d89 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 11:24:50 -0300 Subject: [PATCH 1/9] feat(ocl): add $resolveReference client 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 --- tests/ocl/ocl-reference-resolver.test.js | 565 +++++++++++++++++++++++ tx/ocl/resolve/reference-resolver.js | 360 +++++++++++++++ 2 files changed, 925 insertions(+) create mode 100644 tests/ocl/ocl-reference-resolver.test.js create mode 100644 tx/ocl/resolve/reference-resolver.js diff --git a/tests/ocl/ocl-reference-resolver.test.js b/tests/ocl/ocl-reference-resolver.test.js new file mode 100644 index 00000000..498935cd --- /dev/null +++ b/tests/ocl/ocl-reference-resolver.test.js @@ -0,0 +1,565 @@ +const { + OclReferenceResolver, + normalizeNamespace, + deriveNamespace, + normalizeReference, + isOclRepoPath, + GLOBAL_NAMESPACE, + RESOLVE_PATH +} = require('../../tx/ocl/resolve/reference-resolver'); + +function silentLogger() { + return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +} + +// Shape of one entry in OCL's $resolveReference response array. +function oclEntry({ + resolved = true, + url = '/orgs/CIEL/sources/CIEL/HEAD/', + registryEntry = null, + referenceType = 'relative', + resolutionUrl = null, + request = null +} = {}) { + return { + reference_type: referenceType, + timestamp: '2026-07-15T13:12:18.919301', + resolved, + request, + resolution_url: resolutionUrl, + url_registry_entry: registryEntry, + result: resolved ? { type: 'Source Version', short_code: 'CIEL', url } : null + }; +} + +// Echoes one result per submitted ref, tagging the url so ordering is assertable. +function echoClient() { + return { + post: jest.fn(async (path, body) => ({ + data: body.map(ref => { + const url = typeof ref === 'string' ? ref : ref.url; + return oclEntry({ url: `/orgs/X/sources/${url}/HEAD/`, request: ref }); + }) + })) + }; +} + +function makeResolver({ httpClient, logger, token = 'Token abc', ...rest } = {}) { + return new OclReferenceResolver({ + httpClient: httpClient || echoClient(), + logger: logger || silentLogger(), + token, + ...rest + }); +} + +function httpError(status, data) { + const error = new Error(`Request failed with status code ${status}`); + error.response = { status, data }; + return error; +} + +describe('normalizeNamespace', () => { + it.each([ + ['MyOrg', '/orgs/MyOrg/'], + ['/orgs/MyOrg', '/orgs/MyOrg/'], + ['orgs/MyOrg/', '/orgs/MyOrg/'], + ['/orgs/MyOrg/', '/orgs/MyOrg/'], + [' /orgs/MyOrg/ ', '/orgs/MyOrg/'], + ['/users/joe/', '/users/joe/'], + ['users/joe', '/users/joe/'] + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeNamespace(input)).toBe(expected); + }); + + it.each([['/'], [''], [' '], [null], [undefined]])( + 'treats %s as the global namespace', + input => { + expect(normalizeNamespace(input)).toBe(GLOBAL_NAMESPACE); + } + ); + + // A malformed namespace must throw rather than silently degrade to global — + // that would look fine while resolving against the wrong context. + it.each([['/orgs/'], ['/users/'], ['/foo/bar/'], ['//'], ['a/b/c'], ['/orgs/a/b/']])( + 'rejects %s', + input => { + expect(() => normalizeNamespace(input)).toThrow(/Invalid OCL namespace/); + } + ); +}); + +describe('isOclRepoPath', () => { + it.each([ + ['/orgs/CIEL/sources/CIEL/'], + ['/orgs/CIEL/sources/CIEL/HEAD/'], + ['/orgs/CIEL/collections/C/'], + // The whole point: user-owned repos are real and must not be filtered out. + ['/users/joe/sources/S/'], + [' /users/joe/sources/S/ '] + ])('accepts %s', input => { + expect(isOclRepoPath(input)).toBe(true); + }); + + it.each([ + ['http://loinc.org'], + ['/sources/S/'], + ['/orgs/'], + ['/orgs/CIEL'], + ['/groups/x/sources/S/'], + [''], + [null], + [undefined] + ])('rejects %p', input => { + expect(isOclRepoPath(input)).toBe(false); + }); +}); + +describe('deriveNamespace', () => { + it('prefers an explicit namespace over org', () => { + expect(deriveNamespace({ namespace: '/users/joe/', org: 'CIEL' })).toBe('/users/joe/'); + }); + + it('derives /orgs/{org}/ from org', () => { + expect(deriveNamespace({ org: 'CIEL' })).toBe('/orgs/CIEL/'); + }); + + it.each([[{}], [{ org: '' }], [{ namespace: ' ', org: null }], [undefined]])( + 'falls back to the global namespace for %s', + input => { + expect(deriveNamespace(input)).toBe(GLOBAL_NAMESPACE); + } + ); +}); + +describe('normalizeReference', () => { + it('accepts a relative path string and sends it as a string', () => { + const ref = normalizeReference('/orgs/CIEL/sources/CIEL/'); + expect(ref.url).toBe('/orgs/CIEL/sources/CIEL/'); + expect(ref.body).toBe('/orgs/CIEL/sources/CIEL/'); + expect(ref.namespace).toBeNull(); + }); + + it('keeps the expanded object fields', () => { + const ref = normalizeReference({ + url: 'http://hl7.org/fhir/CodeSystem/x', + version: '0.8', + code: '1948', + resourceType: 'Mapping' + }); + expect(ref.body).toEqual({ + url: 'http://hl7.org/fhir/CodeSystem/x', + version: '0.8', + code: '1948', + resourceType: 'Mapping' + }); + }); + + it('extracts namespace for grouping but never puts it in the body', () => { + const ref = normalizeReference({ url: '/orgs/A/sources/S/', namespace: '/orgs/A/' }); + expect(ref.namespace).toBe('/orgs/A/'); + expect(ref.body).not.toHaveProperty('namespace'); + }); + + it('drops null and undefined fields', () => { + const ref = normalizeReference({ url: 'x', version: null, code: undefined }); + expect(ref.body).toEqual({ url: 'x' }); + }); + + it.each([[''], [' ']])('rejects the empty string %p', input => { + expect(() => normalizeReference(input)).toThrow(/cannot be empty/); + }); + + it.each([[{}], [{ url: '' }], [{ url: null }]])('rejects object %p without a url', input => { + expect(() => normalizeReference(input)).toThrow(/requires a url/); + }); + + it.each([[null], [undefined], [123], [[]]])('rejects %p', input => { + expect(() => normalizeReference(input)).toThrow(/Invalid OCL reference/); + }); +}); + +describe('OclReferenceResolver construction', () => { + it('requires an http client', () => { + expect(() => new OclReferenceResolver({ token: 'Token abc' })).toThrow(/requires an http client/); + }); + + it('derives its namespace from org', () => { + expect(makeResolver({ org: 'CIEL' }).namespace).toBe('/orgs/CIEL/'); + }); + + it('defaults to the global namespace when no org is configured', () => { + expect(makeResolver().namespace).toBe(GLOBAL_NAMESPACE); + }); + + it('throws on a malformed configured namespace', () => { + expect(() => makeResolver({ namespace: '/foo/bar/' })).toThrow(/Invalid OCL namespace/); + }); + + // $resolveReference is auth-gated on every instance probed, while the /orgs/ + // enumeration it replaces is public — so a tokenless call is a guaranteed 401. + it('stays disabled without a token and issues no request', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient, token: null }); + + expect(resolver.isEnabled()).toBe(false); + expect(resolver.disabledReason).toBe('no token configured'); + await expect(resolver.resolveReferences(['/orgs/A/sources/S/'])).resolves.toBeNull(); + expect(httpClient.post).not.toHaveBeenCalled(); + }); + + it('is enabled with a token', () => { + expect(makeResolver().isEnabled()).toBe(true); + }); +}); + +describe('OclReferenceResolver resolution', () => { + it('resolves a single relative reference', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ url: '/orgs/CIEL/sources/CIEL/HEAD/' })] })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/CIEL/sources/CIEL/'); + + expect(result.resolved).toBe(true); + expect(result.repoUrl).toBe('/orgs/CIEL/sources/CIEL/HEAD/'); + expect(httpClient.post).toHaveBeenCalledWith( + RESOLVE_PATH, + ['/orgs/CIEL/sources/CIEL/'], + { params: { namespace: GLOBAL_NAMESPACE } } + ); + }); + + it('resolves an expanded object reference', async () => { + const httpClient = { + post: jest.fn(async () => ({ + data: [oclEntry({ url: '/orgs/CIEL/sources/CIEL/v2021-03-12/', referenceType: 'canonical' })] + })) + }; + const resolver = makeResolver({ httpClient, org: 'CIEL' }); + + const result = await resolver.resolve({ + url: 'http://hl7.org/fhir/CodeSystem/my-codesystem', + version: '0.8', + code: '1948' + }); + + expect(result.resolved).toBe(true); + expect(result.referenceType).toBe('canonical'); + expect(httpClient.post).toHaveBeenCalledWith( + RESOLVE_PATH, + [{ url: 'http://hl7.org/fhir/CodeSystem/my-codesystem', version: '0.8', code: '1948' }], + { params: { namespace: '/orgs/CIEL/' } } + ); + }); + + it('passes a piped canonical through untouched', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await resolver.resolve('http://terminology.hl7.org/CodeSystem/x|0.1.0'); + + expect(httpClient.post).toHaveBeenCalledWith( + RESOLVE_PATH, + ['http://terminology.hl7.org/CodeSystem/x|0.1.0'], + expect.anything() + ); + }); + + it('resolves a user-owned repo (regression: /orgs/-only filters dropped these)', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ url: '/users/joe/sources/S/HEAD/' })] })) + }; + const resolver = makeResolver({ httpClient, namespace: '/users/joe/' }); + + const result = await resolver.resolve('/users/joe/sources/S/'); + + expect(result.repoUrl).toBe('/users/joe/sources/S/HEAD/'); + }); + + it('surfaces url_registry_entry (Phase 2 sandbox needs it)', async () => { + const httpClient = { + post: jest.fn(async () => ({ + data: [ + oclEntry({ + registryEntry: '/orgs/MyOrg/url-registry/1/', + resolutionUrl: 'http://terminology.hl7.org/CodeSystem/x' + }) + ] + })) + }; + const resolver = makeResolver({ httpClient, org: 'MyOrg' }); + + const result = await resolver.resolve('http://terminology.hl7.org/CodeSystem/x|0.1.0'); + + expect(result.registryEntry).toBe('/orgs/MyOrg/url-registry/1/'); + expect(result.resolutionUrl).toBe('http://terminology.hl7.org/CodeSystem/x'); + }); + + it('returns an empty array and issues no request for no references', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await expect(resolver.resolveReferences([])).resolves.toEqual([]); + expect(httpClient.post).not.toHaveBeenCalled(); + }); + + it('reports resolved:false without throwing', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ resolved: false })] })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/nope/sources/nope/'); + + expect(result.resolved).toBe(false); + expect(result.repoUrl).toBeNull(); + }); + + it('treats a resolved flag with no result url as unresolved', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [{ resolved: true, result: null }] })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.resolved).toBe(false); + }); + + it.each([[null], ['oops'], [42]])('treats a malformed result entry %p as unresolved', async entry => { + const httpClient = { post: jest.fn(async () => ({ data: [entry] })) }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.resolved).toBe(false); + expect(result.repoUrl).toBeNull(); + }); + + it('tolerates a non-array response body', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: oclEntry({ url: '/orgs/A/sources/S/HEAD/' }) })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.repoUrl).toBe('/orgs/A/sources/S/HEAD/'); + }); + + it('tolerates a null response body', async () => { + const httpClient = { post: jest.fn(async () => ({ data: null })) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.resolved).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/misaligned/)); + }); +}); + +describe('OclReferenceResolver batching', () => { + // OCL discourages the per-reference namespace field, so we group by namespace and + // use the request-level query parameter instead. + it('groups by namespace: one POST per namespace, order preserved, no per-ref namespace', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient, org: 'Default' }); + + const results = await resolver.resolveReferences([ + 'a', + { url: 'b', namespace: '/orgs/Other/' }, + 'c' + ]); + + expect(httpClient.post).toHaveBeenCalledTimes(2); + + const [firstCall, secondCall] = httpClient.post.mock.calls; + expect(firstCall[1]).toEqual(['a', 'c']); + expect(firstCall[2]).toEqual({ params: { namespace: '/orgs/Default/' } }); + expect(secondCall[1]).toEqual([{ url: 'b' }]); + expect(secondCall[2]).toEqual({ params: { namespace: '/orgs/Other/' } }); + + // The namespace field must never appear in the body. + expect(JSON.stringify(secondCall[1])).not.toContain('namespace'); + + // Caller order survives the regrouping. + expect(results.map(r => r.repoUrl)).toEqual([ + '/orgs/X/sources/a/HEAD/', + '/orgs/X/sources/b/HEAD/', + '/orgs/X/sources/c/HEAD/' + ]); + }); + + it('honours a per-call namespace override', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient, org: 'Default' }); + + await resolver.resolveReferences(['a'], { namespace: '/users/joe/' }); + + expect(httpClient.post.mock.calls[0][2]).toEqual({ params: { namespace: '/users/joe/' } }); + }); + + it('discards a group whose result count does not match the request count', async () => { + // Never let result[0] be attributed to the wrong canonical. + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ url: '/orgs/A/sources/only/HEAD/' })] })) + }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + const results = await resolver.resolveReferences(['a', 'b']); + + expect(results).toHaveLength(2); + expect(results.every(r => r.resolved === false)).toBe(true); + expect(logger.error).toHaveBeenCalledWith( + expect.stringMatching(/returned 1 result\(s\) for 2 reference\(s\).*misaligned/) + ); + }); +}); + +describe('OclReferenceResolver caching', () => { + it('serves a repeat reference from cache', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + const first = await resolver.resolve('/orgs/A/sources/S/'); + const second = await resolver.resolve('/orgs/A/sources/S/'); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(second).toEqual(first); + }); + + // The same canonical legitimately resolves differently per namespace; a + // namespace-blind cache key would cross-contaminate them. + it('does not collide across namespaces', async () => { + const httpClient = { + post: jest.fn(async (path, body, config) => ({ + data: body.map(() => oclEntry({ url: `${config.params.namespace}sources/S/HEAD/` })) + })) + }; + const resolver = makeResolver({ httpClient }); + + const a = await resolver.resolve('S', { namespace: '/orgs/A/' }); + const b = await resolver.resolve('S', { namespace: '/orgs/B/' }); + + expect(httpClient.post).toHaveBeenCalledTimes(2); + expect(a.repoUrl).toBe('/orgs/A/sources/S/HEAD/'); + expect(b.repoUrl).toBe('/orgs/B/sources/S/HEAD/'); + }); + + it('distinguishes references that differ only by code', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await resolver.resolve({ url: 'S', code: '1' }); + await resolver.resolve({ url: 'S', code: '2' }); + + expect(httpClient.post).toHaveBeenCalledTimes(2); + }); + + it('mixes cached and uncached references in one call', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await resolver.resolve('a'); + httpClient.post.mockClear(); + + const results = await resolver.resolveReferences(['a', 'b']); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(httpClient.post.mock.calls[0][1]).toEqual(['b']); + expect(results.map(r => r.repoUrl)).toEqual([ + '/orgs/X/sources/a/HEAD/', + '/orgs/X/sources/b/HEAD/' + ]); + }); + + it('does not cache a failed resolution', async () => { + const httpClient = { + post: jest + .fn() + .mockRejectedValueOnce(httpError(400, { detail: 'bad' })) + .mockResolvedValueOnce({ data: [oclEntry({ url: '/orgs/A/sources/S/HEAD/' })] }) + }; + const resolver = makeResolver({ httpClient, logger: silentLogger() }); + + const first = await resolver.resolve('/orgs/A/sources/S/'); + const second = await resolver.resolve('/orgs/A/sources/S/'); + + expect(first.resolved).toBe(false); + expect(second.resolved).toBe(true); + expect(httpClient.post).toHaveBeenCalledTimes(2); + }); +}); + +describe('OclReferenceResolver error handling', () => { + it('disables itself and falls back when the endpoint is missing (404)', async () => { + const httpClient = { post: jest.fn().mockRejectedValue(httpError(404)) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + await expect(resolver.resolveReferences(['a'])).resolves.toBeNull(); + expect(resolver.isEnabled()).toBe(false); + expect(resolver.disabledReason).toBe('endpoint not implemented (404)'); + expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/not available/)); + + // Stays disabled: no further requests. + await expect(resolver.resolveReferences(['b'])).resolves.toBeNull(); + expect(httpClient.post).toHaveBeenCalledTimes(1); + }); + + it.each([[401], [403]])('disables itself and falls back on %i', async status => { + const httpClient = { post: jest.fn().mockRejectedValue(httpError(status)) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + await expect(resolver.resolveReferences(['a'])).resolves.toBeNull(); + expect(resolver.isEnabled()).toBe(false); + expect(resolver.disabledReason).toBe(`not authorised (${status})`); + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/credentials/)); + }); + + // A 400 is our bug, not the instance's — log it loudly but stay enabled, since a + // later well-formed batch may be fine. + it('reports a 400 without disabling', async () => { + const httpClient = { post: jest.fn().mockRejectedValue(httpError(400, { detail: 'malformed' })) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + const results = await resolver.resolveReferences(['a', 'b']); + + expect(results).toHaveLength(2); + expect(results.every(r => r.resolved === false)).toBe(true); + expect(resolver.isEnabled()).toBe(true); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('malformed')); + }); + + it('falls back on a network timeout without disabling', async () => { + const httpClient = { post: jest.fn().mockRejectedValue(new Error('timeout of 30000ms exceeded')) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + await expect(resolver.resolveReferences(['a'])).resolves.toBeNull(); + expect(resolver.isEnabled()).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/timeout/)); + }); + + it('falls back wholesale when one group fails mid-flight', async () => { + // Half-resolved sets are worse than none: the caller would mix resolver output + // with its fallback path. + const httpClient = { + post: jest + .fn() + .mockResolvedValueOnce({ data: [oclEntry({ url: '/orgs/A/sources/S/HEAD/' })] }) + .mockRejectedValueOnce(httpError(401)) + }; + const resolver = makeResolver({ httpClient, logger: silentLogger(), org: 'A' }); + + const results = await resolver.resolveReferences(['a', { url: 'b', namespace: '/orgs/B/' }]); + + expect(results).toBeNull(); + }); +}); diff --git a/tx/ocl/resolve/reference-resolver.js b/tx/ocl/resolve/reference-resolver.js new file mode 100644 index 00000000..ebe822e2 --- /dev/null +++ b/tx/ocl/resolve/reference-resolver.js @@ -0,0 +1,360 @@ +// Client for OCL's $resolveReference operation. +// +// Resolves a canonical URL (or a relative OCL path) to the OCL repo that holds it, +// instead of hand-building /orgs/{owner}/sources/{id}/ paths. See +// https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html +// +// This is 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. + +const RESOLVE_PATH = '/$resolveReference/'; +const GLOBAL_NAMESPACE = '/'; +const OWNER_TYPES = new Set(['orgs', 'users']); + +// OCL repos are owned by an org OR a user. Matching only /orgs/ silently drops +// every user-owned repo. +const REPO_PATH_PATTERN = /^\/(orgs|users)\/[^/]+\//; + +// namespace is deliberately absent: OCL discourages the per-reference namespace field, +// preferring the request-level query parameter, so we group by namespace instead. +const BODY_FIELDS = [ + 'url', + 'version', + 'code', + 'display', + 'id', + 'filter', + 'cascade', + 'includeExclude', + 'resourceType' +]; + +/** + * True for a relative OCL repo path under either owner type, e.g. + * `/orgs/CIEL/sources/CIEL/` or `/users/joe/sources/S/`. + */ +function isOclRepoPath(value) { + return REPO_PATH_PATTERN.test(String(value == null ? '' : value).trim()); +} + +/** + * Normalize a namespace to OCL's `/:ownerType/:owner/` form, or `/` for global. + * A malformed namespace throws rather than degrading to global, which would look + * fine while silently resolving against the wrong context. + */ +function normalizeNamespace(value) { + const text = String(value == null ? '' : value).trim(); + if (text === '' || text === GLOBAL_NAMESPACE) { + return GLOBAL_NAMESPACE; + } + + // A bare token with no slashes is an org id: `CIEL` -> `/orgs/CIEL/` + if (!text.includes('/')) { + return `/orgs/${text}/`; + } + + const normalized = `/${text.replace(/^\/+/, '').replace(/\/+$/, '')}/`; + const segments = normalized.slice(1, -1).split('/'); + if (segments.length !== 2 || !OWNER_TYPES.has(segments[0]) || !segments[1]) { + throw new Error( + `Invalid OCL namespace '${value}': expected '/' or '/orgs/{id}/' or '/users/{id}/'` + ); + } + + return normalized; +} + +/** + * Effective namespace for a source. Explicit namespace wins; otherwise derive from + * `org`, which is the only namespace-bearing config that reaches tx/ocl today. With a + * global namespace an org's own URL Registry is never consulted, which is not what a + * source pinned to org=X wants. + */ +function deriveNamespace({ namespace = null, org = null } = {}) { + if (namespace != null && String(namespace).trim() !== '') { + return normalizeNamespace(namespace); + } + if (org != null && String(org).trim() !== '') { + return normalizeNamespace(org); + } + return GLOBAL_NAMESPACE; +} + +/** + * Accepts either a relative/canonical URL string or an expanded reference object. + * Returns the request body form plus the namespace hint used for grouping. + */ +function normalizeReference(ref) { + if (typeof ref === 'string') { + const url = ref.trim(); + if (!url) { + throw new Error('OCL reference string cannot be empty'); + } + return { url, namespace: null, body: url }; + } + + if (!ref || typeof ref !== 'object' || Array.isArray(ref)) { + throw new Error(`Invalid OCL reference: expected a string or object, got ${typeof ref}`); + } + + const url = String(ref.url == null ? '' : ref.url).trim(); + if (!url) { + throw new Error('OCL reference object requires a url'); + } + + const body = {}; + for (const field of BODY_FIELDS) { + if (ref[field] !== undefined && ref[field] !== null) { + body[field] = ref[field]; + } + } + + return { url, namespace: ref.namespace == null ? null : ref.namespace, body }; +} + +function cacheKey(namespace, body) { + return `${namespace}|${typeof body === 'string' ? body : JSON.stringify(body)}`; +} + +function unresolved(namespace, request) { + return { + resolved: false, + repoUrl: null, + resolutionUrl: null, + registryEntry: null, + referenceType: null, + namespace, + request, + result: null + }; +} + +function normalizeResult(entry, namespace, request) { + if (!entry || typeof entry !== 'object') { + return unresolved(namespace, request); + } + + const result = entry.result && typeof entry.result === 'object' ? entry.result : null; + const repoUrl = result && result.url ? result.url : null; + + return { + resolved: Boolean(entry.resolved) && Boolean(repoUrl), + repoUrl, + resolutionUrl: entry.resolution_url || null, + // Phase 2's namespace sandbox needs this to tell owner-registry delegation + // apart from a global-registry fallthrough, so never drop it. + registryEntry: entry.url_registry_entry || null, + referenceType: entry.reference_type || null, + namespace, + request: entry.request === undefined ? request : entry.request, + result + }; +} + +class OclReferenceResolver { + #httpClient; + #namespace; + #logger; + #cache = new Map(); + #enabled; + #disabledReason = null; + + /** + * @param {object} options + * @param {object} options.httpClient - axios instance from createOclHttpClient + * @param {string} [options.namespace] - explicit namespace; wins over org + * @param {string} [options.org] - org id, used to derive the namespace + * @param {string} [options.token] - when absent the resolver stays disabled + * @param {object} [options.logger] + */ + constructor({ httpClient, namespace = null, org = null, token = null, logger = console } = {}) { + if (!httpClient) { + throw new Error('OCL reference resolver requires an http client'); + } + + this.#httpClient = httpClient; + this.#namespace = deriveNamespace({ namespace, org }); + this.#logger = logger; + + // $resolveReference is auth-gated on every OCL instance we've probed, while the + // /orgs/ enumeration it replaces is public. Without a token every call is a + // guaranteed 401, so stay disabled and let the caller use its existing path. + this.#enabled = Boolean(token); + if (!this.#enabled) { + this.#disabledReason = 'no token configured'; + } + } + + get namespace() { + return this.#namespace; + } + + isEnabled() { + return this.#enabled; + } + + get disabledReason() { + return this.#disabledReason; + } + + #disable(reason) { + this.#enabled = false; + this.#disabledReason = reason; + } + + /** + * Resolve one reference. Returns null when the resolver is unavailable, so the + * caller falls back to its existing path. + */ + async resolve(ref, options = {}) { + const results = await this.resolveReferences([ref], options); + return results ? results[0] : null; + } + + /** + * Resolve many references in as few round trips as possible. + * + * References are grouped by namespace and each group is sent as one POST with a + * `namespace` query parameter. Results are returned in the caller's original order. + * + * @returns {Promise} null when the resolver is unavailable (use fallback) + */ + async resolveReferences(refs, { namespace = null } = {}) { + if (!this.#enabled) { + return null; + } + + const list = Array.isArray(refs) ? refs : [refs]; + if (list.length === 0) { + return []; + } + + const defaultNamespace = namespace == null ? this.#namespace : normalizeNamespace(namespace); + + const normalized = list.map(ref => { + const entry = normalizeReference(ref); + return { + ...entry, + effectiveNamespace: entry.namespace == null + ? defaultNamespace + : normalizeNamespace(entry.namespace) + }; + }); + + const output = new Array(normalized.length).fill(null); + const groups = new Map(); + + normalized.forEach((entry, index) => { + const key = cacheKey(entry.effectiveNamespace, entry.body); + if (this.#cache.has(key)) { + output[index] = this.#cache.get(key); + return; + } + + const group = groups.get(entry.effectiveNamespace) || []; + group.push({ entry, index }); + groups.set(entry.effectiveNamespace, group); + }); + + for (const [groupNamespace, members] of groups) { + const resolved = await this.#resolveGroup(groupNamespace, members); + if (resolved === null) { + // Resolver became unavailable mid-flight; caller falls back wholesale rather + // than acting on a half-resolved set. + return null; + } + for (const { index, value } of resolved) { + output[index] = value; + } + } + + return output; + } + + async #resolveGroup(groupNamespace, members) { + const body = members.map(({ entry }) => entry.body); + + let response; + try { + response = await this.#httpClient.post(RESOLVE_PATH, body, { + params: { namespace: groupNamespace } + }); + } catch (error) { + return this.#handleError(error, groupNamespace, members); + } + + const payload = Array.isArray(response?.data) + ? response.data + : response?.data == null + ? [] + : [response.data]; + + // Results are positional. If the count doesn't match we cannot know which result + // belongs to which reference, so treat the whole group as unresolved rather than + // silently attributing a resolution to the wrong canonical. + if (payload.length !== members.length) { + this.#logger.error( + `[OCL] $resolveReference returned ${payload.length} result(s) for ${members.length} reference(s) in namespace ${groupNamespace}; discarding group to avoid misaligned results` + ); + return members.map(({ entry, index }) => ({ + index, + value: unresolved(groupNamespace, entry.body) + })); + } + + return members.map(({ entry, index }, position) => { + const value = normalizeResult(payload[position], groupNamespace, entry.body); + this.#cache.set(cacheKey(groupNamespace, entry.body), value); + return { index, value }; + }); + } + + #handleError(error, groupNamespace, members) { + const status = error?.response?.status; + + if (status === 404) { + this.#disable('endpoint not implemented (404)'); + this.#logger.info( + `[OCL] $resolveReference is not available on this instance (404); using repo paths instead` + ); + return null; + } + + if (status === 401 || status === 403) { + this.#disable(`not authorised (${status})`); + this.#logger.warn( + `[OCL] $resolveReference rejected our credentials (${status}); using repo paths instead` + ); + return null; + } + + if (status === 400) { + // Our request body is wrong — a bug on this side. Don't disable: a later, + // well-formed batch may be fine. + const detail = error?.response?.data?.detail || error.message; + this.#logger.error( + `[OCL] $resolveReference rejected the request body in namespace ${groupNamespace}: ${detail}` + ); + return members.map(({ entry, index }) => ({ + index, + value: unresolved(groupNamespace, entry.body) + })); + } + + this.#logger.warn( + `[OCL] $resolveReference failed in namespace ${groupNamespace}: ${error.message}` + ); + return null; + } +} + +module.exports = { + OclReferenceResolver, + normalizeNamespace, + deriveNamespace, + normalizeReference, + isOclRepoPath, + GLOBAL_NAMESPACE, + RESOLVE_PATH +}; From 5c04e767341bb1cadb9d0160c341b87538781960 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 11:25:51 -0300 Subject: [PATCH 2/9] fix(ocl): stop dropping user-owned repos in ConceptMap lookups 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 --- tx/ocl/cm-ocl.cjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index d726ef0a..bd81b1a6 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -3,6 +3,7 @@ const { ConceptMap } = require('../library/conceptmap'); const { PAGE_SIZE } = require('./shared/constants'); const { createOclHttpClient } = require('./http/client'); const { fetchAllPages, extractItemsAndNext } = require('./http/pagination'); +const { isOclRepoPath } = require('./resolve/reference-resolver'); const DEFAULT_MAX_SEARCH_PAGES = 10; @@ -131,7 +132,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { async #collectMappingsForSearch(sourceSystem, targetSystem) { const systemUrl = sourceSystem || targetSystem; const candidates = await this.#candidateSourceUrls(systemUrl); - const sourcePaths = candidates.filter(s => String(s || '').startsWith('/orgs/')); + const sourcePaths = candidates.filter(isOclRepoPath); if (sourcePaths.length === 0) { return []; @@ -283,7 +284,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { const targetCandidates = await this.#candidateSourceUrls(targetSystem); const mappings = []; - const sourcePaths = sourceCandidates.filter(s => String(s || '').startsWith('/orgs/')); + const sourcePaths = sourceCandidates.filter(isOclRepoPath); if (sourceCode && sourcePaths.length > 0) { for (const sourcePath of sourcePaths) { @@ -572,7 +573,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } const sourcePath = String(sourceUrl || '').trim(); - if (!sourcePath.startsWith('/orgs/')) { + if (!isOclRepoPath(sourcePath)) { continue; } From 2dab0c8b0890d67b1c639a8adbc0c43be99d3c80 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 11:26:21 -0300 Subject: [PATCH 3/9] feat(ocl): resolve ConceptMap source canonicals via $resolveReference #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 --- tests/ocl/ocl-cm-provider.test.js | 93 +++++++++++++++++++++++++++++++ tx/ocl/cm-ocl.cjs | 54 ++++++++++++++++-- 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/tests/ocl/ocl-cm-provider.test.js b/tests/ocl/ocl-cm-provider.test.js index a1e2fc4a..418aab88 100644 --- a/tests/ocl/ocl-cm-provider.test.js +++ b/tests/ocl/ocl-cm-provider.test.js @@ -411,3 +411,96 @@ describe('OCLConceptMapProvider', () => { }); }); }); + +// --------------------------------------------------------------- +// $resolveReference integration (#2261) +// --------------------------------------------------------------- +describe('OCLConceptMapProvider $resolveReference integration', () => { + const { OclReferenceResolver } = require('../../tx/ocl/resolve/reference-resolver'); + + const SEARCH_ENDPOINT = '/orgs/TestOrg/sources/'; + + function makeProvider({ token = 'Token abc', post } = {}) { + const provider = new OCLConceptMapProvider({ org: 'TestOrg', token }); + const httpClient = { + get: jest.fn(async () => ({ data: [] })), + post: post || jest.fn(async () => ({ data: [] })) + }; + // The provider captures httpClient at construction, so rebuild the resolver + // on the mock the same way the other tests swap httpClient. + provider.httpClient = httpClient; + provider.referenceResolver = new OclReferenceResolver({ + httpClient, + org: 'TestOrg', + token, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + }); + return { provider, httpClient }; + } + + function resolveReferenceReply(url) { + return jest.fn(async () => ({ + data: [ + { + reference_type: 'canonical', + resolved: true, + request: null, + resolution_url: 'http://x.org/cs', + url_registry_entry: null, + result: { type: 'Source Version', short_code: 'S', url } + } + ] + })); + } + + function getPaths(httpClient) { + return httpClient.get.mock.calls.map(call => call[0]); + } + + const searchParams = [{ name: 'source-system', value: 'http://x.org/cs' }]; + + it('uses the resolved repo and skips the heuristic source search', async () => { + const { provider, httpClient } = makeProvider({ + post: resolveReferenceReply('/users/joe/sources/S/HEAD/') + }); + + await provider.searchConceptMaps(searchParams); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + // The authoritative answer makes the q= text search unnecessary. + expect(getPaths(httpClient)).not.toContain(SEARCH_ENDPOINT); + // ...and a user-owned repo survives, which the old /orgs/-only filter dropped. + expect(getPaths(httpClient)).toContain('/users/joe/sources/S/HEAD/concepts/'); + }); + + it('falls back to the source search when no token is configured', async () => { + const { provider, httpClient } = makeProvider({ token: null }); + + await provider.searchConceptMaps(searchParams); + + expect(httpClient.post).not.toHaveBeenCalled(); + expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT); + }); + + it('falls back to the source search when OCL cannot resolve the canonical', async () => { + const post = jest.fn(async () => ({ + data: [{ reference_type: 'canonical', resolved: false, result: null }] + })); + const { provider, httpClient } = makeProvider({ post }); + + await provider.searchConceptMaps(searchParams); + + expect(post).toHaveBeenCalledTimes(1); + expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT); + }); + + it('falls back to the source search when $resolveReference is unavailable', async () => { + const error = new Error('Request failed with status code 404'); + error.response = { status: 404 }; + const { provider, httpClient } = makeProvider({ post: jest.fn().mockRejectedValue(error) }); + + await provider.searchConceptMaps(searchParams); + + expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT); + }); +}); diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index bd81b1a6..c1f11f92 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -3,7 +3,7 @@ const { ConceptMap } = require('../library/conceptmap'); const { PAGE_SIZE } = require('./shared/constants'); const { createOclHttpClient } = require('./http/client'); const { fetchAllPages, extractItemsAndNext } = require('./http/pagination'); -const { isOclRepoPath } = require('./resolve/reference-resolver'); +const { OclReferenceResolver, isOclRepoPath } = require('./resolve/reference-resolver'); const DEFAULT_MAX_SEARCH_PAGES = 10; @@ -23,6 +23,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { this._sourceCandidatesCache = new Map(); this._sourceUrlsByCanonical = new Map(); this._canonicalBySourceUrl = new Map(); + + // Asks OCL which repo holds a canonical instead of guessing via text search. + // Disabled unless a token is configured, in which case #candidateSourceUrls + // keeps using its existing search. + this.referenceResolver = new OclReferenceResolver({ + httpClient: this.httpClient, + org: this.org, + token: options.token || null + }); } assignIds(ids) { @@ -516,9 +525,16 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } } - const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl); - for (const item of discovered) { - result.add(item); + // Ask OCL which repo holds this canonical (#2261). An authoritative answer makes + // the heuristic text search below unnecessary; without one we fall back to it. + const resolved = await this.#resolveViaReferenceResolver(systemUrl); + if (resolved) { + result.add(resolved); + } else { + const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl); + for (const item of discovered) { + result.add(item); + } } const out = Array.from(result); @@ -526,6 +542,36 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { return out; } + /** + * Resolve a canonical to its OCL repo via $resolveReference. + * Returns null when the resolver is disabled (no token), when the endpoint is + * unavailable, or when OCL cannot resolve the canonical — in every case the + * caller falls back to the existing search. + */ + async #resolveViaReferenceResolver(systemUrl) { + let resolved; + try { + resolved = await this.referenceResolver.resolve(systemUrl); + } catch (error) { + console.warn(`[OCL] $resolveReference lookup failed for ${systemUrl}: ${error.message}`); + return null; + } + + if (!resolved?.resolved || !resolved.repoUrl) { + return null; + } + + // Keep the canonical<->repo caches coherent with the search path's bookkeeping. + const canonicalKey = this.#norm(systemUrl); + if (!this._sourceUrlsByCanonical.has(canonicalKey)) { + this._sourceUrlsByCanonical.set(canonicalKey, new Set()); + } + this._sourceUrlsByCanonical.get(canonicalKey).add(resolved.repoUrl); + this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), systemUrl); + + return resolved.repoUrl; + } + async #resolveSourceCandidatesFromOcl(systemUrl) { const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/sources/` : '/sources/'; const query = this.#queryTokenFromSystem(systemUrl); From b24feebcbb312801c24a9272024ff77468f3935a Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 11:47:14 -0300 Subject: [PATCH 4/9] docs(ocl): document the token's role in $resolveReference `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 --- tx/ocl/README.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/tx/ocl/README.md b/tx/ocl/README.md index c46324c2..2ecafe36 100644 --- a/tx/ocl/README.md +++ b/tx/ocl/README.md @@ -31,6 +31,7 @@ In FHIRsmith terms, these providers are loaded by `tx/library.js` when a source - `jobs/background-queue.js`: singleton keyed queue, size-priority ordering, max concurrency = 2, heartbeat logging every 30s. - `mappers/concept-mapper.js`: maps OCL concept payloads to internal concept context shape. - `model/concept-filter-context.js`: ranked filter result set used by CodeSystem filters. +- `resolve/reference-resolver.js`: client for OCL's `$resolveReference` — resolves a canonical URL to the repo that holds it. Requires a token; see [Canonical resolution via `$resolveReference`](#canonical-resolution-via-resolvereference). - `shared/constants.js`: defaults and constants (`PAGE_SIZE`, cache freshness window, etc.). - `shared/patches.js`: - patches search worker so `CodeSystem?url=...&code=...` on OCL resources only returns matching concept subtree. @@ -131,10 +132,12 @@ ocl:|org=|token=|timeout= Parsed keys: - `baseUrl` (required) -- `org` (optional) -- `token` (optional) +- `org` (optional) — scopes discovery, and derives the `$resolveReference` namespace (`/orgs/{org}/`) +- `token` (optional) — enables `$resolveReference`; without it that operation is not used - `timeout` (optional positive number) +Unrecognised keys are ignored silently, so a typo (`namepsace=...`) is dropped rather than reported. + Examples: ```yaml @@ -163,6 +166,55 @@ sources: - Token is optional, sent as `Authorization`: - `Token ` if no prefix is provided - preserved if already `Token ...` or `Bearer ...` +- **A token is what enables `$resolveReference`.** It remains optional and no existing + configuration needs to change — but see below for what you gain by adding one. + +> **Do not commit a real token.** `data/library.yml` is tracked by git and is not +> ignored, and there is no environment-variable interpolation in `tx/library.js` +> (the `ocl:` alias block is read from the same file). A token added there is a live +> credential in your repository history. Keep the edit local and uncommitted. + +### Canonical resolution via `$resolveReference` + +OCL's [`$resolveReference`](https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html) +answers *"which repo holds this canonical URL?"* authoritatively, in one call. +Without it, `cm-ocl.js` has to guess: it derives a search token from the canonical, +pages through `/orgs/{org}/sources/?q=...`, and matches `canonical_url` on whatever +the search happens to surface. + +**A token is required, and this is the one thing worth understanding here.** +`$resolveReference` is authenticated on every OCL instance we have probed, while the +`/orgs/` enumeration it replaces is public. So a tokenless server can still discover +and serve OCL content exactly as before, but it cannot use `$resolveReference`. + +**Nothing breaks without a token.** The resolver is constructed disabled when no token +is configured, and every caller falls back to the path it used previously. Existing +`ocl:` source lines keep working unchanged; adding a token is opt-in: + +| Configuration | Canonical → repo resolution | Behaviour | +| --- | --- | --- | +| `ocl:https://ocl.example.org` | `/orgs/{org}/sources/?q=` search | unchanged from previous versions | +| `ocl:https://ocl.example.org\|token=...` | `$resolveReference` | authoritative, one request, falls back to the search if it fails | + +The resolver also disables itself permanently — falling back for the rest of the +process — if the instance answers `404` (operation not implemented) or `401`/`403` +(credentials rejected). A `400` is logged as a bug on our side without disabling. + +**Namespace.** `$resolveReference` evaluates a reference within a *namespace*, which +FHIRSmith derives from the source entry: + +- `org=X` configured → namespace `/orgs/X/` +- no `org` → `/` (global), which is OCL's own default + +A namespace is a *preference*, not a boundary: OCL consults the owner's URL Registry, +then repos in the namespace, then falls through to the **Global URL Registry**. A +resolve scoped to `/orgs/A/` can therefore still return another owner's repo. Confining +resolution to a namespace (the "sandbox" case) would have to be enforced on this side +and is not implemented. + +**What it does not do:** `$resolveReference` resolves a *known* reference; it cannot +*list* what exists. Source/collection discovery still uses the `/orgs/` enumeration +described above. ### Enable/disable integration - Enable by including at least one `ocl:` source entry in TX library YAML. @@ -200,11 +252,15 @@ Typical events: - If cache never warms, check cold-cache timestamps and 1-hour rule. - If searches by `code` behave unexpectedly, confirm OCL marker extension is present on CodeSystem resources. - For ValueSet filter behavior, verify `filter` is included in request path through `TxParameters.hashSource` patch and that filter text normalizes as expected. +- If `$resolveReference` seems to be ignored, check for a `[OCL] $resolveReference ...` log line explaining why it disabled itself. Silence usually means no `token=` is configured, in which case it never runs at all. ### Known limitations (from current implementation) - OCL checksums are not relied on for cache invalidation. - Some source/collection discovery paths depend on OCL endpoint support and can fallback. - Missing endpoints returning `404` in some concept fetch paths are treated as empty content for graceful degradation. +- `$resolveReference` is only used for ConceptMap source canonicals (`cm-ocl.js`). `cs-ocl.js`/`vs-ocl.js` still build repo paths by hand, but only as a last resort behind the `concepts_url` / `expansion_url` that OCL supplies in its payloads. +- Namespace sandboxing is not implemented: a namespaced resolve can still fall through to OCL's Global URL Registry and return a repo outside that namespace. +- There is no way to supply a token outside the tracked library YAML. ## Developer notes ### Where to extend @@ -213,6 +269,7 @@ Typical events: - Add queue policy: `tx/ocl/jobs/background-queue.js` - Add mapping logic from OCL payloads: `tx/ocl/mappers/*` - Add fingerprint strategy: `tx/ocl/fingerprint/*` +- Add canonical-resolution behavior: `tx/ocl/resolve/reference-resolver.js` - Add provider-specific behavior: - CodeSystem: `tx/ocl/cs-ocl.js` - ValueSet: `tx/ocl/vs-ocl.js` @@ -220,6 +277,7 @@ Typical events: ### Ownership by concern - HTTP access: `http/client.js`, `http/pagination.js` +- Canonical URL to OCL repo resolution: `resolve/reference-resolver.js` - Disk cold cache and pathing: `cache/cache-paths.js`, `cache/cache-utils.js` - Background jobs and scheduling policy: `jobs/background-queue.js` - Fingerprints/checksums: `fingerprint/fingerprint.js` and provider compare logic @@ -234,3 +292,13 @@ Typical events: ```bash npm test -- --runInBand tests/ocl --coverage --collectCoverageFrom="tx/ocl/**/*.js" ``` + +> **Read that number carefully.** Most of `tx/ocl` is `.cjs` with a one-line `.js` +> re-export stub beside it, and the glob above (like `collectCoverageFrom` in +> `jest.config.js`) matches `**/*.js` but **not** `**/*.cjs`. So for `cs-ocl`, `vs-ocl` +> and `cm-ocl` it reports on the stubs, not the implementation. Only genuine `.js` +> modules — currently `resolve/reference-resolver.js` — are really measured: +> +> ```bash +> npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js" +> ``` From 68a021d962dced52686e54e9cb0a6290d560aad0 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 11:49:07 -0300 Subject: [PATCH 5/9] docs(ocl): soften the token storage warning 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 --- tx/ocl/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tx/ocl/README.md b/tx/ocl/README.md index 2ecafe36..98a4a016 100644 --- a/tx/ocl/README.md +++ b/tx/ocl/README.md @@ -169,10 +169,16 @@ sources: - **A token is what enables `$resolveReference`.** It remains optional and no existing configuration needs to change — but see below for what you gain by adding one. -> **Do not commit a real token.** `data/library.yml` is tracked by git and is not -> ignored, and there is no environment-variable interpolation in `tx/library.js` -> (the `ocl:` alias block is read from the same file). A token added there is a live -> credential in your repository history. Keep the edit local and uncommitted. +> **Know where the token ends up.** There is no environment-variable interpolation in +> `tx/library.js` — the `ocl:` alias block is read from the same YAML — so the token's +> only home is the library file, and that file is normally tracked by git. A token +> committed there is a live credential in your history: it is readable by anyone with +> repository access, and rotating it at OCL (not deleting the line) is the only way to +> revoke it afterwards. +> +> That can be a reasonable trade in a **private** deployment repository whose purpose +> is to carry environment config. Do not do it in a public one, and do not let the +> library file travel with a contribution upstream. ### Canonical resolution via `$resolveReference` From fc2e5dece70865c2f30a456638969782b143dbe5 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 13:43:50 -0300 Subject: [PATCH 6/9] feat(ocl): log which path resolved a ConceptMap source canonical $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 --- tx/ocl/cm-ocl.cjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index c1f11f92..4754241f 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -549,6 +549,18 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { * caller falls back to the existing search. */ async #resolveViaReferenceResolver(systemUrl) { + // Say why once, rather than per lookup: for a tokenless deployment this is the + // expected steady state, not an error. + if (!this.referenceResolver.isEnabled()) { + if (!this._resolverDisabledLogged) { + this._resolverDisabledLogged = true; + console.log( + `[OCL] $resolveReference not in use (${this.referenceResolver.disabledReason}); using source search` + ); + } + return null; + } + let resolved; try { resolved = await this.referenceResolver.resolve(systemUrl); @@ -558,9 +570,17 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } if (!resolved?.resolved || !resolved.repoUrl) { + console.log(`[OCL] $resolveReference did not resolve ${systemUrl}; falling back to source search`); return null; } + // Logged on success too: resolver and search return the same thing, so without + // this there is no way -- from outside or from the logs -- to tell which path ran. + const via = resolved.registryEntry ? `url registry ${resolved.registryEntry}` : 'namespace'; + console.log( + `[OCL] $resolveReference resolved ${systemUrl} -> ${resolved.repoUrl} (namespace ${resolved.namespace}, via ${via})` + ); + // Keep the canonical<->repo caches coherent with the search path's bookkeeping. const canonicalKey = this.#norm(systemUrl); if (!this._sourceUrlsByCanonical.has(canonicalKey)) { From 20929a4549f443f6f19c7a3e611bdbd475337fa7 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 14:07:09 -0300 Subject: [PATCH 7/9] fix(ocl): stop lower-casing the canonical in ConceptMap search 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 --- tests/ocl/ocl-cm-provider.test.js | 18 ++++++++++++++++++ tx/ocl/cm-ocl.cjs | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/ocl/ocl-cm-provider.test.js b/tests/ocl/ocl-cm-provider.test.js index 418aab88..14120bf6 100644 --- a/tests/ocl/ocl-cm-provider.test.js +++ b/tests/ocl/ocl-cm-provider.test.js @@ -473,6 +473,24 @@ describe('OCLConceptMapProvider $resolveReference integration', () => { expect(getPaths(httpClient)).toContain('/users/joe/sources/S/HEAD/concepts/'); }); + // Regression: searchConceptMaps used to lower-case every param value. The old text + // search tolerated it (#norm lower-cases anyway), but $resolveReference matches the + // canonical exactly, so a lower-cased URL never resolved. + it('sends the canonical to $resolveReference with its original casing', async () => { + const { provider, httpClient } = makeProvider({ + post: resolveReferenceReply('/orgs/Mangara/sources/S/HEAD/') + }); + + await provider.searchConceptMaps([ + { name: 'source-system', value: 'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara' } + ]); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(httpClient.post.mock.calls[0][1]).toEqual([ + 'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara' + ]); + }); + it('falls back to the source search when no token is configured', async () => { const { provider, httpClient } = makeProvider({ token: null }); diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index 4754241f..c115192f 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -117,8 +117,13 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { async searchConceptMaps(searchParams, _elements) { this._validateSearchParams(searchParams); + // Keep the canonical exactly as supplied. Lower-casing it here was harmless while + // every consumer compared through #norm() (which lower-cases anyway), but + // $resolveReference matches the canonical exactly, and OCL has no + // .../fhir/codesystem/alcoolspa_uso_mangara -- only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara. + // findConceptMapForTranslation already passes the caller's original casing. const params = Object.fromEntries( - searchParams.map(({ name, value }) => [name, String(value).toLowerCase()]) + searchParams.map(({ name, value }) => [name, String(value)]) ); const sourceSystem = params['source-system'] || params.source || null; const targetSystem = params['target-system'] || params.target || null; From 95273f5fd7b768afbb013fc216bb03dab3a5e0d5 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 14:17:38 -0300 Subject: [PATCH 8/9] fix(ocl): fetch source mappings directly instead of walking every concept 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 --- tests/ocl/ocl-cm-provider.test.js | 9 ++++++++- tx/ocl/cm-ocl.cjs | 27 ++++++++------------------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/tests/ocl/ocl-cm-provider.test.js b/tests/ocl/ocl-cm-provider.test.js index 14120bf6..08081c8f 100644 --- a/tests/ocl/ocl-cm-provider.test.js +++ b/tests/ocl/ocl-cm-provider.test.js @@ -214,6 +214,13 @@ describe('OCLConceptMapProvider', () => { ]; const getMock = jest.fn().mockImplementation((url) => { + // {source}/mappings/ — one request returns every mapping the source owns. + // Verified against the live OCL instance: this returns the same set as + // walking concepts and asking each one, without the per-concept fan-out. + // Must precede the '/sources/' branch below, which would otherwise swallow it. + if (url.endsWith('/mappings/') && !url.includes('/concepts/')) { + return Promise.resolve({ data: mappings }); + } // source search — resolve canonical for SourceA if (url.includes('/sources/') && !url.includes('/concepts/')) { return Promise.resolve({ @@ -470,7 +477,7 @@ describe('OCLConceptMapProvider $resolveReference integration', () => { // The authoritative answer makes the q= text search unnecessary. expect(getPaths(httpClient)).not.toContain(SEARCH_ENDPOINT); // ...and a user-owned repo survives, which the old /orgs/-only filter dropped. - expect(getPaths(httpClient)).toContain('/users/joe/sources/S/HEAD/concepts/'); + expect(getPaths(httpClient)).toContain('/users/joe/sources/S/HEAD/mappings/'); }); // Regression: searchConceptMaps used to lower-case every param value. The old text diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index c115192f..16d494b5 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -155,30 +155,19 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { const allMappings = []; for (const sourcePath of sourcePaths) { const normalizedPath = this.#normalizeSourcePath(sourcePath); - let concepts; try { - concepts = await this.#fetchAllPages( - `${normalizedPath}concepts/`, { limit: PAGE_SIZE }, this.maxSearchPages + // Ask the source for its mappings directly. Walking concepts and asking each + // one costs a request per concept and, worse, only ever sees the first + // maxSearchPages of them -- for LOINC that is 1000 of 184683 concepts (0.5%), + // so any mapping past that point was silently invisible. + const mappings = await this.#fetchAllPages( + `${normalizedPath}mappings/`, { limit: PAGE_SIZE }, this.maxSearchPages ); + allMappings.push(...mappings); } catch (_err) { + // source exposes no mappings endpoint or is inaccessible — skip continue; } - - for (const concept of concepts) { - const code = concept.id || concept.mnemonic; - if (!code) { - continue; - } - try { - const mappings = await this.#fetchAllPages( - `${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`, - { limit: PAGE_SIZE }, 2 - ); - allMappings.push(...mappings); - } catch (_err) { - // concept has no mappings or endpoint inaccessible — skip - } - } } const sourceUrlsToResolve = new Set(); From 492c8669c5093c7c913c046911ee32ff6605c626 Mon Sep 17 00:00:00 2001 From: Italo Macedo Date: Wed, 15 Jul 2026 14:28:41 -0300 Subject: [PATCH 9/9] fix(ocl): use OCL's canonical_url instead of echoing the requested spelling 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 --- tests/ocl/ocl-reference-resolver.test.js | 95 +++++++++++++++++++++++- tx/ocl/cm-ocl.cjs | 5 +- tx/ocl/resolve/reference-resolver.js | 7 ++ 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/tests/ocl/ocl-reference-resolver.test.js b/tests/ocl/ocl-reference-resolver.test.js index 498935cd..5727d455 100644 --- a/tests/ocl/ocl-reference-resolver.test.js +++ b/tests/ocl/ocl-reference-resolver.test.js @@ -12,10 +12,18 @@ function silentLogger() { return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; } -// Shape of one entry in OCL's $resolveReference response array. +// One entry from OCL's $resolveReference response. +// +// Shaped from a real response captured against oclapi2.ips.hsl.org.br, NOT from the +// docs -- the documented example shows only {type, short_code, url}, but the live +// payload carries canonical_url, owner_type, version and checksums, and reports +// type "Source" rather than "Source Version". function oclEntry({ resolved = true, - url = '/orgs/CIEL/sources/CIEL/HEAD/', + url = '/orgs/MS/sources/BRTabelaSUS/', + canonicalUrl = 'https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS', + owner = 'MS', + ownerType = 'Organization', registryEntry = null, referenceType = 'relative', resolutionUrl = null, @@ -23,12 +31,29 @@ function oclEntry({ } = {}) { return { reference_type: referenceType, - timestamp: '2026-07-15T13:12:18.919301', + timestamp: '2026-07-15T17:25:44.682776', resolved, request, resolution_url: resolutionUrl, url_registry_entry: registryEntry, - result: resolved ? { type: 'Source Version', short_code: 'CIEL', url } : null + result: resolved + ? { + short_code: url.split('/').filter(Boolean).pop(), + name: url.split('/').filter(Boolean).pop(), + url, + owner, + owner_type: ownerType, + owner_url: `/orgs/${owner}/`, + version: 'HEAD', + created_at: '2025-10-24T12:41:29.742565Z', + id: url.split('/').filter(Boolean).pop(), + source_type: 'Dictionary', + updated_at: '2026-06-22T15:25:14.276339Z', + canonical_url: canonicalUrl, + type: 'Source', + checksums: { standard: '282d3c8ce440b8a03698196967042a08', smart: '90599db3f6da397c1af26baaf9467eb1' } + } + : null }; } @@ -278,6 +303,68 @@ describe('OclReferenceResolver resolution', () => { expect(result.repoUrl).toBe('/users/joe/sources/S/HEAD/'); }); + // Verbatim response captured from oclapi2.ips.hsl.org.br for + // POST /$resolveReference/ ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"]. + // Guards against the doc's example, which omits most of these fields. + it('handles a real relative-reference response, concept and all', async () => { + const live = [{ + reference_type: 'relative', + resolved: true, + timestamp: '2026-07-15T17:25:44.682776', + request: '/orgs/MS/sources/BRTabelaSUS/concepts/1948/', + resolution_url: '/orgs/MS/sources/BRTabelaSUS/', + url_registry_entry: null, + result: { + short_code: 'BRTabelaSUS', + name: 'BRTabelaSUS', + url: '/orgs/MS/sources/BRTabelaSUS/', + owner: 'MS', + owner_type: 'Organization', + owner_url: '/orgs/MS/', + version: 'HEAD', + created_at: '2025-10-24T12:41:29.742565Z', + id: 'BRTabelaSUS', + source_type: 'Dictionary', + updated_at: '2026-06-22T15:25:14.276339Z', + canonical_url: 'https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS', + type: 'Source', + checksums: { standard: '282d3c8ce440b8a03698196967042a08', smart: '90599db3f6da397c1af26baaf9467eb1' } + } + }]; + const httpClient = { post: jest.fn(async () => ({ data: live })) }; + const resolver = makeResolver({ httpClient }); + + const r = await resolver.resolve('/orgs/MS/sources/BRTabelaSUS/concepts/1948/'); + + expect(r.resolved).toBe(true); + expect(r.repoUrl).toBe('/orgs/MS/sources/BRTabelaSUS/'); + expect(r.referenceType).toBe('relative'); + // OCL strips the concept: the reference points at a concept, the repo is the source. + expect(r.resolutionUrl).toBe('/orgs/MS/sources/BRTabelaSUS/'); + expect(r.canonical).toBe('https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS'); + expect(r.ownerType).toBe('Organization'); + expect(r.registryEntry).toBeNull(); + // The raw result stays available for anything we don't surface (checksums, version...). + expect(r.result.checksums.standard).toBe('282d3c8ce440b8a03698196967042a08'); + }); + + it('surfaces the repo canonical_url rather than echoing the requested spelling', async () => { + const httpClient = { + post: jest.fn(async () => ({ + data: [oclEntry({ + url: '/orgs/MS/sources/BRTabelaSUS/', + canonicalUrl: 'https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS' + })] + })) + }; + const resolver = makeResolver({ httpClient }); + + // Asked with a different spelling than the repo's own canonical. + const r = await resolver.resolve('http://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS'); + + expect(r.canonical).toBe('https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS'); + }); + it('surfaces url_registry_entry (Phase 2 sandbox needs it)', async () => { const httpClient = { post: jest.fn(async () => ({ diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index 16d494b5..ca45ed2e 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -576,12 +576,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { ); // Keep the canonical<->repo caches coherent with the search path's bookkeeping. + // Index under what was asked, but record OCL's own canonical_url as the repo's + // canonical: echoing the request back would store whatever spelling the caller + // used rather than the repo's actual identity. const canonicalKey = this.#norm(systemUrl); if (!this._sourceUrlsByCanonical.has(canonicalKey)) { this._sourceUrlsByCanonical.set(canonicalKey, new Set()); } this._sourceUrlsByCanonical.get(canonicalKey).add(resolved.repoUrl); - this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), systemUrl); + this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), resolved.canonical || systemUrl); return resolved.repoUrl; } diff --git a/tx/ocl/resolve/reference-resolver.js b/tx/ocl/resolve/reference-resolver.js index ebe822e2..d268c46f 100644 --- a/tx/ocl/resolve/reference-resolver.js +++ b/tx/ocl/resolve/reference-resolver.js @@ -121,6 +121,8 @@ function unresolved(namespace, request) { return { resolved: false, repoUrl: null, + canonical: null, + ownerType: null, resolutionUrl: null, registryEntry: null, referenceType: null, @@ -141,6 +143,11 @@ function normalizeResult(entry, namespace, request) { return { resolved: Boolean(entry.resolved) && Boolean(repoUrl), repoUrl, + // OCL returns the repo's own canonical_url and owner_type. Prefer them over + // anything the caller assumed: they are authoritative, and echoing the request + // back would record whatever spelling the caller happened to use. + canonical: result ? (result.canonical_url || result.canonicalUrl || null) : null, + ownerType: result ? (result.owner_type || result.ownerType || null) : null, resolutionUrl: entry.resolution_url || null, // Phase 2's namespace sandbox needs this to tell owner-registry delegation // apart from a global-registry fallthrough, so never drop it.