Open Sink, Closed Sink

鰤カマと鮭カマが好きです

医療系システムのためのデータ履歴管理アプローチ

医療系システムのガイドライン対応で、しばらく前に電子保存の三原則の仕組みをどう実現するかを議論する機会がありました。だいぶ時間が経ってしまったのですが、今回はこのことについて書いてみたいと思います。

電子保存の三原則とは、1999年に厚生省健康政策局長・医薬安全局長・保険局長が連名で出した通知『診療録等の電子媒体による保存について』*1のなかで示された原則で、医療情報を電子媒体に保存するためにシステムが備えるべきとされた真正性・見読性・保存性を指します。

法令に保存義務が規定されている文書等に記録された情報(以下「保存義務のある情報」という。)を電子媒体に保存する場合は次の3条件を満たさなければならない。

(1) 保存義務のある情報の真正性が確保されていること。

○故意または過失による虚偽入力、書換え、消去及び混同を防止すること。

○作成の責任の所在を明確にすること。

(2) 保存義務のある情報の見読性が確保されていること。

○情報の内容を必要に応じて肉眼で見読可能な状態に容易にできること。

○情報の内容を必要に応じて直ちに書面に表示できること。

(3) 保存義務のある情報の保存性が確保されていること。

○法令に定める保存期間内、復元可能な状態で保存すること。

<留意事項>

運用管理規程の策定と実施、証拠能力・証明力、患者のプライバシー保護。

診療録等の電子媒体による保存について

『医療情報システムの安全管理に関するガイドライン』 でも、

  • 内容の正確について、訴訟における証拠能力を有する程度のレベルを担保すること
  • 保存期間は各種法令で定めるところにより、その間は安全に保管されること

を求めたうえで*2、真正性の担保について次のように述べています。

真正性とは、正当な権限で作成された記録に対し、虚偽入力、書換え、消去及び混同が防止されており、かつ、第三者から見て作成の責任の所在が明確であることである。なお、混同とは、患者を取り違えた記録がなされたり、記録された情報間での関連性を誤ったりすることをいう。

(…中略…)

また作成の責任の所在を明確にすることも求められる。具体的には入力者及び確定者の識別・認証、記録の確定、識別情報の記録、更新履歴の保管において、対策を講じる必要がある(代行入力を行う 場合には、確定者の識別・認証において留意が必要である)。

医療情報システムの安全管理に関するガイドライン 第 6.0 版 システム運用編

これはつまり、例えば、

  • 「マスターデータを更新したら、カルテやオーダー(診療上の各種指示)の記録の内容が変わっちゃった」(トランザクションデータにマスターデータのスナップショットではなく参照をもたせてしまう)とか、
  • 「診断や検査の過程で、患者の傷病名が過去にどのような変遷を辿ったか追えなくなってしまった」(傷病名は「肺癌の疑い」→「右肺癌」→「右非小細胞肺癌」のように変化していきます)

といった事態や設計はあってはならない、ということになります(上の通知ではこのことは「旧情報の見読」と表現されています)。

またこのほかにも、代行入力である場合はそれを明らかにして記録する機能を備えたり、「職員が業務上必要のない患者の診療情報に興味本位でアクセスする」、「他の職員になりすまして情報を書き換えたり入力したりする」といった事実を後から監査できるように記録する必要があり、こうした要件を満たさなければなりません。

「後から監査するためのログ記録」については、業務システムで一般的に言及される「監査ログ」と同様のものといえます。

監査ログとは、システム内で何が起こっているのか、誰がいつどこで何をしたかを特定するために重要なログのことです。

例えば、設定したルール通りにシステムが正しく動作しているか、ユーザーから正しく利用されているか、または異なるシステム間での連携は問題ないかなどをチェックできます。監査ログを詳細に記録することで、情報システム部門は、内部の不正情報・セキュリティ違反などが発生したときに、ログを見るだけで追跡できます。

監査ログとは何か?取得する目的とイベント・方法を詳しく紹介! | ヨシヅミ-吉積情報株式会社|Google 認定プレミアパートナー

HL7 FHIRにおける監査ログ

