fhirsmith 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. package/registry/readme.md +0 -124
package/tx/cs/cs-icd11.js CHANGED
@@ -18,12 +18,21 @@ const { CodeSystem } = require('../library/codesystem');
18
18
  const { CodeSystemFactoryProvider, FilterExecutionContext } = require('./cs-api');
19
19
  const { BaseCSServices } = require('./cs-base');
20
20
  const { validateArrayParameter } = require('../../library/utilities');
21
- const { ICD11Expression, parseExpression } = require('./icd11-expressions');
21
+ const { ICD11Expression, parseExpression, axisLabel } = require('./icd11-expressions');
22
22
  const { Issue } = require('../library/operation-outcome');
23
+ const { DesignationUse } = require('../library/designations');
23
24
  const assert = require('assert');
24
25
 
25
26
  // WHO's own vocabulary for the kinds of text an entity carries, used as designation.use.
26
27
  const SCHEMA_NS = 'http://id.who.int/icd/schema';
28
+ const FOUNDATION_URL = 'http://id.who.int/icd/entity';
29
+
30
+ // The extension a Coding may carry to save the reader a lookup: the short code (or the
31
+ // short-code form of an expression) for the entity id in Coding.code. Provisional url.
32
+ const CLASSIFICATION_CODE_EXT = 'http://id.who.int/icd/schema/extension/classificationCode';
33
+
34
+ // The code system of allowMultipleValues: AllowAlways | NotAllowed | AllowedExceptFromSameBlock
35
+ const ALLOW_MULTIPLE_CS = `${SCHEMA_NS}/allowMultipleValues`;
27
36
 
28
37
  // designation.kind values that are not designations: `definition` is
29
38
  // CodeSystem.concept.definition, and is returned by definition() instead.
@@ -34,7 +43,12 @@ const NOT_A_DESIGNATION = new Set(['definition']);
34
43
  * enough of the concept row that the cheap accessors never go back to the database.
35
44
  */
36
45
  class ICD11Concept {
37
- constructor(row) {
46
+ /**
47
+ * @param row the concept row
48
+ * @param bareIds true for the linearizations (MMS, ICF), whose code is the bare entity id;
49
+ * false for the Foundation, whose code is still the entity uri
50
+ */
51
+ constructor(row, bareIds) {
38
52
  this.id = row.id; // internal rowid, used for joins
39
53
  this.entityId = row.entity_id; // 257068234, or 1363559646/other for a residual
40
54
  this.uri = row.uri; // unversioned canonical uri
@@ -49,17 +63,18 @@ class ICD11Concept {
49
63
  this.browserUrl = row.browser_url;
50
64
  this.lft = row.lft; // nested set; null for the Foundation (a DAG)
51
65
  this.rgt = row.rgt;
66
+ this.bareIds = !!bareIds;
52
67
  }
53
68
 
54
69
  /**
55
- * What this concept answers to. WHO's server accepts both the short code and the entity
56
- * uri, and the test suite asserts both address the same concept, so the provider echoes
57
- * back whichever form is canonical for the concept: the short code where there is one,
58
- * the uri otherwise. If the community settles the straw poll differently, this is the
59
- * one place that changes.
70
+ * The concept's code. In MMS and ICF it is the bare entity id (257068234, or
71
+ * 1363559646/other for a residual): the full uri is the system url + '/' + the id, so
72
+ * the prefix carries nothing (the October 2026 WHO meeting). The short code (this.code)
73
+ * is its classificationCode, reported beside it. The Foundation was not part of that
74
+ * decision, and keeps the entity uri.
60
75
  */
61
76
  get canonicalCode() {
62
- return this.code || this.uri;
77
+ return this.bareIds ? this.entityId : this.uri;
63
78
  }
64
79
  }
65
80
 
@@ -105,19 +120,41 @@ class ICD11Services extends BaseCSServices {
105
120
  });
106
121
  }
107
122
 
