fhirsmith 0.18.1 → 0.18.2

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.18.2] - 2026-10-29
9
+
10
+ ### Added
11
+
12
+ - IPS: national profiles - the profile on Bundle that a national IG defines for an IPS document: its jurisdiction (an ISO 3166 country code, or a UN M49 region), the IG's package id and version, and the profile's url. They are shared across all projects at `/ips/national-profiles`, where everyone can see them and only the administrator adds, edits and deletes them (also as JSON). A new processing step, after validation, validates each document against each national profile - loading the IG's package into the validator, once - and keeps the outcome and whether the document passed (no errors). The document's new National Profiles view lists them, and shows each one's validation as the Validation view does
13
+ - IPS: `modules.ips.validator.jar` - a validator jar of the module's own (a local build of org.hl7.fhir.core, say), used as it is, never downloaded or updated. Don't put such a jar at `bin/validator_cli.jar`: the wrapper takes that jar's version from the `.version` file it wrote when it downloaded the release, so a different jar there is started as if it were that release, and is replaced by the next one
14
+ - Registry: `GET /tx-reg/choose-server` - given the code systems a request involves and the client's default server, which one server to send it to, and a log of why. The same choice the Java tooling makes, for clients that aren't Java
15
+ - SNOMED CT ECL: member filters (`^ refset {{ M ... }}`) - `effectiveTime` (with a time value or set), `active`, `moduleId` (any sub expression) and the reference set's own fields (`targetComponentId = << X`, `mapTarget = "..."`, `mapGroup >= #2`, ...), comma-separated and repeatable. A member filter anywhere other than directly after a memberOf constraint is a syntax error (`INVALID_ECL`)
16
+ - SNOMED CT ECL: concept filters (`{{ C ... }}`) - `active`, `definitionStatus` (primitive/defined), `definitionStatusId`, `moduleId` (a concept, a bracketed concept list or any sub expression) and `effectiveTime`
17
+ - SNOMED CT ECL: description filters (`{{ D ... }}`) - `term` (match and `wild:` search terms, single or a set), `language`, `typeId` and `type` (syn/fsn/def), `dialectId` and `dialect` (en-us, en-gb and the other aliases in the ECL specification) with acceptability sets, `moduleId`, `effectiveTime`, `active` and `id`. The filters in one block apply to the same description; only active descriptions are considered unless `active` is given
18
+ - SNOMED CT ECL: history supplements (`{{ + HISTORY }}`, `HISTORY-MIN` / `-MOD` / `-MAX`, `HISTORY (*)`, `HISTORY (refset expression)`) - the inactive concepts that the selected historical association reference sets associate with the result are added to it
19
+ - SNOMED CT ECL: `AND` and `OR` between refinements - between attributes (`: A = x AND B = y`, `: A = x OR B = y`, with brackets for nesting), within an attribute group (`{ A = x OR B = y }`) and between groups (`{ ... } OR { ... }`). Mixing AND and OR without brackets is rejected, as it is for compound expressions. Previously only `,` (conjunction) was accepted
20
+ - SNOMED CT ECL: concrete values in refinements (`= #500`, `>= #1.5`, `!= #-1`, `= "text"`, `= true`). The importer now reads `sct2_RelationshipConcreteValues_*` into a new cache section (cache version 19, after 18's alternate identifiers); editions imported before this report a concrete-value refinement as unsupported. The test edition cache is now `sct_test_20250909e.cache`
21
+ - SNOMED CT ECL: `!=` in refinements (`: A != X` - at least one A whose value is not in X; `[0..0] A != X` - every A is in X), reversed attributes (`: R A = X` - the concepts that are the value of A on a concept in X), and `,` as a conjunction between expressions as well as between refinements
22
+ - SNOMED CT ECL: comments (`/* ... */`), top and bottom of set (`!!> (...)`, `!!< (...)`), and attribute names that are expressions rather than a single concept (`: << 762705008 = ...`, `: (A OR B) = ...`, `: ^ refset = ...`, `: * = ...`, and the same after `.` in dotted expressions)
23
+ - SNOMED CT ECL: alternate identifiers (`LOINC#14394-1`, `"LOINC#14394-1"`): the scheme alias names an identifier scheme (LOINC = 30051010000102 |LOINC code ID|, as imported from the LOINC Extension) and the code resolves to the concept(s) carrying it, which then take hierarchy operators like any set. An unknown scheme, or a code that no concept carries, is `INVALID_ECL`, as an unknown concept is
24
+ - SNOMED CT ECL: reference set field selection on member-of (`^ [targetComponentId] refset`) is parsed, and reported as `UNSUPPORTED_ECL` (the selected fields, and many of the referenced components, are descriptions) rather than as a syntax error
25
+
26
+ ### Changed
27
+
28
+ - IPS: a clinical profile's page is the same for everyone. The required sections and entries are a table of Expectation, Section and Entry (`Code is system|code`, or `Code is in` a value set, with `[m..n]` when the cardinality isn't 1..1). The rules about resources are a table of Expectation, Resource Set (`Conditions where Code is ...`) and Rule, in words for `.exists()`, `.x.exists()` and `.x.y.exists()`, `.all(sameSubject())`, `.conformsTo(url)` and `.category.hasCoding(system, code)`. The administrator adds, edits and deletes the rules on that page one at a time, in a dialog, each saved by itself; the full editor is still there as Edit all
29
+ - IPS: `fhir-validator-wrapper` 1.6.1 (was 1.5.0) - it sends `/loadIG` the package in the request body (1.5.0 sent it in the query, which the validator ignored, so no national profile's package loaded), and starts validator 7.0.4 and later with `-txTests`, which the tx tests need
30
+ - Terminology server: code systems, value sets and concept maps that both hl7.terminology and an endpoint's FHIR core package provide. THO's is still always the default for the url, but the core versions are no longer dropped: a reference that names a core version (`url|5.0.0`) gets it, where THO doesn't have that version. Previously they weren't available at all, so a reference to one failed - including THO's own value sets that pin a core version (`ValueSet/concept-property-type` 6.0.1 includes `concept-property-type` 5.0.0) - and `valueSetVersion=5.0.0` found nothing
31
+ - Registry: the "Register your own server" and "Documentation" links on the main page come from the master registration file (`registration` and `ecosystem-documentation`), so each ecosystem has its own, and a link the file doesn't give is left out. They were hard-coded to the HL7 ecosystem's
32
+ - Tx tests: the `loinc` mode (the LOINC suite) is run
33
+
34
+ ### Fixed
35
+
36
+ - VSAC: the cache folder `terminology-cache/vsac` is created if it doesn't exist. Only its parent was being created (the folder name was passed as if it were a file name), so on a fresh install the VSAC database could not be created (`SQLITE_CANTOPEN`) and every VSAC value set failed
37
+ - CodeSystem resources: the `notSelectable` property is recognised as the tx-ecosystem requirements say - by the `concept-properties#notSelectable` uri, whatever local code the code system gives it, and by the code `notSelectable` only where the code system doesn't declare that code with a different uri. A code system that used `notSelectable` for a property of its own had its concepts marked `abstract` in expansions and treated as not selectable (`notSelectable-unprop-all`, `notSelectable-unprop-true`). A filter on that code is still a filter on the code system's own property
38
+ - LOINC: `$lookup` returns `CLASS` as a string, the class name (e.g. `CHEM`), with the Part code for the class as `partCode`; it returned the Part code as a code
39
+ - SNOMED CT ECL: a hierarchy operator on a bracketed expression was silently dropped - `< (A OR B)` returned A and B themselves rather than their descendants. It now applies to every member of the set, as do operators on `*` and on `^ refset`, which were previously rejected as unsupported
40
+ - SNOMED CT ECL: `^` on a reference set whose members are descriptions (a language reference set) returned an empty expansion. It is now reported as `UNSUPPORTED_ECL` when the reference set is named, and skipped when it merely falls inside a computed operand such as `^ *`
41
+ - SNOMED CT ECL: an attribute name with no comparison (`: 363698007` with nothing after it) was accepted by the parser and failed later as unsupported; it is a syntax error
42
+ - SNOMED CT ECL: an evaluation error is now `UNSUPPORTED_ECL` unless it is about a concept that does not exist (`INVALID_ECL`). It was classified by matching the error text, which filed `= true` as a syntax error. A concept id that is not 6-18 digits is rejected by the parser
43
+ - IPS: the pages link the module's browser scripts with their version (`?v=`), so a browser picks up a changed script as soon as the server is restarted with it, instead of using its cached copy for up to an hour
44
+
45
+ ### Tx Conformance Statement
46
+
47
+ FHIRsmith v0.18.2 passed all 4565 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+icf+closure+loinc, tests v1.10.1-snapshot, runner v7.0.3)
48
+
8
49
  ## [0.18.1] - 2026-10-25
