Imported from jmandel/fhir-questionnaires-from-the-web (
skills/questionnaire-everything/SKILL.md). Install upstream withnpx skills add jmandel/fhir-questionnaires-from-the-web --skill questionnaire-everything. Copyright stays with the author.
Convert forms → FHIR Questionnaires (with SDC)
What this skill does
Takes a directory of mixed clinical form sources (*.pdf, *.html, *.png, *.txt, screenshots, vendor templates) and produces a directory of validated FHIR R4 Questionnaire JSON resources using HL7 SDC STU4 idioms.
Out of scope: prepopulation, observation extraction, terminology server expansion, and the rest of SDC's runtime-integration surface. The goal is structural and rendering fidelity of the source form, with full provenance back to the artifact it came from. Workflow integration is a separate problem.
Inputs and outputs
Input: a directory like:
forms-in/
├── phq-9.pdf
├── stanford-ortho-intake.pdf
├── audit-c.html
├── intake-screenshot.png
└── ...
Output: a sibling directory like:
forms-out/
├── questionnaires/
│ ├── phq-9.json
│ ├── stanford-ortho-intake.json
│ └── ...
├── triage.tsv # per-input: form|skip|uncertain + reason
└── validation.txt # validator output (local + official)
Workflow
1. Inventory and triage
For each input, extract text and decide:
| Bucket | Action |
|---|---|
| PROM / rating scale (named published instrument: PHQ-9, GAD-7, KOOS, AUDIT-C, etc.) | convert with the matrix idiom (see §3) |
| Local intake packet (new-patient registration, history & ROS, condition-specific intake) | convert with the intake-packet pattern (see §4) |
| Clinician checklist (preventive-care, fall-risk screening, referral form) | convert with the checklist pattern (see §4) |
| Follow-up / monitoring form | usually same shape as matrix or intake |
| Consent / privacy / financial-policy / records-request | skip — out of scope |
| Research paper, scoring guide, education handout, annual report | skip — not a fillable form |
| Calculator / explainer page (HTML) | skip if it's not a fillable form |
Text extraction:
- PDF:
pdftotext -layout input.pdf -(preserves columns); fall back topdftotext input.pdf -(raw) if layout breaks tables. - HTML:
lynx -dump -nolist input.htmlorw3m -dump input.html; for JS-heavy pages, usecurlfirst and only inspect the static parts. - Image / scan: OCR with
tesseract input.png -if no text layer. - Screenshot: same as image.
Form-likeness heuristics (use 2+ to confirm):
- Markers:
Name:,Date of birth,please check|circle|complete,signature,over the past (2|4) weeks,how often,rate your,on a scale, numbered Q-lines. - Hard-reject signatures:
annual report,workbook,reference guide,Methods:/Results:, DOI,p<0.001(research paper),Form 990,policy manual. - Scale-likeness fallback for PROMs that lack demographic markers:
0 = …/1 = …/not at all/several days/nearly every day/total score/circle one/strongly agree/disagreePLUS a medical-context noun (symptom|disease|disorder|pain|depression|anxiety|sleep|arthritis) PLUSscore|scale|questionnaire|index|inventory|assessment|measurein the document.
Write your triage decision per input into forms-out/triage.tsv so the user can see what was skipped and why.
2. Author one Questionnaire at a time
For each input bucketed as a form, write a Bun TypeScript builder script that imports scripts/lib.ts (bundled with this skill) and emits the Questionnaire JSON. Per-file scripts (instead of one big script) make the work composable, reviewable, and re-runnable.
Pattern: scripts/build/<id>.ts:
import { questionnaire, group, display, totalScore, ordinal, variable, calcExpr, EXT, SYS } from '../lib';
import { writeFileSync } from 'node:fs';
const ID = 'phq-9';
const opts = [
{ extension: [ordinal(0)], valueCoding: { system: SYS.loinc, code: 'LA6568-5', display: 'Not at all' } },
{ extension: [ordinal(1)], valueCoding: { system: SYS.loinc, code: 'LA6569-3', display: 'Several days' } },
{ extension: [ordinal(2)], valueCoding: { system: SYS.loinc, code: 'LA6570-1', display: 'More than half the days' } },
{ extension: [ordinal(3)], valueCoding: { system: SYS.loinc, code: 'LA6571-9', display: 'Nearly every day' } },
];
const items = [
['q1', 'Little interest or pleasure in doing things'],
['q2', 'Feeling down, depressed, or hopeless'],
// ...
];
const q = questionnaire({
id: ID, name: 'PHQ9', title: 'Patient Health Questionnaire (PHQ-9)',
derivedFrom: ['http://loinc.org/q/44249-1'],
extension: [variable('phq9Sum',
`%resource.item.descendants().where(linkId.startsWith('phq9.matrix.')).answer.valueCoding.extension.where(url='${EXT.ordinalValue}').valueDecimal.sum()`)],
item: [
display('phq9.instructions', 'Over the last 2 weeks, how often have you been bothered by any of the following problems?', { category: 'instructions' }),
group('phq9.matrix', 'PHQ-9 items',
items.map(([slug, text]) => ({ linkId: `phq9.matrix.${slug}`, type: 'choice', required: true, text, answerOption: opts })),
{ control: 'gtable' }),
totalScore('phq9.totalScore', 'PHQ-9 total score', 'phq9Sum', '44261-6'),
],
source: { sourceUrl: 'https://example.org/phq9.pdf', specialty: 'mental-health', formType: 'prom', host: 'example.org', sha256: '...' },
});
writeFileSync(`forms-out/questionnaires/${ID}.json`, JSON.stringify(q, null, 2) + '\n');
Run with bun scripts/build/phq-9.ts.
The library is intentionally thin — about 150 lines of plain functions that emit FHIR-shaped JSON. There's no proprietary runtime; the output is plain Questionnaire JSON that any FHIR tool can consume.
3. The matrix idiom — most PROMs reduce to this
If the source is a grid of "rate each symptom 0–4" items sharing one answer set, use:
group { itemControl: gtable, repeats: false }
├─ item { type: choice, answerOption: [ordinalValue + valueCoding, ...] }
├─ item { ... same shape ... }
└─ ...
+ a top-level `variable` extension that sums ordinals via FHIRPath
+ a readOnly integer/decimal item with `calculatedExpression` referencing the variable
The FHIRPath sum follows this template (substitute your linkId prefix):
%resource.item.descendants()
.where(linkId.startsWith('<prefix>.matrix.'))
.answer.valueCoding.extension
.where(url = 'http://hl7.org/fhir/StructureDefinition/ordinalValue')
.valueDecimal.sum()
This one recipe covers: PHQ-9, GAD-7, AUDIT, AUDIT-C, KOOS, WOMAC, FIQR, NICHQ Vanderbilt (×4 variants), ISI, ESS, FLACC, AIMS, BPI-SF, MDQ, ASRS, MAHC-10, PAID-20, SF-MPQ, RAPID3, and most other matrix-shaped scales. Items vary; structure doesn't.
4. Intake-packet and checklist patterns
Intake packets are longer (50–200 items typical) but mechanically simpler — flat groups of demographics, insurance, providers, medications, allergies, surgical history, family history, ROS checkboxes, social history, and a free-text chief complaint. Use:
- A top-level
groupper section, withtextset to the section heading. - Standard FHIR item types:
string(single line),text(paragraph),date,integer,quantity(withunitextension),boolean,choice/open-choice. - Repeating lists (medications, allergies, surgical history) → nested
groupwithrepeats: trueplusminOccurs/maxOccursextensions if there's a limit. - For "check all that apply" multi-select →
open-choicewithanswerOption.
Clinician checklists (e.g., AAFP preventive screening): one nested group per intervention with status/date/notes sub-items — same shape repeated. Use the matrix idiom with gtable control if the renderer needs a tabular layout.
Authoring conventions
Skeleton
Every Questionnaire emitted by this skill has:
| Field | Value |
|---|---|
resourceType |
"Questionnaire" |
id |
kebab-case slug (phq-9, bwh-neurosurgery-intake) |
url |
canonical, e.g. http://hobby.intake-forms/fhir/Questionnaire/phq-9 — change BASE in lib.ts to your publisher domain |
version |
"1.0.0" (bump on revisions) |
name |
machine-readable identifier (no spaces) |
title |
human-readable title |
status |
"draft" until the conversion is reviewed; "active" after |
experimental |
true for research collections; false for production |
subjectType |
["Patient"] (use ["RelatedPerson"] or ["Patient","Practitioner"] if the form is observer-administered or clinician-completed) |
date |
conversion date |
publisher |
your org or collection name |
derivedFrom |
when there's a LOINC question-set canonical, cite it: ["http://loinc.org/q/44249-1"] for PHQ-9, ["http://loinc.org/q/69737-5"] for GAD-7, etc. |
copyright |
source instrument's copyright/license text, copied verbatim from the source |
linkId conventions
- Hierarchical, dot-separated, scoped under the Questionnaire id:
phq9.matrix.q1,bwhneuro.demographics.lastName. - Stable: linkIds become foreign keys for QuestionnaireResponses and FHIRPath expressions. Don't rename casually after publishing.
- Unique across the entire tree, not just sibling-relative — the local validator enforces this.
Item types
| Type | Use for |
|---|---|
display |
preamble text, instructions, "see your provider" safety blocks. No answer. |
group |
section heading; contains nested item[]. No answer. |
string |
single-line free text (name, occupation) |
text |
multi-line free text (chief complaint, narrative) |
boolean |
yes/no (use choice with Y/N CodeSystem if you want ordinalValue) |
integer, decimal |
counts, scores, slider values |
quantity |
height/weight/BMI/BP — pair with unit / unitOption extensions |
date, dateTime, time |
per FHIR |
choice |
single-select from answerOption or answerValueSet |
open-choice |
multi-select or "other (specify)" |
attachment, reference, url |
per FHIR, rarely used in intake forms |
Provenance (always populate)
Every Questionnaire must carry meta so a consumer can trace it back to the source artifact. The bundled buildMeta() in lib.ts does this from a source argument:
"meta": {
"source": "https://example-clinic.org/forms/intake-2024.pdf",
"tag": [
{ "system": "http://hobby.intake-forms/fhir/CodeSystem/specialty", "code": "ortho-knee" },
{ "system": "http://hobby.intake-forms/fhir/CodeSystem/form-type", "code": "prom" },
{ "system": "http://hobby.intake-forms/fhir/CodeSystem/source-host", "code": "example-clinic.org" }
],
"extension": [
{ "url": "http://hobby.intake-forms/fhir/StructureDefinition/derivedFromArtifact", "valueString": "raw/pdf/example-clinic-intake-2024.pdf" },
{ "url": "http://hobby.intake-forms/fhir/StructureDefinition/sourceSha256", "valueString": "<sha256 of the source file>" },
{ "url": "http://hobby.intake-forms/fhir/StructureDefinition/alsoSeenAt", "valueString": "raw/pdf/other-site-copy.pdf" }
]
}
meta.source is core FHIR (the URL the resource originated from). meta.tag carries facets the consumer can filter on. meta.extension carries the source-file hash for dedup, the local artifact path, and any alsoSeenAt entries when the same instrument is republished by multiple sites (very common for standardized PROMs).
SDC features to use
| Feature | URL | Use for |
|---|---|---|
itemControl: gtable |
…/questionnaire-itemControl |
matrix layout |
itemControl: page / tab-container |
same | multi-section intake packets (renderer-dependent) |
itemControl: slider / drop-down / radio-button |
same | rendering hints |
ordinalValue |
http://hl7.org/fhir/StructureDefinition/ordinalValue |
numeric weight on a choice option (PROM scoring) |
optionExclusive |
…/questionnaire-optionExclusive |
"None of the above" mutually-exclusive option |
optionPrefix |
…/questionnaire-optionPrefix |
"(a)", "(1)" prefixes on answer options |
displayCategory |
…/questionnaire-displayCategory |
mark a display item as instructions, security, help |
variable |
http://hl7.org/fhir/StructureDefinition/variable |
top-level named FHIRPath expression (used by calculatedExpression) |
calculatedExpression (SDC) |
…/sdc-questionnaire-calculatedExpression |
computed score/subscale referencing a variable |
enableWhenExpression (SDC) |
…/sdc-questionnaire-enableWhenExpression |
branching condition too complex for the base enableWhen array |
cqf-expression |
http://hl7.org/fhir/StructureDefinition/cqf-expression |
alternative scoring with CQL (LOINC PHQ-9 example uses this) — prefer calculatedExpression + FHIRPath unless you have a CQL library reference |
unit / unitOption |
…/questionnaire-unit / -unitOption |
UCUM units on quantity items ([lb_av], [in_i], kg, cm) |
regex / entryFormat |
…/regex / …/entryFormat |
input validation pattern + placeholder hint (phone, ZIP, email) |
minOccurs / maxOccurs |
…/questionnaire-minOccurs / -maxOccurs |
bounds on repeating groups |
sliderStepValue, minValue, maxValue |
…/questionnaire-sliderStepValue, …/minValue, …/maxValue |
numeric slider/VAS items |
shortText (SDC) |
…/sdc-questionnaire-shortText |
column header for matrix rows |
supportLink |
…/questionnaire-supportLink |
link to help text |
rendering-xhtml |
…/rendering-xhtml |
rich-text alternative on display items |
SDC features to NOT use
This skill is for structural conversion, not workflow integration. The following extensions belong to SDC's prepopulation / extraction / adaptive surface and must be left out — both because they're out of scope and because including them silently changes how downstream tools handle the artifact:
| Extension | Why excluded |
|---|---|
sdc-questionnaire-launchContext |
declares prepopulation inputs |
sdc-questionnaire-initialExpression |
prefills an item from FHIR data |
sdc-questionnaire-candidateExpression |
answer candidates from FHIR data |
sdc-questionnaire-contextExpression |
runtime context for expressions |
sdc-questionnaire-itemPopulationContext |
per-item prepopulation scope |
sdc-questionnaire-sourceQueries |
embedded FHIR query for population |
sdc-questionnaire-itemExtractionContext |
extract Observations from response |
sdc-questionnaire-observationExtract |
per-item extract-as-Observation flag |
sdc-questionnaire-observationLinkPeriod |
window for extraction |
sdc-questionnaire-endpoint |
server endpoint for runtime calls |
sdc-questionnaire-entryMode |
sequential/random rendering mode |
sdc-questionnaire-lookupQuestionnaire |
reference another Questionnaire |
sdc-questionnaire-answerExpression |
dynamic answer options |
sdc-questionnaire-answerOptionsToggleExpression |
dynamic option toggling |
Adaptive $next-question operation |
adaptive form delivery |
The bundled validate.ts will flag any of these if they sneak in.
Choices: answerOption vs answerValueSet
- Inline
answerOption[]when answers are small, stable, and you want the artifact to be portable offline (no terminology server needed). answerValueSet(canonical URL, e.g.http://loinc.org/vs/LL358-3) when answers come from a published value set and a terminology server is available.- Always attach
ordinalValueextension on each option for any scored instrument —valueCodingalone is not enough for FHIRPath summation. - Use
optionExclusive: trueon the "None of the above" option in multi-select lists.
Behavior: enableWhen, calculatedExpression, variable
- Base R4
enableWhen[]array is fine for simple "show Q5 if Q1=yes" branching. Multiple entries in the array are AND'd; useenableBehavior: "any"for OR. - For complex conditions ("show item if any of Q1–Q9 > 0"), use the SDC
enableWhenExpressionextension with a single FHIRPath expression — much cleaner than 9 OR'denableWhenentries. - For computed totals/subscales, use a top-level
variableextension defining the FHIRPath expression once, then reference it from areadOnly: trueintegerordecimalitem viacalculatedExpression(%variableName). This gives the renderer one expression to evaluate and one place to display the result.
Spec quirks (real ones — call out in code comments if you hit them)
ordinalValue(R4) vsitemWeight(R5): R5 renamed the extension. R4 forms keep usingordinalValueathttp://hl7.org/fhir/StructureDefinition/ordinalValue. Don't mix.regex"superseded" bytargetConstraintper SDC STU4 prose, butregexis still the registered core extension with broader tool support. Keepregexon string items; usetargetConstraintonly for cross-item rules.- LOINC's official PHQ-9 example uses
cqf-expressionwith CQL ("language": "text/cql"). For hand-authored forms without a CQL library to reference, usecalculatedExpressionwith FHIRPath everywhere. answerValueSetrequires a terminology server to expand at render time. InlineanswerOptiondoesn't.
Validation
Step 1: local sanity check (fast, runs in ~50ms)
bun scripts/validate.ts forms-out/questionnaires/
Bundled with this skill. Checks:
- Valid JSON,
resourceType=Questionnaire, required fields present (url,status,name,title,subjectType) linkIdunique across the entire item tree (not just sibling-relative)- Every
enableWhen.questionresolves to an existinglinkId answerOptionitems match parent item type (novalueIntegeron a choice withvalueCoding)- No banned SDC extensions from the out-of-scope list above
- All item
typevalues are valid
Run this on every file save during authoring. It catches the most common mistakes (template-string typos that make JSON unparseable, banned extensions added reflexively) before they propagate.
Step 2: official HL7 validator (slower, more thorough)
# one-time setup in your workspace (not in the skill dir):
bun add fhir-validator-wrapper
# tell the validator which extension URL prefix is "ours" — provenance
# extensions you defined yourself that won't have a public StructureDefinition:
export OWN_EXT_PREFIX="http://my-publisher-domain/fhir/StructureDefinition/"
bun scripts/fhir-validator.ts forms-out/questionnaires/
This uses the fhir-validator-wrapper npm package — a Node wrapper that runs the official validator JAR as a long-lived HTTP service. First run downloads ~180 MB of JAR + IG packages (about 90 seconds); subsequent files in the same batch validate in milliseconds because the JVM stays warm and the IGs stay loaded.
Critical performance note: the cold start (JVM boot + IG load) is the long pole — typically 2–5 minutes on a fresh process; per-file validation after that is milliseconds. Batch every validation into one bun fhir-validator.ts invocation whenever possible. If you're iterating on fixes, prefer running the script against the entire directory once (let it report all errors), patching everything, then a single re-validation — not repeated single-file runs that each pay the cold-start tax. If you know you'll want both structural and terminology checks, enable txServer: 'http://tx.fhir.org/r4' in the same invocation; don't split them across two cold starts.
Requires java (any recent JRE). Configurable via env vars:
| Env var | Default | Meaning |
|---|---|---|
FHIR_VALIDATOR_JAR_PATH |
./validator_cli.jar |
where to cache/download the JAR |
FHIR_VERSION |
4.0.1 |
FHIR version to validate against |
FHIR_IGS |
hl7.fhir.uv.sdc#3.0.0 |
comma-separated IG packages to load |
FHIR_VALIDATOR_PORT |
8081 |
localhost port for the validator HTTP service |
OWN_EXT_PREFIX |
(empty) | URL prefix for your own provenance extensions; "extension URL ... could not be found" errors with this prefix are suppressed |
Common findings to expect and how to interpret:
| Finding | Meaning |
|---|---|
error: extension http://your-domain/... could not be found so is not allowed here |
The validator doesn't have a StructureDefinition for your custom extension. Set OWN_EXT_PREFIX to suppress — the script labels these "own-ext ignored" in the summary. |
error: extension http://hl7.org/.../questionnaire-minOccurs ... not allowed to be used at this point |
The R4 minOccurs/maxOccurs extensions can only sit on items with required: true (or required itself extended). Either make the item required or drop the extension. This is a real R4-vs-R5 constraint that the local validator doesn't catch. |
warning: Unable to validate code "LA6568-5" — Resolved system http://loinc.org, but not retrieved |
The validator skipped the LOINC lookup because the terminology server is off (tx n/a). Safe to ignore for local validation; flip txServer in scripts/fhir-validator.ts to http://tx.fhir.org/r4 if you want full terminology checks (slower, requires network). |
error: duplicate linkId X |
structural — fix immediately. (The local validator already catches this.) |
error: answerOption type does not match item type |
structural — fix immediately. |
error: missing required field X |
fix immediately. |
information: best practice ... |
review case-by-case. |
The wrapper's HTTP-service approach is the right pattern for batch validation. The two-step flow (local sanity check first, official validator second) lets the fast checker catch the trivial mistakes in your tight authoring loop and the slow-but-thorough validator catch the spec-conformance issues at end of conversion.
Step 3: semantic LOINC audit (the validator can't catch this)
The validator confirms that every (system, code) pair exists in LOINC and that the display matches the authoritative one. It cannot tell you that you've attached a valid LOINC code to the wrong concept — e.g., putting a "Total score [AUDIT-C]" code on the Questionnaire root, putting a 4-week-recall variant on a 2-week instrument, or hallucinating a code (72091-2) whose display happens to share one word with the instrument you meant ("Osteoarthritis" in both WOMAC and KOOS). All of these pass the validator but are semantically wrong and will mislead any downstream consumer.
After the validator is green, dump every remaining LOINC binding and read the table by eye:
# Emits TSV: code, file, linkId, our-context-text, LOINC official display
bun your-audit-script.ts > /tmp/loinc-audit.tsv
column -t -s$'\t' /tmp/loinc-audit.tsv | less -S
Look for these patterns:
- Form-level vs total-score code on the Questionnaire root:
Total score [X]codes belong on thetotalScoreitem, not theQuestionnaire.code[]. Use the form-level code (e.g.71354-5"Edinburgh Postnatal Depression Scale [EPDS]") at the root. - Wrong recall window: a code labeled
... in last 4 weeks [Reported.PHQ]on a GAD-7 item (which uses 2-week recall) is wrong even though it's a valid code. - Hallucinated codes that happen to validate: if the authoritative display is completely unrelated to the surrounding question text, the code is wrong even if it exists. Strip it.
- Items on instruments LOINC doesn't have codes for: CAGE individual items don't have LOINC codes (only
89001-2for the CAGE antibody lab test). If you've bound something that looks plausible (e.g. SAMHSA codes), strip it — better no code than wrong code.
A useful shortcut: for any standardized instrument that has a [PROM_NAME] panel/total LOINC, also confirm what code is meant for the form-level binding vs. the score binding. They are almost always two different codes.
Step 4: re-stamp provenance
If you rebuild Questionnaires after editing builders, re-run any provenance attribution step you have (the canonical URL, meta.source, meta.tag, meta.extension[sha256]) to make sure metadata stays consistent.
Checklist before declaring conversion done
For each Questionnaire:
-
id,url,name,title,status,experimental,subjectType,date,publisherall set -
derivedFromcites LOINC canonical when one exists (standardized instruments only) -
copyrightquotes the source instrument's terms verbatim -
meta.sourcepoints to the source URL;meta.tagcarries specialty/form-type/host;meta.extensioncarries sha256 + local artifact path - linkIds are stable, hierarchical, and unique
- Every PROM symptom-row option has
ordinalValue - Computed totals use top-level
variable+calculatedExpression(FHIRPath) - No extensions from the out-of-scope list above
-
bun scripts/validate.tspasses -
bash scripts/fhir-validator.shpasses with no errors (warnings acceptable per the table)
Files bundled with this skill
scripts/lib.ts— builder helpers (questionnaire,group,display,totalScore,ordinal,variable,calcExpr,whenExpr,yn,ordOptions,itemControl,displayCategory,buildMeta, plusEXT.*/SYS.*URL constants). Pure functions emitting plain JSON. ~210 lines, no dependencies.scripts/validate.ts— local sanity checker. Runs in milliseconds; suitable for tight authoring loops. No dependencies.scripts/fhir-validator.ts— wrapper around the official HL7 FHIR validator using thefhir-validator-wrappernpm package. Spawns the validator JAR as a long-lived HTTP service (warm JVM = fast batches). Requiresbun add fhir-validator-wrapperin your workspace, plusjava.
When to ask the user
Before starting a large batch, confirm:
- The canonical URL base (
BASEinlib.ts). Default ishttp://hobby.intake-forms/fhir; production deployments need their own publisher domain. status—draft(default; for review) oractive(already vetted)?- Triage edge cases — when you find PDFs that look like forms but might be research papers / scoring guides / education handouts, list them and ask before skipping.
- Source instrument copyright — for any standardized PROM, confirm the user has the right to host/redistribute it. Some PROMs (KCCQ, MIDAS, EORTC) require licensing for commercial use.
Reasons behind the conventions
A few of the choices in this skill have non-obvious reasons worth keeping in mind:
- Per-form builder scripts beat hand-authoring JSON or one giant generator. A 200-line PHQ-9 JSON becomes 30 lines of declarative TypeScript. A 73-item Stanford Ortho intake becomes 150 lines. The output is the same plain Questionnaire JSON, but the builder is small, reviewable, and re-runnable when you find a bug.
- Validation runs on every save, not at the end. A 50ms local check catches template-string typos and banned extensions before they propagate across dozens of files. The official HL7 validator is the second pass, not the first.
- Provenance is designed in from the start, not bolted on later.
meta.source+meta.tag+ the source-hash extension let a consumer trace any Questionnaire back to a specific PDF/URL/sha. This becomes essential when you have multiple versions of the same instrument (e.g., 10 different sites publishing AUDIT-C) or when a clinic revises its intake quarterly. - The "do not use" SDC list is as important as the "do use" list. SDC has gravity — start reading the spec and you'll be tempted to add
launchContext"just in case". Each excluded extension changes runtime semantics; leaving them out keeps the artifact portable across renderers and use cases. - Standardized instruments are republished constantly across the web (10+ sites posting the same AUDIT-C, 7+ sites posting PHQ-9). When converting a batch, dedup via the
meta.extension[alsoSeenAt]pattern rather than authoring 10 near-identical Questionnaires.