123
+ /** MMS and ICF codes are bare entity ids; the Foundation's are still entity uris. */
124
+ get #bareIds() { return this.shared.systemCode !== 'foundation'; }
125
+
126
+ #concept(row) {
127
+ const c = new ICD11Concept(row, this.#bareIds);
128
+ // "selectable" is a linearization's notion - a category with a short code. The importer
129
+ // records 0 for every Foundation entity, which has neither; but nothing in the Foundation is
130
+ // a grouper in that sense, so none of it is reported as not selectable
131
+ if (!this.#bareIds) c.selectable = true;
132
+ return c;
133
+ }
134
+
135
+ /** The code of a concept row, for references to it (parent, child, stem, values). */
136
+ #ref(row) { return this.#bareIds ? row.entity_id : row.uri; }
137
+
108
138
  // -- metadata -------------------------------------------------------------------------
109
139
 
110
140
  system() { return this.shared.url; }
111
141
  version() { return this.shared.version; }
112
142
 
113
143
  /**
114
- * The default is system|version, but $lookup's `name` is meant to be a display name for
115
- * the code system. OMOP overrides it the same way; WHO returns the linearization title.
116
- * It follows the request's languages like any other display, falling back to the primary
117
- * title. $lookup passes its working languages (displayLanguage, else Accept-Language),
118
- * since displayLanguage never reaches opContext.langs; other callers pass nothing.
144
+ * CodeSystem.name, as export-icd11-codesystem.js writes it: ICD11MMS, ICD11ICF,
145
+ * ICD11FOUNDATION.
119
146
  */
120
- name(languages) { return this.shared.titles?.[this.#displayLang(languages)] || this.shared.title; }
147
+ name() { return `ICD11${this.shared.systemCode.toUpperCase()}`; }
148
+
149
+ /**
150
+ * $lookup's display name: the linearization title in the request's language where WHO
151
+ * translated it, else the primary title. $lookup passes its working languages
152
+ * (displayLanguage, else Accept-Language), since displayLanguage never reaches
153
+ * opContext.langs.
154
+ */
155
+ lookupName(languages) {
156
+ return this.shared.titles?.[this.#displayLang(languages)] || this.shared.title || this.name();
157
+ }
121
158
  description() { return this.shared.title; }
122
159
  totalCount() { return this.shared.count; }
123
160
  defLang() { return 'en'; }
@@ -164,8 +201,8 @@ class ICD11Services extends BaseCSServices {
164
201
  // -- locating -------------------------------------------------------------------------
165
202
 
166
203
  /**
167
- * Accepts the short code and the unversioned entity uri, matching what WHO's server
168
- * accepts. Returns { context, message }; a plain miss carries no message, which the
204
+ * MMS and ICF accept the bare entity id (the code) and the short classification code;
205
+ * the Foundation accepts its entity uri and the short code. Returns { context, message }; a plain miss carries no message, which the
169
206
  * validation worker treats as "not there" rather than "malformed".
170
207
  */
171
208
  async locate(code) {
@@ -186,12 +223,18 @@ class ICD11Services extends BaseCSServices {
186
223
  return { context: null, message: undefined };
187
224
  }
188
225
 
189
- /** A plain code or entity uri, as written; null if it is neither. */
226
+ /**
227
+ * A plain code as written - the short code, or the entity id (MMS, ICF) or uri
228
+ * (Foundation); null if it is none of them. An id is looked up as system + '/' + id,
229
+ * which is what its uri is, so the uri index serves both. The full uri is not accepted
230
+ * in MMS or ICF: it is not a code there.
231
+ */
190
232
  async #resolve(code) {
233
+ const uri = this.#bareIds ? `${this.shared.url}/${code}` : code;
191
234
  const row = await this.#get(
192
235
  `SELECT * FROM concept WHERE system = ? AND (code = ? OR uri = ?)`,
193
- [this.shared.systemId, code, code]);
194
- return row ? new ICD11Concept(row) : null;
236
+ [this.shared.systemId, code, uri]);
237
+ return row ? this.#concept(row) : null;
195
238
  }
196
239
 
197
240
  /** What the expression parser needs: the database, and the request's translations. */
@@ -260,6 +303,74 @@ class ICD11Services extends BaseCSServices {
260
303
  return ctxt === null ? null : ctxt.canonicalCode;
261
304
  }
262
305
 
306
+ /** The short-code form (1A00, or 1A00&XN8P1 for an expression), where there is one. */
307
+ async classificationCode(context) {
308
+ const ctxt = await this.#ensureContext(context);
309
+ if (ctxt === null) return null;
310
+ return (ctxt instanceof ICD11Expression ? ctxt.classificationCode : ctxt.code) || null;
311
+ }
312
+
313
+ /** Value sets may enumerate short codes; the expansion carries the code, the uri. */
314
+ async expansionCode(context, requested) {
315
+ return (await this.code(context)) || requested;
316
+ }
317
+
318
+ mandatoryExpansionProperties() { return ['classificationCode']; }
319
+
320
+ /**
321
+ * $validate-code reports the classificationCode only for a code written with entity ids - in
322
+ * any order, since the classificationCode is the short-code form of the normal form, beside
323
+ * normalized-code. A code written with short codes (wholly or in part) gets none: it already
324
+ * is, or contains, the classification code.
325
+ */
326
+ async reportedClassificationCode(context, code) {
327
+ const ctxt = await this.#ensureContext(context);
328
+ if (ctxt === null) return null;
329
+ const idForm = ctxt instanceof ICD11Expression
330
+ ? ctxt.tokens.every(t => t.text === t.concept.canonicalCode)
331
+ : code === ctxt.canonicalCode;
332
+ return idForm ? this.classificationCode(ctxt) : null;
333
+ }
334
+
335
+ /** A postcoordinated expression's parse records what validation should say about it. */
336
+ async codeIssues(context) {
337
+ const ctxt = await this.#ensureContext(context);
338
+ return ctxt instanceof ICD11Expression ? ctxt.issues : [];
339
+ }
340
+
341
+ /**
342
+ * The classificationCode extension on a Coding, when present, has to be true: it is there
343
+ * so software can show the short code without a lookup, and a wrong one would be shown.
344
+ * It is defined for an MMS or ICF coding whose code is the entity id (or the id form of an
345
+ * expression) - a short code needs no other short code beside it - and its value has to be
346
+ * that code's classificationCode exactly.
347
+ */
348
+ async checkCodingExtensions(coding, context) {
349
+ const out = [];
350
+ const exts = coding.extension || [];
351
+ for (let index = 0; index < exts.length; index++) {
352
+ const ext = exts[index];
353
+ if (ext.url !== CLASSIFICATION_CODE_EXT) continue;
354
+ const value = ext.valueCode ?? '';
355
+ // the linearizations have classification codes; the Foundation has none
356
+ if (this.shared.systemCode === 'foundation') {
357
+ out.push({ index, msgId: 'ICD11_CLASSIFICATION_CODE_SYSTEM', params: [this.shared.url] });
358
+ continue;
359
+ }
360
+ const ctxt = await this.#ensureContext(context);
361
+ const id = await this.code(ctxt);
362
+ const expected = await this.classificationCode(ctxt);
363
+ if (coding.code !== id) {
364
+ out.push({ index, msgId: 'ICD11_CLASSIFICATION_CODE_NOT_ID', params: [coding.code, id] });
365
+ } else if (!expected) {
366
+ out.push({ index, msgId: 'ICD11_CLASSIFICATION_CODE_NONE', params: [value, coding.code] });
367
+ } else if (value !== expected) {
368
+ out.push({ index, msgId: 'ICD11_CLASSIFICATION_CODE_WRONG', params: [value, coding.code, expected] });
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+
263
374
  async display(context) {
264
375
  const ctxt = await this.#ensureContext(context);
265
376
  if (ctxt === null) return null;
@@ -303,9 +414,9 @@ class ICD11Services extends BaseCSServices {
303
414
  // narrower thing than any concept in the classification
304
415
  if (ctxt === null || ctxt instanceof ICD11Expression) return null;
305
416
  const row = await this.#get(
306
- `SELECT c.code, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.parent
417
+ `SELECT c.entity_id, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.parent
307
418
  WHERE cp.child = ? ORDER BY cp.seq LIMIT 1`, [ctxt.id]);
308
- return row ? (row.code || row.uri) : null;
419
+ return row ? this.#ref(row) : null;
309
420
  }
310
421
 
311
422
  /**
@@ -371,12 +482,15 @@ class ICD11Services extends BaseCSServices {
371
482
  */
372
483
  propertyDefinitions() {
373
484
  const defs = [
374
- // code, not string: the entity uri identifies a concept in this code system, and
375
- // that is what the Geneva review concluded WHO gets wrong by returning valueString.
376
- { code: 'id', uri: `${SCHEMA_NS}/entityId`, type: 'code',
377
- description: 'The stable WHO entity uri.' },
378
- { code: 'code', uri: `${SCHEMA_NS}/code`, type: 'string',
379
- description: 'The short code, where the entity has one.' },
485
+ // The entity uri is the code (decided at the October 2026 test-case review); the short
486
+ // code is reported alongside it as classificationCode. Both are codes in this code
487
+ // system, so both are type code.
488
+ { code: 'code', uri: `${SCHEMA_NS}/entityId`, type: 'code',
489
+ description: this.#bareIds
490
+ ? 'The code: the WHO entity id. The entity uri is the system url + \'/\' + the id.'
491
+ : 'The code: the stable, unversioned WHO entity uri.' },
492
+ { code: 'classificationCode', uri: `${SCHEMA_NS}/code`, type: 'code',
493
+ description: 'The short classification code (e.g. 1A00), where the entity has one.' },
380
494
  { code: 'classKind', uri: `${SCHEMA_NS}/classKind`, type: 'code',
381
495
  description: 'chapter | block | window | category.' },
382
496
  { code: 'notSelectable', uri: 'http://hl7.org/fhir/concept-properties#notSelectable',
@@ -387,17 +501,31 @@ class ICD11Services extends BaseCSServices {
387
501
  ];
388
502
  if (this.shared.systemCode !== 'foundation') {
389
503
  defs.push(
390
- { code: 'source', uri: `${SCHEMA_NS}/source`, type: 'string',
391
- description: 'The Foundation entity this linearization entry came from.' },
504
+ { code: 'source', uri: `${SCHEMA_NS}/source`, type: 'Coding',
505
+ description: 'The Foundation entity this linearization entry came from, as a Coding in '
506
+ + 'the Foundation. Requested as `source.details`, it also carries what a $lookup of '
507
+ + 'that entity returns: subproperties (definition, parent) and designation parts.' },
392
508
  { code: 'blockId', uri: `${SCHEMA_NS}/blockId`, type: 'string' },
393
509
  { code: 'codeRange', uri: `${SCHEMA_NS}/codeRange`, type: 'string' },
394
- { code: 'postcoordinationScale', uri: `${SCHEMA_NS}/postcoordinationScale`, type: 'string',
395
- description: 'A postcoordination axis of this concept; repeats once per axis.' },
510
+ { code: 'postcoordination', uri: `${SCHEMA_NS}/postcoordination`, type: 'string',
511
+ description: 'One per axis the code declares, with subproperties axis, required, '
512
+ + 'allowMultipleValues and valueSet; for an expression, also value (repeating), valued '
513
+ + 'or not, and a value that is a cluster member carries its own postcoordination '
514
+ + 'entries. Has no value of its own.' },
515
+ { code: 'allowMultipleValues', uri: `${SCHEMA_NS}/allowMultipleValues`, type: 'Coding',
516
+ description: 'Subproperty of postcoordination: AllowAlways | NotAllowed | AllowedExceptFromSameBlock.' },
517
+ { code: 'valueSet', uri: `${SCHEMA_NS}/valueSet`, type: 'string',
518
+ description: 'Subproperty of postcoordination: the canonical of the ValueSet of the values '
519
+ + 'permitted on the axis.' },
396
520
  // only ever returned for a postcoordinated expression, never for a plain concept
397
- { code: 'stem', uri: `${SCHEMA_NS}/stem`, type: 'string',
398
- description: 'A concept in the expression that carries axes of its own.' },
399
- { code: 'postcoordinationValues', uri: `${SCHEMA_NS}/postcoordinationValues`, type: 'string',
400
- description: 'A value in the expression, with the axis it was placed on.' });
521
+ { code: 'stem', uri: `${SCHEMA_NS}/stem`, type: 'Coding',
522
+ description: 'The root of the expression.' },
523
+ { code: 'axis', uri: `${SCHEMA_NS}/axis`, type: 'Coding',
524
+ description: 'Subproperty of postcoordination: the axis, in the schema vocabulary.' },
525
+ { code: 'required', uri: `${SCHEMA_NS}/requiredPostcoordination`, type: 'boolean',
526
+ description: 'Subproperty of postcoordination: the axis must be given a value.' },
527
+ { code: 'value', uri: `${SCHEMA_NS}/value`, type: 'Coding',
528
+ description: 'Subproperty of postcoordination: a code on the axis.' });
401
529
  }
402
530
  return defs;
403
531
  }
@@ -406,11 +534,13 @@ class ICD11Services extends BaseCSServices {
406
534
  const ctxt = await this.#ensureContext(context);
407
535
  if (!ctxt) return [];
408
536
  if (ctxt instanceof ICD11Expression) {
409
- return [{ code: 'id', valueCode: ctxt.uriForm }, { code: 'code', valueString: ctxt.canonicalCode }];
537
+ const out = [{ code: 'code', valueCode: ctxt.canonicalCode }];
538
+ if (ctxt.classificationCode) out.push({ code: 'classificationCode', valueCode: ctxt.classificationCode });
539
+ return out;
410
540
  }
411
- const out = [{ code: 'id', valueCode: ctxt.uri }];
541
+ const out = [{ code: 'code', valueCode: ctxt.canonicalCode }];
412
542
  if (ctxt.classKind) out.push({ code: 'classKind', valueCode: ctxt.classKind });
413
- if (ctxt.code) out.push({ code: 'code', valueString: ctxt.code });
543
+ if (ctxt.code) out.push({ code: 'classificationCode', valueCode: ctxt.code });
414
544
  if (!ctxt.selectable) out.push({ code: 'notSelectable', valueBoolean: true });
415
545
  return out;
416
546
  }
@@ -424,20 +554,25 @@ class ICD11Services extends BaseCSServices {
424
554
  * the lot -- for 1A00 that is 7.5KB, mostly index terms -- which is one of the things
425
555
  * the test suite pins.
426
556
  */
427
- async extendLookup(ctxt, props, params) {
557
+ async extendLookup(ctxt, props, params, txParams) {
428
558
  validateArrayParameter(props, 'props', String);
429
559
  validateArrayParameter(params, 'params', Object);
430
560
  const concept = await this.#ensureContext(ctxt);
431
561
  if (!concept) return;
562
+ // post-coordination-metadata=true asks for every axis, with its rules; otherwise only the
563
+ // axes that carry a value are described, and only by what is on them
564
+ const meta = !!txParams?.postCoordinationMetadata;
432
565
  if (concept instanceof ICD11Expression) {
433
- return this.#extendLookupExpression(concept, props, params);
566
+ return this.#extendLookupExpression(concept, props, params, meta);
434
567
  }
435
568
 
436
- if (this._hasProp(props, 'id', true)) {
437
- this._addCodeProperty(params, 'property', 'id', concept.uri);
569
+ // code is the entity id (uri in the Foundation); classificationCode the short code,
570
+ // only where there is one
571
+ if (this._hasProp(props, 'code', true)) {
572
+ this._addCodeProperty(params, 'property', 'code', concept.canonicalCode);
438
573
  }
439
- if (concept.code && this._hasProp(props, 'code', true)) {
440
- this._addCodeProperty(params, 'property', 'code', concept.code);
574
+ if (concept.code && this._hasProp(props, 'classificationCode', true)) {
575
+ this._addCodeProperty(params, 'property', 'classificationCode', concept.code);
441
576
  }
442
577
  if (this._hasProp(props, 'classKind', true) && concept.classKind) {
443
578
  this._addCodeProperty(params, 'property', 'classKind', concept.classKind);
@@ -454,94 +589,205 @@ class ICD11Services extends BaseCSServices {
454
589
  if (concept.codeRange && this._hasProp(props, 'codeRange', true)) {
455
590
  this._addStringProperty(params, 'property', 'codeRange', concept.codeRange);
456
591
  }
457
- if (concept.sourceUri && this._hasProp(props, 'source', false)) {
458
- this._addStringProperty(params, 'property', 'source', concept.sourceUri);
592
+ // `source.details` is not a property of its own: it asks for source, with the Foundation
593
+ // entity's own lookup content inside it
594
+ const sourceDetails = props.some(p => String(p).toLowerCase() === 'source.details');
595
+ if (concept.sourceUri && (sourceDetails || this._hasProp(props, 'source', true))) {
596
+ params.push({ name: 'property', part: await this.#sourcePart(concept.sourceUri, sourceDetails) });
459
597
  }
460
598
  if (concept.browserUrl && this._hasProp(props, 'browserUrl', false)) {
461
599
  this._addStringProperty(params, 'property', 'browserUrl', concept.browserUrl);
462
600
  }
463
601
 
464
- // hierarchy. Parents and children are codes in this code system, so they use whichever
465
- // form is canonical for the target -- the short code where there is one.
602
+ // hierarchy. Parents and children are codes in this code system, so they are spelled
603
+ // the same way as the code.
466
604
  if (this._hasProp(props, 'parent', true)) {
467
605
  for (const r of await this.#all(
468
- `SELECT c.code, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.parent
606
+ `SELECT c.entity_id, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.parent
469
607
  WHERE cp.child = ? ORDER BY cp.seq`, [concept.id])) {
470
- this._addCodeProperty(params, 'property', 'parent', r.code || r.uri);
608
+ this._addCodeProperty(params, 'property', 'parent', this.#ref(r));
471
609
  }
472
610
  }
473
611
  if (this._hasProp(props, 'child', false)) {
474
612
  for (const r of await this.#all(
475
- `SELECT c.code, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.child
613
+ `SELECT c.entity_id, c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.child
476
614
  WHERE cp.parent = ? ORDER BY cp.seq`, [concept.id])) {
477
- this._addCodeProperty(params, 'property', 'child', r.code || r.uri);
615
+ this._addCodeProperty(params, 'property', 'child', this.#ref(r));
478
616
  }
479
617
  }
480
618
 
481
- // The postcoordination axes this concept declares, as the canonical of the ValueSet
482
- // that enumerates each one. The cardinality rules live on those value sets.
483
- if (this.shared.systemCode !== 'foundation' && this._hasProp(props, 'postcoordinationScale', true)) {
484
- for (const r of await this.#all(
485
- `SELECT value_set_uri FROM pc_scale WHERE concept = ? ORDER BY seq`, [concept.id])) {
486
- this._addStringProperty(params, 'property', 'postcoordinationScale', r.value_set_uri);
619
+ // The postcoordination axes this concept declares: one postcoordination property per
620
+ // axis, in the shape a postcoordinated expression uses, with nothing on any axis. Only
621
+ // with post-coordination-metadata: without it only valued axes are listed, and a plain
622
+ // concept has none.
623
+ if (meta && this.shared.systemCode !== 'foundation' && this._hasProp(props, 'postcoordination', true)) {
624
+ for (const axis of await this.#all(
625
+ `SELECT id, seq, axis, axis_name, required, allow_multiple, value_set_uri FROM pc_scale
626
+ WHERE concept = ? ORDER BY seq`, [concept.id])) {
627
+ params.push({ name: 'property', part: this.#axisEntry(axis, true) });
487
628
  }
488
629
  }
489
630
  }
490
631
 
491
632
  /**
492
- * $lookup for a postcoordinated expression. Two properties carry what a client cannot
493
- * work out from the code string:
633
+ * $lookup for a postcoordinated expression (the October 2026 WHO meeting):
634
+ *
635
+ * code, classificationCode the id form and the short-code form, as for a plain concept
636
+ * stem the root, as a Coding
637
+ * postcoordination one per axis the root declares, valued or not:
638
+ * axis Coding in the schema vocabulary
639
+ * required boolean
640
+ * allowMultipleValues Coding (AllowAlways | NotAllowed | ...)
641
+ * valueSet string: the canonical of the axis's ValueSet
642
+ * value Coding, repeating: what is on the axis
643
+ * plus one with only `value`s, for `/` members on no axis of the
644
+ * root.
645
+ *
646
+ * Without post-coordination-metadata=true only the axes that carry a value are listed, and
647
+ * each carries only `axis` and its values: required, allowMultipleValues and valueSet, and
648
+ * the unvalued axes, are the metadata. (A `/` member with no qualifiers is then just its
649
+ * Coding.)
494
650
  *
495
- * stem which parts of the expression are concepts in their own right
496
- * postcoordinationValues which value went on which axis
651
+ * A `value` that is a `/` member is a code with axes of its own, and carries its own
652
+ * postcoordination entries inside it, one level down - the same shape. That nesting is the
653
+ * only way to keep a member's qualifiers with that member: the same code can be a member
654
+ * twice with different qualifiers. ($lookup does not describe subproperties inside
655
+ * subproperties; Parameters allows them.)
497
656
  *
498
- * The second is the one that matters. `1D01.0Y/1G41/1G40` says nothing about whether
499
- * 1G40 is the causing condition or the manifestation, and both are legal there -- so a
500
- * client that only has the string cannot tell, and a server that reports both on the
501
- * first axis has told it something untrue. Each (axis, value) pair is therefore kept
502
- * distinct, and the axis is named by its schema uri, the same identifier the
503
- * postcoordinationScale property uses on a stem.
657
+ * Everything comes out in normal-form order: axes in the order the code defines them,
658
+ * values in the order the normal form puts them.
504
659
  */
505
- async #extendLookupExpression(expr, props, params) {
660
+ async #extendLookupExpression(expr, props, params, meta) {
506
661
  if (this._hasProp(props, 'code', true)) {
507
662
  this._addCodeProperty(params, 'property', 'code', expr.canonicalCode);
508
663
  }
509
- if (this._hasProp(props, 'id', true)) {
510
- this._addCodeProperty(params, 'property', 'id', expr.uriForm);
664
+ if (expr.classificationCode && this._hasProp(props, 'classificationCode', true)) {
665
+ this._addCodeProperty(params, 'property', 'classificationCode', expr.classificationCode);
511
666
  }
667
+ const root = expr.stems[0];
512
668
  if (this._hasProp(props, 'stem', true)) {
513
- for (const stem of expr.stems) {
514
- const label = await this.#title(stem.stem.concept);
515
- const part = [{ name: 'code', valueCode: 'stem' }];
516
- if (label) {
517
- part.push({ name: 'subproperty', part: [
518
- { name: 'code', valueCode: 'stemLabel' },
519
- { name: 'value', valueString: label }] });
520
- }
521
- part.push({ name: 'subproperty', part: [
522
- { name: 'code', valueCode: 'stemUri' },
523
- { name: 'value', valueUri: stem.stem.concept.uri }] });
524
- part.push({ name: 'value', valueCode: stem.stem.concept.canonicalCode });
669
+ params.push({ name: 'property', part: [
670
+ { name: 'code', valueCode: 'stem' },
671
+ { name: 'value', valueCoding: await this.#coding(root.stem.concept) }] });
672
+ }
673
+ if (this._hasProp(props, 'postcoordination', true)) {
674
+ for (const part of await this.#postcoordination(expr, root, true, meta)) {
525
675
  params.push({ name: 'property', part });
526
676
  }
527
677
  }
528
- if (this._hasProp(props, 'postcoordinationValues', true)) {
529
- // no sorting here: the test harness normalises repeated `property` parameters by
530
- // (code, value) before it compares, so these arrive ordered by axis whatever order
531
- // they leave in. Left in expression order, which is the one a reader of the raw
532
- // response would expect.
533
- const bindings = expr.stems.flatMap(stem => stem.bindings);
534
- for (const b of bindings) {
535
- const label = await this.#title(b.token.concept);
536
- const sub = [{ name: 'code', valueCode: b.token.concept.canonicalCode }];
537
- if (label) sub.push({ name: 'description', valueString: label });
538
- sub.push({ name: 'value', valueUri: b.token.concept.uri });
539
- params.push({ name: 'property', part: [
540
- { name: 'code', valueCode: 'postcoordinationValues' },
541
- { name: 'subproperty', part: sub },
542
- { name: 'value', valueCode: b.axis.axis }] });
678
+ }
679
+
680
+ /**
681
+ * The `source` property: the Foundation entity a linearization entry came from, as a Coding
682
+ * in the Foundation (whose codes are entity uris), with its title. With `details`, it also
683
+ * carries what a $lookup of that entity would say: as subproperties, its definition and its
684
+ * parents in the Foundation (codes there, as $lookup returns them); and its designations, as
685
+ * `designation` parts shaped like a $lookup's own. The display is already on the Coding.
686
+ * The Foundation is in the same database, so no second provider is needed; if a
687
+ * database was built without it, the Coding is all there is.
688
+ */
689
+ async #sourcePart(uri, details) {
690
+ const row = await this.#get(
691
+ `SELECT c.id, s.url AS system_url FROM concept c JOIN system s ON s.id = c.system
692
+ WHERE s.code = 'foundation' AND c.uri = ?`, [uri]);
693
+ const coding = { system: row ? row.system_url : FOUNDATION_URL, code: uri };
694
+ const label = row ? await this.#title(row) : null;
695
+ if (label) coding.display = label;
696
+ const part = [{ name: 'code', valueCode: 'source' }, { name: 'value', valueCoding: coding }];
697
+ if (details && row) {
698
+ const sub = (code, value) => ({ name: 'subproperty', part: [{ name: 'code', valueCode: code }, { name: 'value', ...value }] });
699
+ const def = await this.#get(
700
+ `SELECT value FROM designation
701
+ WHERE concept = ? AND kind = 'definition' AND lang = ? ORDER BY seq LIMIT 1`,
702
+ [row.id, this.#displayLang()]);
703
+ if (def) part.push(sub('definition', { valueString: def.value }));
704
+ for (const r of await this.#all(
705
+ `SELECT c.uri FROM concept_parent cp JOIN concept c ON c.id = cp.parent
706
+ WHERE cp.child = ? ORDER BY cp.seq`, [row.id])) {
707
+ part.push(sub('parent', { valueCode: r.uri }));
708
+ }
709
+ // and its designations, in the same shape as a $lookup's own - which the operation does
710
+ // not describe inside a property, but they are what a $lookup of the entity would say,
711
+ // and a subproperty (a code and a value) cannot carry their language and use
712
+ for (const r of await this.#all(
713
+ `SELECT lang, kind, value FROM designation WHERE concept = ? ORDER BY lang, seq`, [row.id])) {
714
+ if (NOT_A_DESIGNATION.has(r.kind)) continue;
715
+ const value = r.value.trim();
716
+ if (!value) continue;
717
+ const use = r.kind === 'title' ? { ...DesignationUse.DISPLAY } : { system: SCHEMA_NS, code: r.kind };
718
+ part.push({ name: 'designation', part: [
719
+ { name: 'language', valueCode: r.lang },
720
+ { name: 'use', valueCoding: use },
721
+ { name: 'value', valueString: value }] });
722
+ }
723
+ }
724
+ return part;
725
+ }
726
+
727
+ /** A concept in this code system as a Coding, with its display. */
728
+ async #coding(concept) {
729
+ const c = { system: this.shared.url, code: concept.canonicalCode };
730
+ const label = await this.#title(concept);
731
+ if (label) c.display = label;
732
+ return c;
733
+ }
734
+
735
+ /**
736
+ * The postcoordination entries for one code of an expression - the root, or a `/` member -
737
+ * each as the `part` list of a property (or, one level down, of a subproperty).
738
+ */
739
+ async #postcoordination(expr, st, isRoot, meta) {
740
+ const pos = (t) => expr.tokens.indexOf(t);
741
+ const out = [];
742
+ for (const axis of [...st.axes].sort((a, b) => a.seq - b.seq)) {
743
+ const values = st.bindings.filter(b => b.axis === axis).map(b => b.token).sort((a, b) => pos(a) - pos(b));
744
+ if (!meta && values.length === 0) continue; // unvalued axes only with the metadata
745
+ const part = this.#axisEntry(axis, meta);
746
+ for (const t of values) part.push(await this.#valuePart(expr, t, meta));
747
+ out.push(part);
748
+ }
749
+ if (isRoot) {
750
+ // `/` members that are on no axis of the root still qualify it
751
+ const loose = expr.tokens.filter(t => t.sep === '/' && !t.axis);
752
+ if (loose.length) {
753
+ const part = [{ name: 'code', valueCode: 'postcoordination' }];
754
+ for (const t of loose) part.push(await this.#valuePart(expr, t, meta));
755
+ out.push(part);
756
+ }
757
+ }
758
+ return out;
759
+ }
760
+
761
+ /**
762
+ * The start of a postcoordination entry: the axis and what is known about it. A plain
763
+ * concept's lookup stops here; an expression's adds the values.
764
+ */
765
+ #axisEntry(axis, meta) {
766
+ const sub = (code, value) => ({ name: 'subproperty', part: [{ name: 'code', valueCode: code }, { name: 'value', ...value }] });
767
+ const part = [
768
+ { name: 'code', valueCode: 'postcoordination' },
769
+ sub('axis', { valueCoding: { system: SCHEMA_NS, code: axis.axis_name, display: axisLabel(axis.axis_name) } })];
770
+ if (!meta) return part; // the axis rules are post-coordination-metadata
771
+ part.push(sub('required', { valueBoolean: !!axis.required }));
772
+ if (axis.allow_multiple) {
773
+ part.push(sub('allowMultipleValues', { valueCoding: { system: ALLOW_MULTIPLE_CS, code: axis.allow_multiple } }));
774
+ }
775
+ if (axis.value_set_uri) {
776
+ part.push(sub('valueSet', { valueString: axis.value_set_uri }));
777
+ }
778
+ return part;
779
+ }
780
+
781
+ /** One `value` subproperty; a `/` member carries its own axes inside it. */
782
+ async #valuePart(expr, token, meta) {
783
+ const part = [{ name: 'code', valueCode: 'value' }, { name: 'value', valueCoding: await this.#coding(token.concept) }];
784
+ const member = token.sep === '/' ? expr.stems.find(st => st.stem === token) : null;
785
+ if (member) {
786
+ for (const p of await this.#postcoordination(expr, member, false, meta)) {
787
+ part.push({ name: 'subproperty', part: p });
543
788
  }
544
789
  }
790
+ return { name: 'subproperty', part };
545
791
  }
546
792
 
547
793
  // -- subsumption -----------------------------------------------------------------------
@@ -591,15 +837,15 @@ class ICD11Services extends BaseCSServices {
591
837
 
592
838
  async nextContext(iteratorContext) {
593
839
  if (!iteratorContext || iteratorContext.cursor >= iteratorContext.rows.length) return null;
594
- return new ICD11Concept(iteratorContext.rows[iteratorContext.cursor++]);
840
+ return this.#concept(iteratorContext.rows[iteratorContext.cursor++]);
595
841
  }
596
842
 
597
843
  // -- filters ---------------------------------------------------------------------------
598
844
 
599
845
  /**
600
846
  * `concept is-a` and `concept descendent-of` are what WHO's own value sets use, and what
601
- * a postcoordination scale is expressed with. The filter value may be a short code or an
602
- * entity uri: WHO's published value sets mix the two, so both have to work.
847
+ * a postcoordination scale is expressed with. The filter value is a code as #resolve
848
+ * reads it: the short code, or the entity id (MMS, ICF) or uri (Foundation).
603
849
  */
604
850
  async doesFilter(prop, op, value) {
605
851
  return prop === 'concept' && (op === 'is-a' || op === 'descendent-of') && !!value;
@@ -653,13 +899,18 @@ class ICD11Services extends BaseCSServices {
653
899
  WHERE cl.ancestor = ? AND cl.depth >= ?
654
900
  ORDER BY COALESCE(c.lft, c.id)`, [set.ancestorId, set.minDepth]);
655
901
  } else {
902
+ // the text, or the start of a short code (searching 1A0 finds 1A00..1A09): the code
903
+ // is a uri now, so the short code is the one a person types
656
904
  set.rows = await this.#all(
657
- `SELECT DISTINCT c.* FROM designation d JOIN concept c ON c.id = d.concept
658
- WHERE c.system = ? AND d.lang IN (?, ?)
659
- AND d.kind IN ('title', 'indexTerm', 'inclusion', 'fullySpecifiedName')
660
- AND LOWER(d.value) LIKE ?
905
+ `SELECT c.* FROM concept c
906
+ WHERE c.system = ?
907
+ AND (LOWER(c.code) LIKE ?
908
+ OR EXISTS (SELECT 1 FROM designation d
909
+ WHERE d.concept = c.id AND d.lang IN (?, ?)
910
+ AND d.kind IN ('title', 'indexTerm', 'inclusion', 'fullySpecifiedName')
911
+ AND LOWER(d.value) LIKE ?))
661
912
  ORDER BY COALESCE(c.lft, c.id)`,
662
- [this.shared.systemId, this.#displayLang(), this.defLang(), `%${set.text}%`]);
913
+ [this.shared.systemId, `${set.text}%`, this.#displayLang(), this.defLang(), `%${set.text}%`]);
663
914
  }
664
915
  return set.rows;
665
916
  }
@@ -675,7 +926,7 @@ class ICD11Services extends BaseCSServices {
675
926
  async filterConcept(filterContext, set) {
676
927
  const rows = await this.#materialise(set);
677
928
  if (set.cursor >= rows.length) return null;
678
- return new ICD11Concept(rows[set.cursor++]);
929
+ return this.#concept(rows[set.cursor++]);
679
930
  }
680
931
 
681
932
  async filterLocate(filterContext, set, code) {
@@ -694,11 +945,11 @@ class ICD11Services extends BaseCSServices {
694
945
  `SELECT 1 AS ok FROM concept_closure
695
946
  WHERE ancestor = ? AND descendant = ? AND depth >= ? LIMIT 1`,
696
947
  [set.ancestorId, concept.id, set.minDepth]);
697
- return row ? true : `'${concept.canonicalCode}' is not in the value set`;
948
+ return row ? true : `'${concept.code || concept.canonicalCode}' is not in the value set`;
698
949
  }
699
950
  const rows = await this.#materialise(set);
700
951
  return rows.some(r => r.id === concept.id)
701
- ? true : `'${concept.canonicalCode}' is not in the value set`;
952
+ ? true : `'${concept.code || concept.canonicalCode}' is not in the value set`;
702
953
  }
703
954
 
704
955
  /** The filter sets are closed, whatever the code system as a whole may be. */
@@ -791,7 +1042,7 @@ class ICD11ServicesFactory extends CodeSystemFactoryProvider {
791
1042
 
792
1043
  /**
793
1044
  * The postcoordination scale value sets. WHO publishes one per axis per stem -- 25,354 of
794
- * them for 2026-01 -- and $lookup hands out their canonicals as the postcoordinationScale
1045
+ * them for 2026-01 -- and $lookup hands out their canonicals as the valueSet of each postcoordination
795
1046
  * property of a concept, so a client that follows one has to find something at the other
796
1047
  * end. They are built on demand from pc_scale rather than shipped: the compose is three
797
1048
  * or four is-a includes, and pre-expanding the lot would be 14.6 million rows.
@@ -811,8 +1062,11 @@ class ICD11ServicesFactory extends CodeSystemFactoryProvider {
811
1062
  FROM pc_scale ps JOIN concept c ON c.id = ps.concept
812
1063
  WHERE ps.value_set_uri = ? AND c.system = ?`, [url, this._shared.systemId]);
813
1064
  if (!scale) return null;
1065
+ // the filter values are codes, so ids: the scale root's uri is system + '/' + its id
814
1066
  const roots = await all(
815
1067
  `SELECT target_uri FROM pc_scale_entity WHERE scale = ? ORDER BY seq`, [scale.id]);
1068
+ const prefix = `${this._shared.url}/`;
1069
+ const rootCode = (u) => (u.startsWith(prefix) ? u.slice(prefix.length) : u);
816
1070
 
817
1071
  const slug = `${this._shared.systemCode}-${scale.entity_id}-${scale.axis_name}`.replace(/\//g, '-');
818
1072
  return {
@@ -827,14 +1081,14 @@ class ICD11ServicesFactory extends CodeSystemFactoryProvider {
827
1081
  date: this._shared.version,
828
1082
  publisher: 'World Health Organization',
829
1083
  description: `The values WHO permits on the ${scale.axis_name} postcoordination axis `
830
- + `of ${scale.stem_code || scale.stem_uri}.`,
1084
+ + `of ${scale.stem_code || scale.entity_id}.`,
831
1085
  compose: {
832
1086
  // one include per scale root: membership is the root and everything under it,
833
1087
  // which is exactly how WHO expresses it in its own published value sets
834
1088
  include: roots.map(r => ({
835
1089
  system: this._shared.url,
836
1090
  version: this._shared.version,
837
- filter: [{ property: 'concept', op: 'is-a', value: r.target_uri }],
1091
+ filter: [{ property: 'concept', op: 'is-a', value: rootCode(r.target_uri) }],
838
1092
  })),
839
1093
  },
840
1094
  };