Под «документом» здесь — то что пациент сам загрузил: PDF лабораторного отчёта, фото анализа, отсканированный bloodtest и т.п. Когда юзер жмёт «удалить» на своём тесте, под удаление попадает не только сам файл, но и вся downstream-цепочка артефактов, которые мы извлекли / сгенерили из него:

УровеньАртефакты
Sourceзагруженный файл в GCS (PDF / image); BloodTestFile в Postgres (метаданные файла); BloodTest в Postgres (analysis run)
Derived (FHIR)Observation (биомаркеры), DiagnosticReport (контейнер lab-отчёта), Composition (AI overview), ClinicalImpression, Provenance (audit trail генерации)
Pipeline stateLangfuse traces (LLM calls), Inngest event history

Контекст

Вопрос «как удалять документы» имеет две оси ограничений:

  1. Бизнес / compliance: HIPAA (US), GDPR right-to-be-forgotten (EU), regional retention rules (РФ — Закон о персональных данных), customer SLA (B2B-партнёры могут требовать proof of deletion). Это диктует что должно происходить с PHI при удалении — physical purge, retention period, audit log, etc.

  2. Техника: derived artifacts (что делать с FHIR Observations, ссылающимися на удалённый источник), FHIR _history semantics (DELETE оставляет tombstone, содержимое может оставаться в server-side history), cascade rules между Postgres ↔ FHIR ↔ GCS, recovery / undo сценарии.

Оси пересекаются: HIPAA-обязательство «physical purge PHI in 30 days» — это business constraint, который определяет техническое решение (нельзя tombstone-only, нужен purge-job). Right-to-be-forgotten — business, но требует cascade через все downstream FHIR-ресурсы. Recovery / «undo» — техническая фича, но при наличии compliance-обязательств на purge она усложняется.

Сегодня в production применены три разных pattern’а удаления без единой стратегии. Audit показан ниже, далее — варианты решения с учётом обоих осей.

Текущее состояние (audit)

Три разных pattern’а в production:

АртефактМеханизмГде определено
PatientdeletedAt: DateTime? — timestamp tombstone, NULL = livepackages/database/prisma/schema.prisma
BloodTeststatus: BloodTestStatus со значением 'deleted' — enum-tombstone, нет deletedAt колонкито же
BloodTestFileпока не verified — отдельный TBD audit
FHIR Observation / DiagnosticReport / ClinicalImpressionчерез FHIR API DELETE метод — resource переходит в _history с request.method=DELETE (fhir-resource-history)HAPI / Google Healthcare API спека
GCS объекты (исходные PDF / images)не verified
Langfuse tracesне verified — вероятно retention-policy на стороне Langfuse

Inconsistency пример: API handler в b2c-dashboard/app/api/v0/[...path]/route.ts фильтрует BloodTest через status: { not: "deleted" } — но Patient через deletedAt IS NULL. Каждый downstream consumer вынужден помнить, какой признак для какого resource’а. Это симптом отсутствия единой стратегии, не корневое решение.

Позиции

A. Cascade purge — физическое удаление всей цепочки

При удалении теста удаляем (DELETE) всё что вниз по цепочке: BloodTestFile, FHIR Observation(s), FHIR DiagnosticReport, ссылки в CI sub-extensions (rewriting CI на следующем run’е без этих entries), GCS объекты.

За:

  • Удалено значит удалено — никаких dangling ссылок
  • Endpoint’у не нужны special-case’ы для «биомаркер с stub’ом но без Observation»
  • PHI-compliance (GDPR right-to-be-forgotten, HIPAA) — proper deletion

Против:

  • Невозможен undo / recovery после ошибочного удаления
  • FHIR _history хранит DELETE-tombstone, но содержимое предыдущих версий по спеке может оставаться — нужно verify, что HAPI / Google Healthcare API физически чистят (особенно важно при purge для compliance)
  • CI sub-extensions нужно переписать — additional write при каждом delete
  • Cascade чтения cross-references стоит O(N) в количестве биомаркеров в CI

B. FHIR DELETE interaction (без physical purge)

Стандартный FHIR HTTP DELETE /Resource/{id} — server создаёт tombstone в _history (запись с DELETE-методом, без content), subsequent GET возвращает 410 Gone, search excludes. По спеке content предыдущих версий остаётся в history (server MAY физически удалить если policy того требует — но это не default).

За:

  • FHIR-нативно — стандартный HTTP verb, любой FHIR-сервер обязан поддерживать
  • Audit trail в _history — видно когда удалено и кем (через Provenance)
  • Reversible — PUT по той же URL «оживляет» ресурс
  • Чистая семантика — «remove this record», не conflate с invalidation

