FHIR версионирует каждый resource «из коробки»: создание + каждое изменение порождает новую запись, прошлые версии остаются доступны через _history API. BloodGPT использует это для всех evolving entities — derived AI ресурсов (Composition / CarePlan / ClinicalImpression), которые перезаписываются на каждый analysis run. Эта страница — про механизм; app-level правила (как выбирать default-render из истории, как пэйрить связанные ресурсы) — в health-report-versioning-model.

meta.versionId и meta.lastUpdated

Каждая версия resource’а на сервере несёт server-assigned поля в meta:

  • meta.versionId — идентификатор версии. Free-form string по спеке; на практике HAPI и Google Healthcare API используют integer ("1", "2", …), но клиент не должен это assumить.
  • meta.lastUpdated — server timestamp последней записи этой версии. Стабильно отражает «когда».
  • meta.tag / meta.security / meta.profile — структурированные labels (см. meta-блок).

Клиент никогда не назначает versionId / lastUpdated сам. Любые значения, отправленные в этих полях, сервер игнорит и перезаписывает. Это даёт строгий monotonic ordering и server-authoritative timestamp.

Conditional PUT по identifier — основной механизм upsert’а

Когда домен говорит «одна сущность на пациента, обновляется», canonical FHIR-паттерн —conditional PUT по бизнес-identifier’у, не POST.

PUT /Composition?identifier=http://bloodgpt.com/fhir/identifier/patient-summary|Patient-2269
Content-Type: application/fhir+json
 
{ "resourceType": "Composition", "identifier": [...], ... }

Семантика:

  • Сервер ищет resource по identifier=system|value.
  • Если нет совпадения → POST-эквивалент: создаёт resource с новым id, versionId=1.
  • Если одно совпадение → PUT-эквивалент: обновляет тот же resource id, versionId++, прошлая версия остаётся в _history.
  • Если несколько совпадений → 412 Precondition Failed (idempotency violation — duplicate canonical pointers).

Альтернатива — PUT /Composition/{id} (по resource id), но это требует от клиента уже знать id. Conditional PUT по identifier работает с первого создания и далее, единый writer-code-path.

_history API — как читать прошлые версии

Стандартные FHIR-эндпоинты:

  • GET {Resource}/{id}/_history — bundle всех версий resource’а в обратном хронологическом порядке (newest first). Каждая entry — полная resource на момент той версии.
  • GET {Resource}/{id}/_history/{versionId} — точная версия.
  • GET {Resource}/{id}/_history?_count=N&_since=YYYY-MM-DD — пагинация и фильтр по lastUpdated.

Bundle entry’и несут request.method (POST/PUT/DELETE) — можно отличить версии created от updates от тombstone’ов (для удалённых ресурсов _history тоже работает; entry с request.method=DELETE маркирует deletion).

В отличие от обычного GET /{Resource}/{id} (возвращает latest), _history-чтения не платят за индекс по lastUpdated — версии физически линкованы внутри resource record’а.

Versioned vs unversioned references

Reference в FHIR может быть pinned к конкретной версии:

{ "reference": "Patient/2269/_history/5" }

— versioned reference, всегда возвращает ту же версию даже после следующих PUT’ов. Без /_history/{n} — unversioned, всегда возвращает latest. См. Ссылки в fhir-basics.

Versioned references ценны для immutable audit trail (e.g. Provenance.entity.what), но клиенту нужно их явно генерировать.

HAPI-specific: meta.tag accumulates additively через conditional-PUT

Важный quirk, на который BloodGPT полагается в CI ↔ Composition pairing.

По спеке meta.tag — это набор labels, и каждый PUT может его перезаписывать. HAPI вместо replacement делает union: tags, присутствовавшие в любой прошлой версии, переносятся в новую версию. Effect: версия, созданная в run’е N, несёт tags из run’ов 1..N (если каждый run писал свой run-id тэгом).

Следствие — можно пэйрить два ресурса (Composition и CI), записанных в один и тот же run, через tag-set superset: «paired CI = старейшая версия CI, у которой tag-set ⊇ Composition’s tag-set». Это работает потому что:

  • Каждый run стампит tag analysis-run|{runId} на каждый ресурс, который пишет.
  • HAPI на conditional-PUT накапливает тэги.
  • Версии, написанные в run K, содержат {runId-1, ..., runId-K} — все предыдущие плюс current.
  • «Старейшая CI с superset Composition’s tags» = CI version, написанная в том же run’е (либо первая, которая накопила всё что у Composition).

Если HAPI бы делал replace (как формально по спеке допустимо), meta.tag каждой версии бы содержал только текущий run-id — pairing работал бы через прямой equality, не superset. Наш writer-code независимо от поведения сервера стампит только current-run tag; superset-logic — на read-side как defense против любого варианта server behavior.

Это не FHIR-стандарт, а HAPI-implementation choice. Google Healthcare API (production) — поведение надо проверять отдельно при portability concerns.

Provider support

Providerconditional PUT_history APImeta.tag accumulation
HAPI (dev / staging)accumulates
Google Healthcare API (prod)TBD verify

Конкретные тесты production-поведения нужно сделать при движении из stage в prod (особенно последняя колонка — на ней висит CI-pairing).

Failure modes

  • Multiple matches при conditional PUT. 412 Precondition Failed. Сигнализирует поломку identifier-scheme’а (где-то писали duplicates). Recovery — мануальный cleanup.
  • If-Match mismatch. Если writer добавляет If-Match: W/"{versionId}" (optimistic locking) и сервер увидел уже более свежую версию — 412. Используется для race-condition защиты; BloodGPT сейчас не использует, полагается на Inngest dedup.
  • Stale read. Между _history walk и follow-up read latest может появиться новая версия. Это не баг, это eventual consistency — для read-only UI обычно ОК; для writer’ов требует If-Match.
  • Capped _history. Некоторые серверы конфигурируют retention period или max version count. HAPI по умолчанию — unlimited; production limits надо проверять.

Открытые вопросы

  • Production tag-accumulation behavior на Google Healthcare API — наш CI-pairing зависит от additive-tags-через-PUT. Если prod replaces — pairing деградирует на lastUpdated-fallback. Нужен test.
  • If-Match adoption для writer’ов с риском параллельных run’ов — currently отложено.

Связано