Контекст

После каждого 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’а пациент за месяц копит десятки ресурсов одного типа. Решений на повестке — три уровня:

  1. Что считать единицей идентичности — серия независимых snapshot’ов (каждый run — отдельный объект) или одна evolving entity с историей.
  2. Если evolving entity — какой механизм versioning’а — собственная схема через POST + linked-list или FHIR-нативный механизм.
  3. Если 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-run meta.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}/_history API.

  • Stale-detection UX. Composition.meta.lastUpdated стабильно отражает «когда был последний run»; UI сравнивает с timestamp’ами свежеподнятых Observation/Condition → banner «новые данные, обновить интерпретацию?».

  • Snapshot Navigator UI. Стрелки ←/→ на /health walk’ают 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-Match optimistic locking. Дедуп нестрогий — теоретически возможен lost-update.

  • Pre-tagging legacy Composition versions — версии до landing’а analysis-run tag 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, не написана.

Связано