Контекст
После каждого patient-scoped analysis run (кнопка «Update analysis», POST /api/summary) BloodGPT генерирует три derived FHIR-ресурса:
Composition(subject = Patient/X) — narrative summary: identifiedPatterns, healthConsiderations, recommendedSteps, panelOverviews.CarePlan(subject = Patient/X) — followUpSchedules, urgentTests («Follow-up Recommendations»).ClinicalImpression(subject = Patient/X) — per-параметровый rich-output: clinicalInterpretation, foundContext / missingContext / referenceContext / additionalWorkup, citations, ссылки на каждуюObservation.
Семантически три ресурса — части rolling patient health summary: текущий взгляд системы на здоровье пациента, эволюционирующий во времени. Не immutable lab report (DiagnosticReport / Observation), не event-record. При анализе читается вся история наблюдений пациента, не один тест — поэтому ни один из трёх ресурсов семантически не «про конкретный тест».
С появлением «Update analysis» каждый клик повторяет анализ. Без правил versioning’а пациент за месяц копит десятки ресурсов одного типа. Решений на повестке — три уровня:
- Что считать единицей идентичности — серия независимых snapshot’ов (каждый run — отдельный объект) или одна evolving entity с историей.
- Если evolving entity — какой механизм versioning’а — собственная схема через POST + linked-list или FHIR-нативный механизм.
- Если FHIR-нативный — как выбирать default-render из истории — по wall-clock или по patient-timeline axis.
Позиции
Уровень 1: единица идентичности
A. Серия независимых snapshot’ов. Каждый run — отдельный resource с собственным id. Append-only лог. Lookup latest — search по subject + сортировка. Старые остаются как историческая запись.
B. Evolving entity. Один resource на пациента; каждый run перезаписывает его. Run history — отдельный механизм (или встроенный, см. уровень 2).
Уровень 2: механизм versioning’а (при B)
B1. POST новых ресурсов + ручной linked-list через extension. Append каждый run, новый resource id, в extension’е — ссылка на previous. Stable «canonical pointer» — отдельная структура (например, флаг is_current).
B2. Conditional PUT по identifier + FHIR-нативный _history. Первый run — POST с identifier’ом, последующие — PUT по тому же identifier’у. FHIR-сервер увеличивает meta.versionId, прошлые версии достаются через {Resource}/{id}/_history/{versionId} API. Механизм описан в fhir-resource-history.
Уровень 3: default-render из истории (при B2)
3a. max(lastUpdated). FHIR-search дефолт. Возвращает версию, записанную последней по wall-clock.
3b. max(asOfDate) = max(Composition.event[0].period.start). Patient-timeline axis. Возвращает версию с самой свежей «отчётной датой», независимо от того когда run произошёл по wall-clock.
3c. Явный promote через is_current extension. Writer выставляет boolean на нужной версии, read-path фильтрует.
3d. Synthetic ordering (run-id, sequence) — но FHIR id обычно UUID, не несёт chronological order.
Выбрали
(1) Evolving entity → (2) conditional PUT + native FHIR _history → (3) default-render по max(asOfDate).
Почему evolving entity
Domain mental model совпадает — «взгляд системы на здоровье пациента, который меняется», не «серия независимых заключений». Stable resource id даёт stable pointers (BloodTest.fhirAssessmentId указывает на тот же id, downstream — PDF, notifications, exports — читает одну сущность, не пересылает «какой именно run»). Single source of truth.
Почему conditional PUT + _history
FHIR-нативный механизм versioning’а — поддерживается и HAPI’ем, и Google Healthcare API’ем. Не пилим своё, не дублируем то, что платформа уже умеет. Storage остаётся flat (один resource id на пациента), audit trail для compliance — стандартный FHIR паттерн через _history API.
Аргумент «append-only лог как историческая запись» (B1) — снимается тем, что _history его и есть. Linked-list через extension’ы поверх POST дублирует platform-feature.
Почему max(asOfDate)
Сценарий: пациент запускает «Run analysis» на старом тесте — система пишет новую версию Composition с asOfDate в прошлом, но lastUpdated = now. По 3a (max lastUpdated) эта историческая retrospect-версия вытесняет current-state run как default render на /health. Пользователь зашёл посмотреть «как у меня сейчас» и видит интерпретацию пятилетней давности — bug-like UX.
По 3b (max asOfDate) — current-state run остаётся default’ом, потому что его patient-timeline date (свежий тест) > historical retrospect’а. Historical retrospect доступен через snapshot navigation, но не вытесняет live view.
asOfDate уже семантически нужен (per-test scoping в actuality-классификации биомаркеров), не лишний state. is_current extension (3c) — лишний writer-side state, race conditions при параллельных run’ах. Synthetic ordering (3d) не решает проблему — UUID не несёт chronological order, а заменить id-scheme на monotonic = тот же max(lastUpdated) через другую дверь.
Counterfactual
Если бы выбрали (A) series-of-snapshots — проблема «какую версию считать default» возникает идентично. Search по subject + сортировка имеет ту же patho, где исторический retrospect выигрывает. Asof-date axis ортогональна storage-shape выбору; она нужна независимо. Поэтому уровни (1)/(2) и (3) — независимые decisions, и (3) сохраняется как rule даже если кто-то будущий пересмотрит (1) или (2).
Следствия
-
Writer-side. Composition writer стампит
event[0].period.start = asOfDate(как клинический период, который покрывает документ; LOINC «История общая» — placeholder без сильной семантики, может уточниться). -
Read-side. Общий selector helper walk’ает Composition
_history, picks max(asOfDate). Используется обоими read-route’ами/health(Health Report rendering и Connection Map drawer) — гарантия, что обе панели рендерят одну и ту же версию. -
CI ↔ Composition pairing. CI и CarePlan не несут
asOfDateв явном виде. Pairing — черезanalysis-runmeta.tag superset: writer стампит tag с run-id на все три ресурса; HAPI accumulates тэги на каждый conditional-PUT (additive, не replacing — см. HAPI tag-accumulation квирк); paired CI = OLDEST CI version, у которой tag-set ⊇ Composition tag-set (это CI, записанный в том же run’е, либо самая ранняя версия, накопившая всё что у Composition). Fallback по lastUpdated, final fallback — latest CI. -
Patient-summary set. Три ресурса обновляются вместе каждый run — все upsert’ятся по identifier’у
{IDENTIFIER}|{fhirPatientId}в одной pipeline-итерации. Inngest dedup (~5min event-id bucket) защищает от параллельных писаний одного run’а.Composition.section[].entry → Reference(ClinicalImpression)— summary указывает на детальный per-param разбор. -
Identifier scheme.
http://bloodgpt.com/fhir/identifier/{patient-summary | care-plan | clinical-impression}|{fhirPatientId}. Multi-tenant: каждый tenant имеет свой Healthcare API store, отдельного(patient, organization)ключа не нужно. -
Pointers.
BloodTest.fhirAssessmentIdостаётся stable pointer’ом — patient-scoped трек пишет туда canonical Composition id, легаси-читатели продолжают резолвить через него. Audit / compliance — через{Resource}/{id}/_historyAPI. -
Stale-detection UX.
Composition.meta.lastUpdatedстабильно отражает «когда был последний run»; UI сравнивает с timestamp’ами свежеподнятых Observation/Condition → banner «новые данные, обновить интерпретацию?». -
Snapshot Navigator UI. Стрелки
←/→на/healthwalk’аютavailableVersions. Сейчас сортировка — FHIR-дефолт (lastUpdated desc); после max(asOfDate)-aware default selection та же ось логически должна перейти на asOfDate — отдельный UI-тикет (возможно с axis switcher: «health timeline» vs «analysis history»).
Открытые вопросы
-
CarePlan и CI без explicit asOfDate. Сейчас pairing через identifier + tag-superset держит. Если появится time-travel на follow-up (CarePlan-only view as-of-date X) или потребуется CI вне Composition-контекста — придётся backfill’ить asOfDate на writer’ах. Пока tag-pairing достаточен.
-
Race conditions при параллельных run’ах. Два run’а одновременно завершились, оба пишут PUT на тот же identifier. Сейчас полагаемся на Inngest dedup (~5min event-id bucket), не на FHIR
If-Matchoptimistic locking. Дедуп нестрогий — теоретически возможен lost-update. -
Pre-tagging legacy Composition versions — версии до landing’а
analysis-runtag scheme не несут tags, CI-пэйринг падает на lastUpdated-fallback. Естественно мигрируют при re-run’ах; specific cleanup не планируется. -
Накопленные orphan per-test CI (исторический keying на anchor-test, времён до re-key на patient-identifier) — оставлены как orphan’ы; читатели их не находят, ищут по patient-identifier.
-
fhirDocumentCompositionId(source-document envelope Composition) — отдельный resource type с той же naming-болезнью; отдельная decision при касании. -
enrichment-fan-out-fork-test-vs-patient— отдельная decision-страница про форк двух пайплайнов (test-scoped и patient-scoped); пока drafted, не написана.
Связано
- fhir-resource-history — механизм conditional PUT +
_historyAPI, HAPI-specific meta.tag accumulation квирк - patient-summary-composition-naming — scope split (patient-summary vs test-overview), identifier-scheme
- interpretation-scope-patient-vs-test — два трека (patient-scoped + legacy test-scoped), Stage-3
- health-report-pipeline — pipeline mechanics, как три ресурса собираются за run
- fhir-meta-tagging —
analysis-runtag scheme, origin tags - fhir-composition, fhir-careplan, fhir-clinical-impression — FHIR resource types
- fhir-resource-origin-and-lifecycle — origin → mutability rules
- ai-enrichment-separate-step — parallel PUT-pattern для Observation enrichment (legacy)
- staged-output-fhir-storage — общий принцип FHIR-storage