医療情報の交換・共有のための世界的な標準規格として期待されているHL7 FHIR*3も、こうした点を考慮した仕様になっています。FHIRはいわゆるRESTfulな情報交換用フレームワークで、エンティティにあたる概念をDomainResourceと呼んでおり、RESTFul APIの要領でDomainResourceに対するCRUD操作をおこなえる仕様になっています。

傷病名を例にしてみます。FHIRで傷病名はConditionというDomainResourceで表現されています。

「識別子(ID)が condition-id であるCondition」は Condition/condition-id のように表現され、

GET /Condition/condition-id

の形式でREST APIを叩くことで、そのデータの取得をリクエストすることができます。

このAPIアクセスはリソースの現在の最新状態を取得するもので、UPDATEやDELETEを繰り返しているリソースの更新前の状態を取得する場合は、そのためのAPIが別途用意されています。各世代にはversion idという版特有のIDが付与されており、 Condition/condition-id/_history/version-id のような記法によって、Conditionの特定の版を取得するよう指定することができます*4

ただ、この記法(historyとかvreadとか言います)では、DomainResourceの各バージョンは記録されますが、「誰がいつ操作したか」といった情報は記録されないため、この仕組みだけでは三原則やガイドラインの要件を満たせません。

FHIRにはそのためのDomainResourceも用意されており、DomainResourceに対する版管理として、操作対象(entity)・操作者(agent)・操作(activity)とともに永続化するProvenanceと、イベントや操作記録としてのアクセスログに相当するAuditEventを使うことができます。

これらのDomainResourceはあくまでも開発者が必要に応じて使うものであり、システム要件を満たす粒度や頻度で記録するよう、プログラマによって実装されることになります。

RDBでの表現 ~ 案1

こうした背景のなか、今回は「RDBでの記録」を前提として、ガイドライン・三原則の要件や監査ログ機能をどのように実現するかを検討しました。特に「更新や削除による変更の履歴を追跡し、ある時点における当時の状態を復元できること」、変更履歴をもつテーブル設計について時間を割いたので、この点を重点的に書いてみたいと思います。

あるエンティティの各世代の状態を履歴として管理する場合、そのエンティティのテーブルに世代を示すrevisionのようなカラムを足したり、 あるいは別にhistoryテーブルを用意するなどの案が考えられます。

revisionやhistoryテーブルによる実装は、先にふれたFHIRでのversion idやvreadとも通じており、実装として違和感なく首肯されるところです。

このほか、一回の操作をあらわすoperate_logテーブルと、「何をしたか」をエンティティの属性ごとに記録するoperate_log_itemテーブルを用意する、というものも見られました。

ここでまた傷病名を題材にして、実際のテーブルの構造を考えてみます。傷病名は、例えば次のような disease エンティティを考えることができます。そして、各世代の履歴としてdisease_historyをもっている、という状態です*5