9
50
 
10
51
 
@@ -79,12 +120,6 @@ Note: We are heading towards a 1.0.0 release.
79
120
  - `$lookup`: a warning when the `displayLanguage` asked for has no display for the code, and the display is in the default language (`LOOKUP_DISPLAY_LANGUAGE_FALLBACK`)
80
121
  - ICD-11: the `classificationCode` extension (provisional url `http://id.who.int/icd/schema/extension/classificationCode`) on a Coding carries the short code for an entity id, saving a lookup. When present it is checked in `$validate-code` (CodeSystem and ValueSet): a wrong value, or the extension on a code with no short code, on a short code, or on a system other than MMS, is an error
81
122
  - ICD-11 `$lookup`: postcoordination is reported as one `postcoordination` property per axis (subproperties `axis` and `value` Codings, a `/` member's value with its own nested postcoordination), replacing the WHO `postcoordinationScale` shape. With the new parameter `post-coordination-metadata=true` every declared axis is listed with `required`, `allowMultipleValues` and its value set; otherwise only the valued axes. `source` (the Foundation entity) is a Coding, and asking for `source.details` adds what a `$lookup` on it would return, designations included. A single-valued axis with several values gets a message in `$validate-code`
82
- - SNOMED CT ECL: member filters (`^ refset {{ M ... }}`) - `effectiveTime` (with a time value or set), `active`, `moduleId` (any sub expression) and the reference set's own fields (`targetComponentId = << X`, `mapTarget = "..."`, `mapGroup >= #2`, ...), comma-separated and repeatable. A member filter anywhere other than directly after a memberOf constraint is a syntax error (`INVALID_ECL`). Description filters (`{{ D }}`), concept filters (`{{ C }}`) and history supplements (`{{ + }}`) are now parsed and rejected as `UNSUPPORTED_ECL` rather than failing as a lexer error
83
- - SNOMED CT ECL: concept filters (`{{ C ... }}`) - `active`, `definitionStatus` (primitive/defined), `definitionStatusId`, `moduleId` (a concept, a bracketed concept list or any sub expression) and `effectiveTime`
84
- - SNOMED CT ECL: `AND` and `OR` between refinements - between attributes (`: A = x AND B = y`, `: A = x OR B = y`, with brackets for nesting), within an attribute group (`{ A = x OR B = y }`) and between groups (`{ ... } OR { ... }`). Mixing AND and OR without brackets is rejected, as for compound expressions. Previously only `,` (conjunction) was accepted
85
- - SNOMED CT ECL: description filters (`{{ D ... }}`) - `term` (match and `wild:` search terms, single or a set), `language`, `typeId` and `type` (syn/fsn/def), `dialectId` and `dialect` (en-us, en-gb and the other aliases in the ECL specification) with acceptability sets, `moduleId`, `effectiveTime`, `active` and `id`. The filters in one block apply to the same description; only active descriptions are considered unless `active` is given
86
- - SNOMED CT ECL: comments (`/* ... */`), top and bottom of set (`!!> (...)`, `!!< (...)`), and attribute names that are expressions rather than a single concept (`: << 762705008 = ...`, `: (A OR B) = ...`, `: ^ refset = ...`, `: * = ...`, and the same after `.` in dotted expressions)
87
- - SNOMED CT ECL: concrete values in refinements (`= #5`, `!= "x"`, `= match:"x"`, `= true`, ...) and alternate identifiers (`"ICD10#A01.1"`, `SCTID#123`) and reference set field selection on member-of (`^ [targetComponentId] refset`) are now parsed, so they are reported as `UNSUPPORTED_ECL` with a message saying what is missing (concrete value relationships / alternate identifier reference sets are not loaded) rather than as a syntax error
88
123
  - SNOMED CT: `tx/sct/documentation.md` - what the server supports for SNOMED CT: editions, ECL, expressions and the MRCM, implicit concept maps and translation
89
124
  - `utilities/make-sct-test-extension.js`: builds the extension concepts (module TEST1000003) in the SNOMED CT test edition used by the tx tests - one of each shape (fully defined ungrouped, fully defined role-grouped, equivalent to an International concept, primitive), plus inactive ones with and without historical associations
90
125
  - README: each module's status - in production; in production but still under development; under development / prototyping; or on the back-burner
@@ -106,9 +141,6 @@ Note: We are heading towards a 1.0.0 release.
106
141
  - ValueSet `$validate-code`: an include with more than one filter only checked the first. The other filters' checks return a message when the code fails them, and a message was taken as a pass
107
142
  - LOINC: the `exists` filter ignored its value. On a relationship property (`METHOD_TYP`, `COMPONENT`, `document-kind`, ...) the value was looked up as a code or display, so `exists true` and `exists false` both returned every code with the relationship (because a code displayed as "True" and one as "False" exist), and on a property (`CLASSTYPE`, `ORDER_OBS`, ...) `exists false` meant `exists true`. `true` now returns the codes that have the property, `false` every other code in the code system - Parts, AnswerLists and Answers included, so add `CLASSTYPE exists true` to restrict to LOINC terms (e.g. LOINC terms with no method: `METHOD_TYP exists false` + `CLASSTYPE exists true`). Any other value is not supported
108
143
  - Packages: a package download or page request was counted twice in the statistics (once by the validation step, once by the handler), and tarball downloads were not told from requests for a version's JSON document; downloads are now counted as `download`, version documents as `version`, each once
109
- - SNOMED CT ECL: a hierarchy operator on a bracketed expression was silently dropped - `< (A OR B)` returned A and B themselves rather than their descendants. It now applies to every member of the set, as do operators on `*` and on `^ refset`, which were previously rejected as unsupported
110
- - SNOMED CT ECL: `^` on a reference set whose members are descriptions (a language reference set) returned an empty expansion; it is now reported as `UNSUPPORTED_ECL`
111
- - SNOMED CT ECL: an evaluation error is now `UNSUPPORTED_ECL` unless it is about a concept that does not exist (`INVALID_ECL`); it was classified by matching the error text, which filed `= true` as a syntax error. A concept id that is not 6-18 digits is rejected by the parser
112
144
  - SNOMED CT expressions: the MRCM content type taken as "postcoordinated" was 723594008, which is *all precoordinated content* (postcoordinated is 723595009). So the body-structure part-of attributes (|Regional part of| etc., precoordinated only) were allowed in expressions, |Has ingredient| (postcoordinated only) was not checked, and for the attributes with a precoordinated-specific range that range was used in place of the general one
113
145
 
114
146
  - SHL: every access to a live link (`/shl/access/{uuid}`) hung without a response - the access-logging callback was a plain `function`, so `this.db` was undefined in it and the request was never answered (since 0.3.0). A `GET` on a link is now the U flag (direct file) access the SMART Health Links spec defines: a link with one file returns it as a JWE (`application/jose`), and `recipient` is read from the query and logged (it was always `anonymous`); a link with more than one file, or a VHL, still gets the manifest / VHL bundle. The manifest now has `status` (`finalized`), file `location`s are always https (they followed `req.secure`, so http behind a proxy without trust proxy), and `embedded` is the JWE itself (it was the base64 of it, as stored)
package/ips/ips.js CHANGED
@@ -39,10 +39,12 @@
39
39
  * @module ips/ips
40
40
  */
41
41
 
42
+ const http = require('http');
42
43
  const express = require('express');
43
44
  const path = require('path');
44
45
  const fs = require('fs');
45
46
  const crypto = require('crypto');
47
+ const scripts = require('./scripts');
46
48
  const rateLimit = require('express-rate-limit');
47
49
  const escape = require('escape-html');
48
50
  const session = require('express-session');
@@ -79,7 +81,69 @@ const TX_STARTUP_GRACE_MS = 5 * 60 * 1000;
79
81
  const FHIR_JSON = 'application/fhir+json; charset=utf-8';
80
82
  const CODE_RE = /^[a-z0-9][a-z0-9-]{1,31}$/;
81
83
  // paths at the root that can't be project codes
82
- const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup']);
84
+ const { jurisdiction: jurisdictionOf } = require('./jurisdictions');
85
+ const LOAD_IG_TIMEOUT_MS = 300000;
86
+ const RESERVED = new Set(['login', 'logout', 'admin', 'metadata', 'openapi', 'static', 'assets', 'lookup', 'national-profiles']);
87
+
88
+ /**
89
+ * Ask the validator service to load a package (id#version) - POST /loadIG with {"ig": ...} as
90
+ * the validator's LoadIGHTTPHandler takes it. fhir-validator-wrapper's own loadIG() (to 1.6.0
91
+ * at least) sends the package id and version as query parameters with no body, which the
92
+ * service refuses ("Error parsing JSON source: ... Eof at Line 1"), so it is only used for a
93
+ * validator with no address of its own (as the tests' are).
94
+ * @returns {Promise<Object>} the OperationOutcome the service answers with
95
+ */
96
+ function loadIG(validator, packageId, version) {
97
+ if (!validator.baseUrl) {
98
+ return validator.loadIG(packageId, version);
99
+ }
100
+ const body = JSON.stringify({ ig: `${packageId}#${version}` });
101
+ const url = new URL(`${validator.baseUrl}/loadIG`);
102
+ return new Promise((resolve, reject) => {
103
+ const req = http.request({
104
+ hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST',
105
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/fhir+json', 'Content-Length': Buffer.byteLength(body) }
106
+ }, (res) => {
107
+ const chunks = [];
108
+ res.on('data', (c) => chunks.push(c));
109
+ res.on('end', () => {
110
+ const text = Buffer.concat(chunks).toString('utf8');
111
+ try {
112
+ resolve(JSON.parse(text));
113
+ } catch (e) {
114
+ reject(new Error(`The validator answered HTTP ${res.statusCode} with a response that is not JSON (${text.length} bytes)`));
115
+ }
116
+ });
117
+ res.on('error', reject);
118
+ });
119
+ req.on('error', reject);
120
+ // loading a package can mean downloading it, and its dependencies
121
+ req.setTimeout(LOAD_IG_TIMEOUT_MS, () => req.destroy(new Error('The validator took too long to load the package')));
122
+ req.end(body);
123
+ });
124
+ }
125
+
126
+ /**
127
+ * What's wrong with a national profile's details, or null: a jurisdiction (a country's ISO 3166
128
+ * two-letter code, or a region's UN M49 three-digit code - see jurisdictions.js), a FHIR package id (lower case, at
129
+ * least two parts separated by dots), a version (as package versions are written), and the
130
+ * canonical URL (http or https) of the IG's profile on Bundle.
131
+ */
132
+ function nationalProfileProblem(jurisdiction, packageId, version, url) {
133
+ if (!jurisdictionOf(jurisdiction)) {
134
+ return 'The jurisdiction must be a country, by its ISO 3166 two-letter code (e.g. AU), or a region, by its UN M49 three-digit code (e.g. 150 for Europe)';
135
+ }
136
+ if (packageId.length > 128 || !/^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$/.test(packageId)) {
137
+ return 'The package id must be a FHIR package id: lower case letters, digits, - and _, in two or more parts separated by dots (e.g. hl7.fhir.au.ips)';
138
+ }
139
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/.test(version)) {
140
+ return 'The version must be a package version (e.g. 1.0.0, or 1.1.0-ballot)';
141
+ }
142
+ if (url.length > 1024 || !/^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/.test(url)) {
143
+ return 'The profile must be the canonical URL (http or https) of the IG\'s profile on Bundle';
144
+ }
145
+ return null;
146
+ }
83
147
 
