Под «документом» здесь — то что пациент сам загрузил: 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 state | Langfuse traces (LLM calls), Inngest event history |
Контекст
Вопрос «как удалять документы» имеет две оси ограничений:
-
Бизнес / 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.
-
Техника: derived artifacts (что делать с FHIR Observations, ссылающимися на удалённый источник), FHIR
_historysemantics (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:
| Артефакт | Механизм | Где определено |
|---|---|---|
Patient | deletedAt: DateTime? — timestamp tombstone, NULL = live | packages/database/prisma/schema.prisma |
BloodTest | status: 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-стороне
Что нужно для разрешения
- Audit oставшихся артефактов — что у нас с
BloodTestFile, GCS объектами, Langfuse trace retention. Без этого выбор любой стратегии неполный. - PHI-compliance constraint — есть ли формальные обязательства (GDPR / HIPAA / customer SLA) на physical deletion? Если да, отбрасывается B и часть C.
- Recovery use case — есть ли в продукте scenarios «отменить удаление» (UI-кнопка undo, support-instrument для customer recovery)? Если да — physical purge (A, D) усложняется.
- 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
_historysemantics для DELETE (что остаётся на сервере после DELETE-запроса, как server-side purge выглядит) - fhir-observation — статусные коды, в т.ч. различие
cancelledvsentered-in-error(для записей, удалённых по ошибке записи, не по запросу пользователя) - health-report-versioning-model — versioning для derived AI ресурсов через conditional PUT
- fhir-gravity — внешние consumer’ы FHIR API, которые могут зависеть от deletion shape — active