Против:

  • Default не purge’ит history — для GDPR / HIPAA compliance нужен отдельный mechanism поверх (например, Google Healthcare API Resource-purge — non-standard FHIR extension)
  • Cascade rules между Postgres и FHIR нужно определить руками — DELETE одной Observation не каскадирует на Composition.section.entry references
  • Recovery возможен только пока история не purge’нута; после full purge — restore невозможен

Важно: status='entered-in-error' — это НЕ deletion. R4 codesystem явно определяет это значение как «This electronic record should never have existed» — для случаев когда данные были введены ошибочно (wrong patient, typo’d value, mislabeled specimen). Спека прямо различает: если real-world activity произошла (тест реально сдан), используется cancelled, не entered-in-error. Юзер-инициированное удаление загруженного документа — не «record never should have existed», это «remove my data». Использовать entered-in-error как proxy для DELETE — semantic misuse, ломает downstream FHIR consumer’ов (которые ожидают entered-in-error на «wrongly recorded», а не на «user wanted to delete»). См. HL7 R4 §entered-in-error.

C. Унифицированный soft-delete на app-уровне (deletedAt везде)

Добавить deletedAt: DateTime? на все Prisma модели — BloodTest, BloodTestFile, и т.д. Refactor существующего BloodTest.status=‘deleted’ → deletedAt-флаг. FHIR данные остаются физически, query layer фильтрует.

За:

  • Один pattern для всех Postgres документов
  • Recovery возможен (UPDATE deletedAt = NULL)
  • Чистая audit trail (видно когда удалено)

Против:

  • Не решает FHIR-side — нужен parallel mechanism (либо B, либо A для FHIR)
  • Migration работы: рефакторить BloodTest.status enum, обновить все downstream queries (есть как минимум один status: { not: "deleted" } фильтр в b2c-dashboard)
  • PHI compliance — данные физически остаются (если это deal-breaker для GDPR — нужен purge job)

D. Hybrid — physical deletion FHIR + tombstone Postgres

Postgres: BloodTest.status='deleted' или deletedAt (опционально унифицировано через C). FHIR: cascade physical DELETE для всех связанных resources. CI sub-extensions переписываются на следующем run’е без удалённых biomarkers.

За:

  • PHI / compliance закрывается на FHIR-уровне
  • Postgres-уровень даёт recovery + UI-нужные сигналы («deleted N days ago»)
  • Endpoint upstream’а не нуждается в edge-case outdatedParameters[] для удалённых Observation — их просто нет
  • Минимум deviation от текущего поведения для UI-handler’ов

Против:

  • Двойная семантика — нужно понимать что Postgres-tombstone означает «FHIR cascade выполнен», иначе drift
  • Cascade rebuild CI sub-extensions — additional write
  • Recovery невозможен на FHIR-стороне

Что нужно для разрешения

  1. Audit oставшихся артефактов — что у нас с BloodTestFile, GCS объектами, Langfuse trace retention. Без этого выбор любой стратегии неполный.
  2. PHI-compliance constraint — есть ли формальные обязательства (GDPR / HIPAA / customer SLA) на physical deletion? Если да, отбрасывается B и часть C.
  3. Recovery use case — есть ли в продукте scenarios «отменить удаление» (UI-кнопка undo, support-instrument для customer recovery)? Если да — physical purge (A, D) усложняется.
  4. External FHIR consumer’ы — есть ли клиенты которые читают наш FHIR API напрямую (Google Healthcare API export, integrations через fhir-gravity)? Если да — B меньше нарушает их expectations.

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

  • Как удаляется GCS-объект — есть ли retention-policy или нужен explicit cleanup job?
  • BloodTestFile — есть ли deletedAt / status=‘deleted’ / parallel pattern?
  • При удалении Patient — каскадно ли удаляются все BloodTest? (Сегодня скорее всего нет — каждый имеет свой mechanism)
  • Если переходим к Variant D — cascade rebuild CI sub-extensions делаем sync (в delete-handler) или lazy (на следующем actuality-run’е)?
  • Что с Langfuse traces — нужно ли тоже purge’ить для compliance?

Связано

  • fhir-resource-history — FHIR _history semantics для DELETE (что остаётся на сервере после DELETE-запроса, как server-side purge выглядит)
  • fhir-observation — статусные коды, в т.ч. различие cancelled vs entered-in-error (для записей, удалённых по ошибке записи, не по запросу пользователя)
  • health-report-versioning-model — versioning для derived AI ресурсов через conditional PUT
  • fhir-gravity — внешние consumer’ы FHIR API, которые могут зависеть от deletion shape — active