84
148
  /**
85
149
  * What's wrong with a submitted document, if anything.
@@ -165,25 +229,6 @@ function preferValue(req, name) {
165
229
  return null;
166
230
  }
167
231
 
168
- // the browser scripts: the module's own files, read once and served from memory (so no
169
- // request reaches the file system), with an ETag so a browser can keep its copy
170
- const SCRIPTS = new Map();
171
- function script(name) {
172
- if (!SCRIPTS.has(name)) {
173
- const body = fs.readFileSync(path.join(__dirname, name), 'utf8');
174
- SCRIPTS.set(name, { body, etag: `"${crypto.createHash('sha256').update(body).digest('base64url').substring(0, 27)}"` });
175
- }
176
- const s = SCRIPTS.get(name);
177
- return (req, res) => {
178
- res.setHeader('Cache-Control', 'public, max-age=3600');
179
- res.setHeader('ETag', s.etag);
180
- if (req.get('If-None-Match') === s.etag) {
181
- return res.status(304).end();
182
- }
183
- res.type('application/javascript').send(s.body);
184
- };
185
- }
186
-
187
232
  function wantsHtml(req) {
188
233
  const f = req.query._format;
189
234
  if (typeof f === 'string' && f !== '') {
@@ -308,6 +353,7 @@ class IpsModule {
308
353
  this.setupSession();
309
354
  this.setupRootRoutes();
310
355
  this.setupProfileRoutes();
356
+ this.setupNationalProfileRoutes();
311
357
  this.setupProjectRoutes();
312
358
  this.setupValidator();
313
359
  this.setupValueSets();
@@ -451,7 +497,7 @@ class IpsModule {
451
497
  next();
452
498
  };
453
499
 
454
- r.get('/profile-editor.js', script('profile-editor.js'));
500
+ r.get('/profile-editor.js', scripts.handler('profile-editor.js'));
455
501
  r.get('/lookup', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.systemLookup(req, res)));
456
502
  r.get('/lookup-vs', this.session, adminJson, ...this.lookups, h('lookup', (req, res) => this.valueSetLookup(req, res)));
457
503
  r.get('/StructureDefinition', ...this.web, h('profiles', (req, res) => this.profileList(req, res)));
@@ -460,12 +506,37 @@ class IpsModule {
460
506
  r.get('/StructureDefinition/:id', profile, ...this.web, h('profile', (req, res) => this.profileRead(req, res)));
461
507
  r.get('/StructureDefinition/:id/edit', profile, this.session, this.csrf, admin, h('profile', (req, res) => this.profileEditor(req, res, req.profile)));
462
508
  r.post('/StructureDefinition/:id', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.profileSave(req, res, req.profile)));
509
+ // one constraint at a time, from the profile's page: add, edit (keeping its key), delete
510
+ r.post('/StructureDefinition/:id/constraint', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.constraintSave(req, res, null)));
511
+ r.post('/StructureDefinition/:id/constraint/:key', profile, this.sameOrigin, this.session, json, this.csrf, adminJson, h('profile', (req, res) => this.constraintSave(req, res, req.params.key)));
512
+ r.post('/StructureDefinition/:id/constraint/:key/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => this.constraintDelete(req, res)));
463
513
  r.post('/StructureDefinition/:id/delete', profile, this.sameOrigin, this.session, this.form, this.csrf, admin, h('profile', (req, res) => {
464
514
  this.store.deleteProfile(req.profileId);
465
515
  res.redirect(req.baseUrl + '/StructureDefinition');
466
516
  }));
467
517
  }
468
518
 
519
+ /**
520
+ * The national profiles, across all projects: everyone can see them, only the administrator
521
+ * adds, edits and deletes them. Before the project routes, so /national-profiles isn't taken
522
+ * for a project code.
523
+ */
524
+ setupNationalProfileRoutes() {
525
+ const r = this.router;
526
+ const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
527
+ const admin = this.adminOnly;
528
+ r.get('/national-profiles', ...this.web, h('national-profiles', (req, res) => this.sendNationalProfiles(req, res)));
529
+ r.post('/national-profiles', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => this.saveNationalProfile(req, res, null)));
530
+ r.post('/national-profiles/:id', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => this.saveNationalProfile(req, res, req.params.id)));
531
+ r.post('/national-profiles/:id/delete', this.sameOrigin, this.session, this.form, this.csrf, admin, h('national-profiles', (req, res) => {
532
+ const np = /^\d{1,15}$/.test(req.params.id) ? this.store.nationalProfile(parseInt(req.params.id, 10)) : null;
533
+ if (np && this.store.deleteNationalProfile(np.id)) {
534
+ this.log.info(`IPS module: national profile ${np.packageId}#${np.version} deleted`);
535
+ }
536
+ res.redirect(req.baseUrl + '/national-profiles');
537
+ }));
538
+ }
539
+
469
540
  setupProjectRoutes() {
470
541
  const r = this.router;
471
542
  const h = (name, fn) => (req, res) => this.handle(req, res, name, () => fn(req, res));
@@ -509,7 +580,7 @@ class IpsModule {
509
580
  next();
510
581
  };
511
582
 
512
- r.get('/editor.js', script('editor.js'));
583
+ r.get('/editor.js', scripts.handler('editor.js'));
513
584
  r.get('/:code', project, ...this.web, h('list', (req, res) => this.htmlList(req, res)));
514
585
  r.get('/:code/metadata', project, h('metadata', (req, res) => this.metadata(req, res)));
515
586
  // The submission form has no CSRF token and starts no session: submitting is open to
@@ -595,15 +666,22 @@ class IpsModule {
595
666
  * wait in the queue.
596
667
  */
597
668
  setupValidator() {
669
+ // the national profiles' packages, as they are loaded into the validator: id#version -> Promise
670
+ this.nationalPackages = new Map();
598
671
  const cfg = this.config.validator;
599
672
  if (!cfg || cfg.enabled === false) {
600
673
  this.log.warn('IPS module: no validator configured (modules.ips.validator) - documents will not be validated');
601
674
  return;
602
675
  }
603
676
  const FhirValidator = require('fhir-validator-wrapper');
604
- const jar = folders.filePath('bin', 'validator_cli.jar');
677
+ // a jar of its own (a validator built locally, say): used as it is, never downloaded or
678
+ // updated; otherwise the released one in the data folder's bin directory, kept up to date
679
+ const localJar = typeof cfg.jar === 'string' && cfg.jar.trim() ? (path.isAbsolute(cfg.jar) ? cfg.jar : folders.filePath(cfg.jar)) : null;
680
+ const jar = localJar || folders.filePath('bin', 'validator_cli.jar');
605
681
  this.validator = new FhirValidator(jar, this.log);
606
682
  const settings = {
683
+ autoDownload: !localJar,
684
+ skipUpdateCheck: !!localJar,
607
685
  version: cfg.version || '4.0.1',
608
686
  txServer: cfg.txServer || 'http://tx.fhir.org/r4',
609
687
  txLog: cfg.txLog || folders.filePath('logs', 'ips-validator-tx.log'),
@@ -611,7 +689,7 @@ class IpsModule {
611
689
  igs: Array.isArray(cfg.packages) ? cfg.packages : ['hl7.fhir.uv.ips#1.1.0'],
612
690
  timeout: cfg.timeout || 120000
613
691
  };
614
- this.log.info(`IPS module: starting the FHIR validator on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
692
+ this.log.info(`IPS module: starting the FHIR validator${localJar ? ` (${localJar})` : ''} on port ${settings.port} (tx ${settings.txServer}; ${settings.igs.join(', ')})`);
615
693
  this.validatorStarting = this.validator.start(settings).then(() => {
616
694
  this.log.info('IPS module: the FHIR validator is running');
617
695
  }).catch(e => {
@@ -622,6 +700,31 @@ class IpsModule {
622
700
  });
623
701
  }
624
702
 
703
+ /**
704
+ * Load a national profile's package into the validator - once: a package that loaded, or
705
+ * failed to (with why), is remembered until the national profiles are changed, so each
706
+ * document doesn't ask again. The validator's packages (modules.ips.validator.packages) are
707
+ * loaded when it starts; these are added to them.
708
+ * @returns {Promise<void>} rejects with the reason the package could not be loaded
709
+ */
710
+ loadNationalPackage(packageId, version) {
711
+ const key = `${packageId}#${version}`;
712
+ let p = this.nationalPackages.get(key);
713
+ if (!p) {
714
+ p = loadIG(this.validator, packageId, version).then(outcome => {
715
+ const bad = (Array.isArray(outcome?.issue) ? outcome.issue : []).filter(i => i && (i.severity === 'error' || i.severity === 'fatal'));
716
+ if (bad.length > 0 || outcome?.resourceType !== 'OperationOutcome') {
717
+ const why = bad.map(i => i.details?.text || i.diagnostics || i.code).filter(Boolean).join('; ') || 'the validator did not return an OperationOutcome';
718
+ throw new Error(`The validator could not load ${key}: ${why}`);
719
+ }
720
+ this.log.info(`IPS module: national profile package ${key} loaded into the validator`);
721
+ });
722
+ p.catch(e => this.log.warn(`IPS module: ${e.message}`));
723
+ this.nationalPackages.set(key, p);
724
+ }
725
+ return p;
726
+ }
727
+
625
728
  /** Read the value sets the terminology check holds documents to: the module's own, in ips/valuesets/. */
626
729
  setupValueSets() {
627
730
  try {
@@ -1295,6 +1398,52 @@ class IpsModule {
1295
1398
  render.renderSubmit(req.project, this.ctx(req, res), { needsToken: tokenConfigured(req.project.token), aiEnabled: this.store.projectAi(req.project).enabled === true, ...options }), Date.now(), req);
1296
1399
  }
1297
1400
 
1401
+ // ---- national profiles ----
1402
+
1403
+ /** The national profiles: a page (with the administrator's forms), or JSON. */
1404
+ sendNationalProfiles(req, res, message, status = 200) {
1405
+ const list = this.store.nationalProfiles();
1406
+ res.status(status);
1407
+ if (!wantsHtml(req) && !message) {
1408
+ return res.json(list.map(np => ({ jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url })));
1409
+ }
1410
+ this.sendHtml(res, 'National Profiles', render.renderNationalProfiles(list, this.ctx(req, res), message), Date.now(), req);
1411
+ }
1412
+
1413
+ /** Add (id null) or change a national profile, from the form: packageId, version, url. */
1414
+ saveNationalProfile(req, res, id) {
1415
+ const field = (name) => (typeof req.body[name] === 'string' ? req.body[name].trim() : '');
1416
+ const packageId = field('packageId');
1417
+ const version = field('version');
1418
+ const url = field('url');
1419
+ // a country code is upper case; a region's is digits
1420
+ const jurisdiction = field('jurisdiction').toUpperCase();
1421
+ const problem = nationalProfileProblem(jurisdiction, packageId, version, url);
1422
+ if (problem) {
1423
+ return this.sendNationalProfiles(req, res, problem, 400);
1424
+ }
1425
+ const existing = this.store.nationalProfileByPackage(packageId, version);
1426
+ if (id === null) {
1427
+ if (existing) {
1428
+ return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
1429
+ }
1430
+ this.store.createNationalProfile(packageId, version, url, jurisdiction);
1431
+ this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) added (${url})`);
1432
+ } else {
1433
+ const n = /^\d{1,15}$/.test(id) ? parseInt(id, 10) : -1;
1434
+ if (existing && existing.id !== n) {
1435
+ return this.sendNationalProfiles(req, res, `There is already a national profile for ${packageId}#${version}`, 409);
1436
+ }
1437
+ if (!this.store.updateNationalProfile(n, packageId, version, url, jurisdiction)) {
1438
+ return this.sendNationalProfiles(req, res, 'That national profile no longer exists', 404);
1439
+ }
1440
+ this.log.info(`IPS module: national profile ${packageId}#${version} (${jurisdiction}) saved (${url})`);
1441
+ }
1442
+ // a package that failed to load is tried again
1443
+ this.nationalPackages = new Map();
1444
+ res.redirect(req.baseUrl + '/national-profiles');
1445
+ }
1446
+
1298
1447
  // ---- clinical profiles ----
1299
1448
 
1300
1449
  /** The clinical profiles: a page, or a FHIR searchset of the StructureDefinitions. */
@@ -1321,7 +1470,31 @@ class IpsModule {
1321
1470
  res.type('application/fhir+json');
1322
1471
  return res.send(JSON.stringify(sd));
1323
1472
  }
1324
- this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url) }), Date.now(), req);
1473
+ const ctx = { ...this.ctx(req, res), valueSetTitle: (url) => this.valueSetTitle(url), profileTitle: (url) => this.profileTitle(url) };
1474
+ // the administrator edits the rows in place, a row at a time
1475
+ const editor = ctx.user?.isAdmin ? {
1476
+ constraints: this.editorConstraints(sd),
1477
+ sections: profiles.IPS_SECTIONS, systems: profiles.SYSTEMS, resourceTypes: profiles.RESOURCE_TYPES, sectionSystem: editorSchema.LOINC, canLookup: !!this.tx
1478
+ } : null;
1479
+ this.sendHtml(res, `Clinical Profile: ${sd.title}`, render.renderProfile(sd, ctx, editor), Date.now(), req);
1480
+ }
1481
+
1482
+ /**
1483
+ * What to call a profile a rule says resources conform to: one of the clinical profiles (by
1484
+ * its title), or one of the national profiles (by its jurisdiction and package), or null.
1485
+ */
1486
+ profileTitle(url) {
1487
+ if (typeof url !== 'string') {
1488
+ return null;
1489
+ }
1490
+ const plain = url.split('|')[0];
1491
+ const m = /\/StructureDefinition\/profile-([a-f0-9]{12})$/.exec(plain);
1492
+ const sd = m ? this.store.profile(m[1]) : null;
1493
+ if (sd && sd.url === plain) {
1494
+ return sd.title;
1495
+ }
1496
+ const np = this.store.nationalProfiles().find(p => p.url === plain);
1497
+ return np ? `${np.jurisdiction ? `${np.jurisdiction} ` : ''}national profile (${np.packageId}#${np.version})` : null;
1325
1498
  }