erDiagram
    disease ||--o{ disease_history : "has"
    disease {
        uuid id PK
        string name  "傷病名"
        datetime created_at
        datetime deleted_at
    }
    disease_history {
        uuid id PK
        uuid disease_id FK
        string name "傷病名"
        datetime operated_at "操作日時"
        short operation_type "操作種別(1 : 作成 / 2 : 更新 / 3 : 削除)"
        uuid operated_by "操作者"
    }

素直な実装です。真正性の担保のため、操作者や操作の種別、時刻もあわせて記録するようにしています。

実用では、傷病名に「修飾語」(modifier)というエンティティが一対多でひもづき、傷病名と修飾語によって傷病名集約を構成している、という表現にすることが多いです。修飾語というのは、例えば「右」「両側」のような部位的な補足など、様々な詳細を傷病名に付与する修飾子です。

そうすると、こういう構造になります。

erDiagram
    disease ||--o{ modifier : "has"
    disease ||--|{ disease_history : "has"
    modifier ||--|{ modifier_history : "has"
    
    disease {
        uuid id PK
        string name  "傷病名"
        datetime created_at
        datetime deleted_at
    }
    
    modifier {
        uuid id PK
        uuid disease_id FK
        string name "修飾語名"
        datetime created_at
        datetime deleted_at
    }
    
    disease_history {
        uuid id PK
        uuid disease_id FK
        string name
        datetime operated_at
        short operation_type
        uuid operated_by
    }
    
    modifier_history {
        uuid id PK
        uuid modifier_id FK
        string name
        datetime operated_at
        short operation_type
        uuid operated_by
    }

構造そのものに複雑性はないですが、ある時点における傷病名集約の状態を復元するには、少しばかり複雑なSQLを指定してあげないといけなくなりそうなのは想像がつきます。 operated_at を使って頑張る必要がある訳ですね。

これで頑張るのは結構苦しい印象があります。このほかにも、「変更履歴を時系列順にすべてリストアップしたい」といったユースケースでは、SQLがさらに複雑化しそうです。

historyうしのリレーションの導入 ~ 案2

そこで、「各世代どうしにリレーションを貼っちゃえばいいじゃない」という発想が出てきます。

erDiagram
    disease ||--o{ modifier : "has"
    disease ||--o{ disease_history : "has"
    modifier ||--o{ modifier_history : "has"
    disease_history ||--o{ disease_modifier_relation : "relates to"
    modifier_history ||--o{ disease_modifier_relation : "relates to"

    disease {
        uuid id PK
        string name
        datetime created_at
        datetime deleted_at
    }
    
    modifier {
        uuid id PK
        uuid disease_id FK
        string name
        datetime created_at
        datetime deleted_at
    }

    disease_history {
        uuid id PK
        uuid disease_id FK
        integer history_version "版"
        string name
        datetime operated_at
        short operation_type
        uuid operated_by
    }

    modifier_history {
        uuid id PK
        uuid modifier_id FK
        integer history_version "版"
        string name
        datetime operated_at
        short operation_type
        uuid operated_by
    }

    disease_modifier_relation {
        uuid disease_history_id FK
        uuid modifier_history_id FK
    }

これで各世代における情報の復元はかなりやりやすくなります。時刻や版からdisease_historyが定まれば、あとはリレーションでいもづる的に当時の各modifierエンティティも特定できます。

ただしこの構造を採用する場合、disease集約が更新される都度、(たとえdisease自身には変更がなかったとしても)disease_historyを記録する必要があります。 history_version 以外が同じ内容のdisease_historyレコードが積み上げられることになります。

例えば、idd1 である傷病名集約が「肺癌 の疑い」→「 肺癌」→「 非小細胞肺癌」という変遷を経たとすると、次のような記録が考えられます*6

disease テーブル:

id name created_at deleted_at
d1 非小細胞肺癌 2023-07-01 10:00:00 [NULL]

modifier テーブル:

id disease_id name last_operated_at operation_type operated_by
m1 d1 の疑い 2023-07-02 11:00:00 2023-07-01 10:00:00 2023-07-02 11:00:00
m2 d1 2023-07-02 11:00:00 2023-07-02 11:00:00 [NULL]

disease_history テーブル:

id disease_id history_version name operated_at operation_type operated_by
dh1 d1 1 肺癌 2023-07-01 10:00:00 1 u1
dh2 d1 2 肺癌 2023-07-02 11:00:00 2 u2
dh3 d1 3 非小細胞肺癌 2023-07-03 12:00:00 2 u3

modifier_history テーブル:

id modifier_id history_version name operated_at operation_type operated_by
mh1 m1 1 の疑い 2023-07-01 10:00:00 1 u1
mh2 m1 2 の疑い 2023-07-02 11:00:00 3 u2
mh3 m2 1 2023-07-02 11:00:00 1 u2

disease_modifier_relation テーブル:

disease_history_id modifier_history_id
dh1 mh1
dh2 mh3
dh3 mh3

肺癌 の疑い」から「 肺癌」に更新されるとき、diseaseエンティティ自身は 肺癌 のままで何も変更が加えられていませんが、disease集約視点で見ると「の疑い modiferが削除され、新しくmodifierが追加された状態」に変化しています。

集約としての世代を管理する構造がないので、集約ルートたるdiseaseエンティティにその責務を負わせるほかなく、history_version を1つインクリメントさせた新しいdisease_historyを記録し(dh1dh2)、これに新しいmodifier_historyとdisease_modifier_relationが関連づくよう記録します。

こうすることで更新の前後の状態を完全に管理できますが、diseaseエンティティ自身は何も変更されていないのにdisease_historyが記録されているので、変更履歴としては歪であるといえます。

エンティティの履歴管理と集約の履歴管理をわける ~ 最終案

上の構造案は、変更履歴の追跡のためのhistoryなのに、変更がなくてもレコードが追加されてしまう状態でした。各エンティティの版管理と集約の版管理の両方の責務を、集約ルートであるエンティティに兼ねさせていることが問題になっている訳です。

この点はレビューで指摘を受けてはじめて気がついたもので、その後メンバーとの議論のなかで集約の履歴管理用の disease_revision を切り出す案が出され、最終的には次のような構造になりました。

erDiagram
    disease ||--o{ modifier : "has"
    disease ||--o{ disease_history : "has"
    disease ||--o{ disease_revision : "has"
    modifier ||--o{ modifier_history : "has"
    disease_revision ||--o{ disease_modifier_relation : "relates to"
    modifier_history ||--o{ disease_modifier_relation : "relates to"
    disease_revision }|--|| disease_history : "relates to"

    disease {
        uuid id PK
        string name
        datetime created_at
        datetime deleted_at
    }

    modifier {
        uuid id PK
        uuid disease_id FK
        string name
        datetime created_at
        datetime deleted_at
    }

    disease_revision {
        uuid id PK
        uuid disease_id FK
        uuid disease_history_id FK
        integer revision "集約の版"
        datetime created_at
    }

    disease_history {
        uuid id PK
        uuid disease_id FK
        integer history_version "エンティティの版"
        string name
        datetime operated_at
        short operation_type
        uuid operated_by
    }

    modifier_history {
        uuid id PK
        uuid modifier_id FK
        integer history_version "エンティティの版"
        string name
        datetime operated_at
        short operation_type
        uuid operated_by
    }

    disease_modifier_relation {
        uuid disease_revision_id FK
        uuid modifier_history_id FK
    }

各エンティティの変更履歴はdisease_historyとmodifier_historyで管理し、登録・変更・削除の都度 history_version をインクリメントした新しいレコードが作成されます。

一方集約としての管理はdisease_revisionでおこないます。disease自身に変更がない場合は、disease_historyの代りにdisease_revisionを記録することで、集約ルートであるdiseaseエンティティの履歴管理と、集約全体の履歴管理を明確にわけておこなえるようになりました。diseaseとmodifierのリレーションはdisease_revisionとmodifier_historyの関連として表現しています。

先ほどの肺癌の例は、次のようになります。

disease テーブル:

id name created_at deleted_at
d1 非小細胞肺癌 2023-07-01 10:00:00 [NULL]

modifier テーブル:

id disease_id name created_at deleted_at
m2 d1 2023-07-02 11:00:00 [NULL]
m1 d1 の疑い 2023-07-01 10:00:00 2023-07-02 11:00:00

disease_revision テーブル:

id disease_id disease_history_id revision created_at
dr1 d1 dh1 1 2023-07-01 10:00:00
dr2 d1 dh1 2 2023-07-02 11:00:00
dr3 d1 dh2 3 2023-07-03 12:00:00

disease_history テーブル:

id disease_id history_version name operated_at operation_type operated_by
dh1 d1 1 肺癌 2023-07-01 10:00:00 1 u1
dh2 d1 2 非小細胞肺癌 2023-07-03 12:00:00 2 u3

modifier_history テーブル:

id modifier_id history_version name operated_at operation_type operated_by
mh1 m1 1 の疑い 2023-07-01 10:00:00 1 u1
mh2 m1 2 の疑い 2023-07-02 11:00:00 3 u2
mh3 m2 1 2023-07-02 11:00:00 1 u2

disease_modifier_relation テーブル:

disease_revision_id modifier_history_id
dr1 mh1
dr2 mh3
dr3 mh3

各テーブルの記録を見ても、テーブルごとの責務が明らかになっていて、わかりやすい構造を導き出すことができたように思います。当初の目的である真正性も不足なく満たされています。

最後に、検討の成果を確認する意味も込めて、見読化のためのSQLを見てみます。この最終案では、ある時点(例えば2023-07-02 00:00:00現在)における、IDがd1のdisease集約を取得するSQLは次のようになります。

WITH latest_disease_revision AS (
    SELECT
        dr.*
    FROM
        disease_revision dr
    WHERE
        dr.disease_id = 'd1'
    AND dr.created_at <= '2023-07-02 00:00:00'
    ORDER BY
        dr.created_at DESC
    LIMIT 1
)
SELECT
    -- aggregate
    dr.disease_id,
    dr.revision,
    -- disease entity
    dh.name,
    dh.history_version,
    dh.operated_by,
    dh.operation_type,
    dh.operated_at,
    -- modifier entity
    mh.modifier_id,
    mh.name,
    mh.history_version,
    mh.operated_by,
    mh.operation_type,
    mh.operated_at
FROM
    latest_disease_revision dr
INNER JOIN
    disease_history dh ON dr.disease_history_id = dh.id
LEFT JOIN
    disease_modifier_relation dmr ON dr.id = dmr.disease_revision_id
LEFT JOIN
    modifier_history mh ON dmr.modifier_history_id = mh.id
WHERE
    dh.operation_type IN (1, 2) -- 1: CREATE / 2: UPDATE / 3: DELETE
;

かなりスッキリしました。爽快感すらあります笑

おわりに

書いていませんでしたが、今回の検討では、以下の要件を満たす設計を目指していました。いずれの要件も概ね満たす、満足のいくデータモデルになったのではないかと考えています*7

  • 親子関係をもつエンティティ(全体として見ると集約)において、過去のある時点における状態の完全な復元性を担保すること(旧情報の見読)
  • FHIRの監査ログ機構にマッピングできるモデルにすること
    • 前述のProvenanceとAuditEventとのマッピングリストを用意すればOK。AuditEventに相当する機能については今回は記事にしなかった
  • 多少の主観が混じるものの、ある時点における状態を復元するためのSQLが複雑になりすぎないこと
    • 構造が複雑化した場合に実行時のパフォーマンスが懸念されるが、インデックスを貼るなどの一般的な対処によって実用的なパフォーマンスに収められること
    • SQLを書くのが苦しくない、あるいは読むのが苦しくないこと。恣意的な基準だが、大量のサブクエリやwindow関数を使わずにすませたい
  • 永続化されている内容の把握に、プログラムによるパースやバリデーションを必要としない構造であること
    • 例えば、更新にあたって旧情報をJSON型で永続化した場合、そのデータはデシリアライズ時にはじめてパースエラーや型エラーが検出される、という体験になる。これは記録されたデータの解釈にアプリケーションをとおす必要があるということなので、デバッグやデータの通覧性の体験として劣るため、望ましくないと考えた
  • 物理削除をしない構造にすること
    • 論理削除をアンチパターンとする見解もあるものの、やはり物理削除は後戻りできない点のリスクを考慮すると選びにくく、論理削除による管理の方針とした
    • history以外のモデルの作成日時や操作者といったカラムは、要件に応じて削除することも検討できそうです

今回は履歴管理を中心に書きましたが、履歴管理ではない、操作ログやアクセスログとしての監査ログについても、別の記事で書ければと思っています。

参考

個人情報保護に役立つ監査証跡ガイド―あなたの病院の個人情報を守るために―(PDF直リンク)

*1:https://www.mhlw.go.jp/www1/houdou/1104/h0423-1_10.html

*2:https://www.mhlw.go.jp/content/10808000/001112044.pdf#page=50 『医療情報システムの安全管理に関するガイドライン 第 6.0 版 システム運用編』より、『14.3電子カルテデータの確定』

*3:https://hl7.org/fhir/R4/index.html

*4:https://hl7.org/fhir/http.html#history

*5:以降でER図と例を挙げての説明がありますが、わかりやすさを優先して型がいい加減なところがあります。

*6:operation_type .. 1 : 作成 / 2 : 更新 / 3 : 削除

*7:逆に、「対象エンティティのデータ構造の変更(カラムの追加や廃止、型変更)」といった点はあまり重視していませんでした。