1326
1499
 
1327
1500
  /** The title of one of this module's own value sets, or null. */
@@ -1331,8 +1504,12 @@ class IpsModule {
1331
1504
  }
1332
1505
 
1333
1506
  /** The editor, for a new profile or an existing one. */
1507
+ editorConstraints(sd) {
1508
+ return profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
1509
+ }
1510
+
1334
1511
  profileEditor(req, res, sd) {
1335
- const constraints = profiles.constraintsOf(sd).map(c => ({ ...c, shape: profiles.parseShape(c.expression), rshape: profiles.parseResourceShape(c.expression) }));
1512
+ const constraints = this.editorConstraints(sd);
1336
1513
  this.sendHtml(res, sd ? `Edit Clinical Profile: ${sd.title}` : 'New Clinical Profile',
1337
1514
  render.renderProfileEditor(sd, this.ctx(req, res), {
1338
1515
  profile: { id: sd ? sd.id : null, title: sd ? sd.title : '', constraints },
@@ -1369,6 +1546,53 @@ class IpsModule {
1369
1546
  res.status(previous ? 200 : 201).json({ id: sd.id, url: `${req.baseUrl}/StructureDefinition/${sd.id}` });
1370
1547
  }
1371
1548
 
1549
+ /**
1550
+ * Add a constraint to a profile ({human, expression}), or replace one (by its key, which it
1551
+ * keeps). 201/200 with {key}, 404 when there is no constraint with that key, 422 with {problems}.
1552
+ */
1553
+ constraintSave(req, res, key) {
1554
+ const previous = req.profile;
1555
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
1556
+ const row = { key, human: body.human, expression: body.expression };
1557
+ const constraints = profiles.constraintsOf(previous);
1558
+ const at = key === null ? constraints.length : constraints.findIndex(c => c.key === key);
1559
+ if (at < 0) {
1560
+ return res.status(404).json({ problems: [`The profile has no constraint '${key}'`] });
1561
+ }
1562
+ // the row by itself first, so what is wrong is said of it, not of a number in the list
1563
+ const alone = profiles.checkInput({ title: previous.title, constraints: [{ human: row.human, expression: row.expression }] }, null);
1564
+ if (alone.problems.length) {
1565
+ return res.status(422).json({ problems: alone.problems.map(p => p.replace(/^Constraint 1/, 'The rule')) });
1566
+ }
1567
+ constraints.splice(at, key === null ? 0 : 1, row);
1568
+ const checked = profiles.checkInput({ title: previous.title, constraints }, previous);
1569
+ if (checked.problems.length) {
1570
+ return res.status(422).json({ problems: checked.problems });
1571
+ }
1572
+ this.saveConstraints(previous, checked.constraints);
1573
+ this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${checked.constraints[at].key} ${key === null ? 'added' : 'saved'}`);
1574
+ res.status(key === null ? 201 : 200).json({ key: checked.constraints[at].key });
1575
+ }
1576
+
1577
+ /** Delete one of a profile's constraints, back to the profile's page. */
1578
+ constraintDelete(req, res) {
1579
+ const previous = req.profile;
1580
+ const constraints = profiles.constraintsOf(previous);
1581
+ const at = constraints.findIndex(c => c.key === req.params.key);
1582
+ if (at < 0) {
1583
+ return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`The profile has no constraint '${req.params.key}'`), 404);
1584
+ }
1585
+ constraints.splice(at, 1);
1586
+ this.saveConstraints(previous, constraints);
1587
+ this.log.info(`IPS module: clinical profile ${previous.id}: constraint ${req.params.key} deleted`);
1588
+ res.redirect(`${req.baseUrl}/StructureDefinition/${previous.id}`);
1589
+ }
1590
+
1591
+ saveConstraints(previous, constraints) {
1592
+ const id = previous.id.substring('profile-'.length);
1593
+ this.store.saveProfile(id, profiles.build({ id, url: previous.url, title: previous.title, constraints }));
1594
+ }
1595
+
1372
1596
  // ---- the editor ----
1373
1597
 
1374
1598
  sendCompose(req, res) {
@@ -1597,6 +1821,7 @@ class IpsModule {
1597
1821
  ...this.ctx(req, res),
1598
1822
  steps: this.steps.map(st => st.name),
1599
1823
  level: typeof req.query.level === 'string' ? req.query.level : undefined,
1824
+ np: typeof req.query.np === 'string' ? req.query.np : undefined,
1600
1825
  problemsOnly: req.query.problems === '1'
1601
1826
  };
1602
1827
  return this.sendHtml(res, `${req.project.name}: Document ${row.id}`, render.renderDocument(req.project, row, view, ctx), start, req);
@@ -0,0 +1,65 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * Jurisdictions, as http://hl7.org/fhir/ValueSet/jurisdiction has them: a country, by its
9
+ * ISO 3166-1 two-letter code (urn:iso:std:iso:3166), or a region, by its UN M49 three-digit
10
+ * code (http://unstats.un.org/unsd/methods/m49/m49.htm). The codes and names are the ones
11
+ * the terminology module's providers hold (tx/cs/cs-country.js, tx/cs/cs-areacode.js).
12
+ *
13
+ * @module ips/jurisdictions
14
+ */
15
+
16
+ const { CountryCodeFactoryProvider } = require('../tx/cs/cs-country');
17
+ const { AreaCodeFactoryProvider } = require('../tx/cs/cs-areacode');
18
+
19
+ const ISO_3166 = 'urn:iso:std:iso:3166';
20
+ const M49 = 'http://unstats.un.org/unsd/methods/m49/m49.htm';
21
+ // in the country list, but not countries
22
+ const UNKNOWN = new Set(['XX', 'ZZ']);
23
+
24
+ let tables = null;
25
+
26
+ /**
27
+ * The providers' code lists, read once. Only their data is wanted, not code system
28
+ * providers, so their load() runs on an object with no i18n (it only fills codes/codeMap).
29
+ */
30
+ function load() {
31
+ if (!tables) {
32
+ const countries = Object.create(CountryCodeFactoryProvider.prototype);
33
+ const areas = Object.create(AreaCodeFactoryProvider.prototype);
34
+ // load() is async in form, but does its work synchronously
35
+ countries.load();
36
+ areas.load();
37
+ tables = { countries: countries.codeMap, areas: areas.codeMap };
38
+ }
39
+ return tables;
40
+ }
41
+
42
+ /**
43
+ * A jurisdiction by its code: two upper case letters (a country), or three digits (a
44
+ * region - an M49 region, not a country's numeric code).
45
+ * @param {string} code
46
+ * @returns {{system: string, code: string, display: string}|null} null if it is neither
47
+ */
48
+ function jurisdiction(code) {
49
+ if (typeof code !== 'string') {
50
+ return null;
51
+ }
52
+ const t = load();
53
+ if (/^[A-Z]{2}$/.test(code)) {
54
+ const c = t.countries.get(code);
55
+ // not the user-assigned codes (XK, QM-QZ...), nor the 'unknown' ones (XX, ZZ): they aren't countries ISO 3166 names
56
+ return c && !c.userDefined && !UNKNOWN.has(code) ? { system: ISO_3166, code, display: c.display } : null;
57
+ }
58
+ if (/^\d{3}$/.test(code)) {
59
+ const a = t.areas.get(code);
60
+ return a && a.class_ === 'region' ? { system: M49, code, display: a.display } : null;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ module.exports = { jurisdiction, ISO_3166, M49 };
package/ips/pipeline.js CHANGED
@@ -12,10 +12,11 @@
12
12
  * 1. translate translate CodeableConcepts that fail the terminology check to codes that pass it
13
13
  * 2. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
14
14
  * 3. validation validate the (translated) document with the FHIR validator (fhir-validator-wrapper)
15
- * 4. terminology check each CodeableConcept against the value sets for international exchange
16
- * 5. piqi measure the quality of the document with PIQI (not yet)
17
- * 6. profiles check the document against each clinical profile (profiles.js)
18
- * 7. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
15
+ * 4. national validate it against each national profile (the national IGs' profiles on Bundle)
16
+ * 5. terminology check each CodeableConcept against the value sets for international exchange
17
+ * 6. piqi measure the quality of the document with PIQI (not yet)
18
+ * 7. profiles check the document against each clinical profile (profiles.js)
19
+ * 8. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
19
20
  *
20
21
  * Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
21
22
  * one of two places: as a result stored against the document (store.saveResult - the
@@ -122,6 +123,66 @@ const validation = {
122
123
  }
123
124
  };
124
125
 
126
+ /**
127
+ * National profiles: the document is validated against each national profile - the
128
+ * national IG's package is loaded into the validator first (once), then the document is
129
+ * validated against the IG's profile on Bundle, as the validation step validates it. For
130
+ * each profile, the result keeps the OperationOutcome and whether the document passed: no
131
+ * errors (or fatal issues). A profile whose package can't be loaded, or that the validator
132
+ * can't validate against, is kept with the reason (pass is then null), and doesn't stop the
133
+ * pipeline.
134
+ */
135
+ const national = {
136
+ name: 'national',
137
+ async run(row, project, module) {
138
+ const list = module.store.nationalProfiles();
139
+ const started = Date.now();
140
+ if (list.length === 0) {
141
+ module.store.saveResult(row.id, 'national', { ms: 0, profiles: [] });
142
+ return 'national profiles: none';
143
+ }
144
+ const v = module.validator;
145
+ if (!v) {
146
+ throw new Error('The validator is not configured (modules.ips.validator)');
147
+ }
148
+ if (!v.isRunning()) {
149
+ throw new Error('The validator is not running');
150
+ }
151
+ const cfg = module.config.validator || {};
152
+ const checkIpsCodes = cfg.checkIpsCodes !== false;
153
+ const doc = JSON.stringify(row.analysed || row.bundle);
154
+ const profiles = [];
155
+ for (const np of list) {
156
+ const entry = { id: np.id, jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url };
157
+ const t0 = Date.now();
158
+ try {
159
+ await module.loadNationalPackage(np.packageId, np.version);
160
+ const outcome = await v.validate(doc, { profiles: [np.url], checkIpsCodes, timeout: VALIDATE_TIMEOUT_MS });
161
+ if (!outcome || outcome.resourceType !== 'OperationOutcome') {
162
+ throw new Error('The validator did not return an OperationOutcome');
163
+ }
164
+ entry.counts = issueCounts(outcome);
165
+ entry.pass = !entry.counts.error && !entry.counts.fatal;
166
+ entry.outcome = outcome;
167
+ } catch (e) {
168
+ entry.pass = null;
169
+ entry.error = e.message;
170
+ }
171
+ entry.ms = Date.now() - t0;
172
+ profiles.push(entry);
173
+ }
174
+ module.store.saveResult(row.id, 'national', {
175
+ ms: Date.now() - started,
176
+ validatorVersion: typeof v.jarVersion === 'function' ? v.jarVersion() : null,
177
+ profiles
178
+ });
179
+ const passed = profiles.filter(p => p.pass === true).length;
180
+ const failed = profiles.filter(p => p.pass === false).length;
181
+ const unchecked = profiles.length - passed - failed;
182
+ return `national profiles: ${passed} passed, ${failed} failed` + (unchecked ? `, ${unchecked} not checked` : '');
183
+ }
184
+ };
185
+
125
186
  // ---- the terminology check ------------------------------------------------------------
126
187
 
127
188
  /**
@@ -1003,10 +1064,10 @@ const aiStep = {
1003
1064
  };
1004
1065
 
1005
1066
  /** The steps, in order. Only the ones that exist yet. */
1006
- const STEPS = [translate, narrative, validation, terminology, profilesStep, aiStep];
1067
+ const STEPS = [translate, narrative, validation, national, terminology, profilesStep, aiStep];
1007
1068
 
1008
1069
  module.exports = {
1009
- STEPS, translate, narrative, validation, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
1070
+ STEPS, translate, narrative, validation, national, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
1010
1071
  validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
1011
1072
  IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
1012
1073
  };