Appearance
退職者データの保持と消去
2026-08-27 実装。関連コード: packages/db/src/queries/erasure.ts(消去の実体)、 apps/api/src/lib/data-retention.ts(保持期間の暦計算)、 apps/api/src/routes/members.ts(POST /members/:id/erase)、 apps/api/src/routes/settings/privacy.ts(GET/PUT /settings/data-retention)、 packages/authz/src/catalog.ts(member.erase)。
1. なぜこの機能に設計ドキュメントが要るのか
退職者のデータは、2つの法律が正反対のことを要求しているほぼ唯一の領域である。 どちらか片方だけを見て実装すると、必ずもう片方に違反する。
| 法令 | 求めること | 性質 |
|---|---|---|
| 労働基準法109条 | 賃金台帳・出勤簿等の記録を、最後の記載日から5年間保存しなければならない。ただし令和2年改正の附則143条2項により当分の間3年間で足りる | 義務(違反は罰則の対象) |
| 個人情報保護法22条 | 利用する必要がなくなった個人データは遅滞なく消去するよう努めなければならない | 努力義務 |
素朴な実装は2通りあり、どちらも間違っている。
- 退職時に users 行を DELETE する — 労基法109条違反。しかも
punch_events.user_id等の 外部キーが壊れるので、そもそも DB が受け付けない。カスケード削除にすれば通るが、 それは法定帳簿の基礎資料を消す操作そのものである。 - 何もしない(永久に保持する) — 個情法22条の努力義務を初期設定で放棄することになる。 「消せる機能が無い」は「消さないことにした」ではなく「判断していない」であり、 コンプライアンス製品としては前者より悪い。
KIZAMI の答えは 「保持期間の経過後に、行は残したまま匿名化する」。以下その定義。
2. 用語 — 「消去」は行削除ではない
本ドキュメントおよび実装・UI文言における 消去(erase) は、次を指す:
保存義務の対象である記録の行は1件も削除せず、その行から 「誰の記録か」を特定できる情報を取り除く操作。
UI では「削除」と書かない(apps/web/src/lib/i18n/ja.ts の members.erase* のコメント参照)。 「削除しました」と表示したのに行が残っていると、後日の開示請求に対応する担当者が 事実と異なる説明をしてしまう。
3. 線引き — 何を消し、何を残すか
packages/db/src/queries/erasure.ts の eraseUserPersonalData() が行う内容。
3.1 匿名化(行を残して値を置き換える)
| テーブル・列 | 消去後の値 | 残す理由 |
|---|---|---|
users.name | 削除済みユーザー(固定文字列) | punch_events 等が FK で参照している。行を消すと勤怠記録ごと壊れる |
users.email | user_deleted_<id>@invalid | 同上。.invalid は RFC 2606 の予約 TLD なので実在せず、この宛先へメールは飛ばない |
users.hire_date | 変更しない | 雇入年月日は労働者名簿(労基法107条)の記載事項。氏名が匿名化された時点で個人は特定できない |
users.erased_at | 消去時刻を記録 | 二重実行の検出と、再有効化の禁止に使う |
3.2 物理削除(行ごと消す)
いずれも労基法109条の保存義務の対象ではなく、残す理由が一切ないもの。
| テーブル | 内容 |
|---|---|
auth_credentials | パスワードハッシュ |
sessions | ログインセッション |
user_totp / user_totp_recovery_codes | 二要素認証の共有鍵・リカバリコード |
push_subscriptions | ブラウザ発行の端末固有 endpoint・User-Agent |
user_notification_settings | 本人のメールアドレス・Webhook URL(私物の連絡先になりうる) |
api_keys | 「その人として打刻できる」資格情報(user_id が本人のもののみ) |
invitations / password_reset_tokens | 発行済みトークンのハッシュ |
slack_user_links / slack_link_tokens | 外部サービス上の識別子(Slack ユーザーID) |
notifications | 本人宛の通知。本文に氏名・申請内容が入る。本人はもうログインできず読めない |
3.3 列の null 化(行を残して列だけ消す)
| テーブル・列 | 理由 |
|---|---|
punch_events.meta_ip / meta_ua / meta_gps_lat / meta_gps_lng | 打刻の時刻は賃金台帳の基礎資料だが、「どのIP・どの端末・どの座標から打刻したか」は保存義務の対象ではない。packages/db/src/schema/punches.ts が GPS 保持期間について既に明示している「保持期間経過後は null 化(行は消さない)」と同じ性質の操作 |
punch_events は追記専用テーブルだが、これは例外として UPDATE を発行する。 supersedes_id の連鎖(=「有効な打刻」の判定)に一切触れないため、追記専用の不変条件は保たれる。
3.4 一切触れないもの
| 対象 | 理由 |
|---|---|
audit_logs | 不可変原則。行数・action・target・occurred_at は変えない。表示上の actor 名は listAuditLogs が users を JOIN して解決しているため、users 行の匿名化が自動的に監査ログの表示名にも及ぶ(コード: packages/db/src/queries/audit.ts) |
punch_events の時刻・種別・supersedes_id | 保存義務の対象そのもの |
closing_snapshots / closing_events | 締めた月の数字は動かない(締めの不変性は KIZAMI の中核的な約束) |
leave_grants / leave_requests / correction_requests / auto_break_waivers / memberships | 個人を指すのは不透明な UUID だけであり、users 行の匿名化と同時に特定可能性が失われる |
3.5 既知の残留(判断点)
完全な消去ではないことを明示しておく。次の2つは意図的に残している。
- 自由記述の理由文 —
correction_requests.reason/leave_requests.reason/auto_break_waivers.reasonには従業員が書いた事情が入りうる。これらは 「なぜその修正・その休暇を承認したか」という監査の連鎖の一部であり、消すと 承認の根拠が失われる。運用側は、自由記述欄に機微情報を書かない運用を案内することが望ましい - 他人宛の通知の本文 — 承認依頼通知などは本文に対象者の氏名が埋め込まれている (「〇〇さんの休暇申請が承認待ちです」)。消去するのは本人宛の通知だけで、 承認者など他人の受信箱に残ったものは触らない。他人の受信箱を書き換える操作は その人にとって不可解な変化であり、消去の副作用として行うべきではないと判断した (通知は一時的な UI 要素であり、時間の経過とともに読み流されて実務上の参照価値を失う)
どちらもプライバシー通知の雛形には書いていない — 雛形が扱うのは「何を消すか」であり、 これらは製品の内部的な限界として本ドキュメントに記録するに留める。
4. 実行できる条件
POST /members/:id/erase は次の順で判定する。すべて 409 を返す(400 ではない — 入力の誤りではなく 対象の状態が条件を満たしていない)。
member.erase権限(テナント全体スコープ)。無ければ 403- 対象が同一テナントに存在すること。無ければ 404(他テナントの ID でも 404 — 存在を漏らさない)
- 自分自身でないこと。
cannot_erase_self - 未消去であること。
already_erased - 退職処理済み(
is_active = false)であること。not_deactivated - 退職日が記録されていること。
deactivated_at_unknown - 退職日 + 保持年数が経過していること。
retention_period_active(レスポンス body にretention: { deactivatedDate, erasableFrom, remainingDays, retentionYears })
4.1 保持期間の暦計算(apps/api/src/lib/data-retention.ts)
誤差が出るなら長く保持する方向でなければならない(短い方向に外すと保存義務違反)。 そのため次の3点を保守側へ倒している。
- 暦で加算する(365日 × N ではない)。閏日を含む期間で1日早く消せてしまうのを避ける
- 2月29日退職は、存在しない記念日を手前(2月28日)へ丸めず翌日側へ送る
- 経過「後」の日から可能とする。退職日 + N年 の翌日以降(境界日そのものはまだ期間内)
起算日に「退職処理を行った日」(users.deactivated_at)を使う正当性: 労基法109条の起算日は「最後の記載日」だが、退職処理は最終出勤日以降に行われるため、 deactivated_at >= 最後の記載日 が常に成り立つ。つまりこの起算はどちらへズレても 義務期間より長く保持する方向にしか倒れない。
4.2 冪等ではなく 409 で弾く理由
2回目の実行を冪等に 200 で返すと、「消したつもりの相手が実は別人だった」ときに気づけない。 取り返しのつかない操作は「もう終わっている」ことを明示的に伝えるほうが安全。
5. 終端状態としての「消去済み」
erased は deactivated(無効化)とは別の終端状態である。
| 状態 | is_active | deactivated_at | erased_at | 再有効化 |
|---|---|---|---|---|
| 在籍中 | true | null | null | ― |
| 退職(無効化) | false | 退職日 | null | できる |
| 消去済み | false | 退職日 | 消去日 | できない(409 already_erased) |
消去済みを再有効化できてはいけない理由: 氏名・メール・認証情報は既に失われており戻す先が無い。 tombstone のまま復活させると「削除済みユーザー」という名前でログインできる人ができてしまう。
再有効化(復職)は deactivated_at を null に戻す。復職した人に退職日は無く、 値を残したままにするとその人がいつまでも「消去可能になった退職者」の一覧に現れてしまう。
6. テナント設定 — 保持年数
tenants.personal_data_retention_years(3 または 5、既定 5)。 GET/PUT /settings/data-retention(権限は個人情報まわりの設定と同じ = notification.settings.manage の転用)。
- 既定を5年にする理由: 5年が労基法109条の原則で、3年は附則143条2項の「当分の間」の 経過措置にすぎない。既定を3にすると、経過措置が終了したときに設定を触っていない全テナントが 一斉に違反側へ倒れる。既定は常に原則側に置く
- 3 / 5 以外を受け付けない理由: 任意の年数を許すと「1年で消せる」設定ができてしまい、 保存義務違反を製品が手伝うことになる
- 版管理(
tenant_setting_versions)にしない理由: この値は打刻の集計に一切影響しない (原則6「計算に影響する設定は版で持つ」の対象外)。加えて版管理にすると 「消去を実行した日に有効だった版」と「退職日に有効だった版」のどちらで判定するのかという 答えの無い問いが生まれる。保存義務は「今この瞬間、この記録を消してよいか」の判断であり、 常に現在値で評価するのが正しい
7. 権限 — なぜ member.deactivate の流用ではないのか
新規カタログ項目 member.erase(「退職者の個人データを消去できる」、テナント全体スコープのみ、 危険フラグあり)を新設した。
2FA リセットのときは「他人のログイン要件を一段弱める操作は退職処理と同格」として member.deactivate を流用した(two-factor-auth.md)。消去は同格ではなく 一段重い:
- 無効化は取り消せる(再有効化がある)。消去には戻す経路が製品にも DB にも存在しない
- 退職処理を任せられる担当者と、個人データを不可逆に消してよい担当者は同じとは限らない
スコープをテナント全体のみにした理由: 部署スコープを許すと「自部署の退職者だけ消せる部署長」が 生まれるが、消去可否の判定材料(保持期間・法定帳簿の保存義務)は全社で1つであり、部署ごとに 判断が分かれる余地が無い。個人情報保護法上の管理責任も事業者単位で負う。
同梱プリセットでは「管理者」のみに付与する(apps/api/src/lib/tenant-bootstrap.ts の ADMIN_GRANTS)。 既存テナントへは syncSystemPresetGrants() が伝播する。
8. UI
8.1 メンバー管理画面(/settings/members)
- 退職者の行に退職日と保持状況(「消去可能まであと N 日(YYYY-MM-DD から)」/ 「個人データを消去できます」)を出す。これが「消去可能になった退職者」の一覧性を担う
- 「個人データを消去」ボタンは 退職処理済み × 未消去 × 保持期間経過済み の行にだけ出す。 押せないボタンを出して 409 を返させるより、出さないほうが誤解が少ない
- 確認ダイアログでは、影響の列挙に加えて 対象者氏名の再入力を求める (
ConfirmDialogのconfirmPhrase、2026-08-27 新設)。判断点: 既存の確認ダイアログは ボタン1つで実行できるが、消去は元に戻せず、かつ対象を取り違えても気づけない (押した瞬間に氏名が消えるので、後から「誰を消したか」を目で確かめられない)。 逆に、取り消せる操作(無効化・招待の取り消し)にこれを付けると単なる摩擦なので付けない
8.2 日次ワーカーによる通知はしない(判断点)
締め忘れ・打刻忘れのようなリマインドは行わない。消去は期限のある義務ではなく 「もう消してよい」という状態にすぎず、急かすと誤操作(取り返しがつかない)を誘う。 担当者が個人情報の棚卸しを行うときに一覧で見つけられれば十分である。
9. プライバシー通知の雛形への反映
@kizami/privacy-template の buildPrivacyNotice() に「退職後の取り扱い」節を追加した。 テナント設定の保持年数(3 or 5)が文中に入る。
従業員向けの通知でこの点を黙っていると「退職したのになぜ記録が残っているのか」という 当然の疑問に答えられない。保存義務があるから残すことと、期間の経過後に何を消すかの 両方を明記する。ヘルプ側にも同じ内容を privacy.retention-after-leaving(origin: law)で置いている。
10. 移行(既存の退職者)
この機能より前に無効化されたユーザーは deactivated_at が null であり、消去できない (409 deactivated_at_unknown)。起算日が分からないものを「消してよい」と答えてはいけないため、 推測で埋めることはしない(監査ログから復元する手もあるが、監査ログは業務ロジックの入力に すべきデータではない — 閲覧に audit_log.view が要る一方、消去可否は誰の目にも同じ答えで なければならない)。
必要な場合の手順は 再有効化 → あらためて退職処理。その時点が起算日になるので、 実際の退職日より後ろにずれる = 義務期間より長く保持する方向であり、法令上は安全側に倒れる。
11. スコープ外
テナント自体の削除は本ドキュメントの対象外。セルフホストの現在の配備形態では、テナントの 廃止は DB ごと破棄する運用管理の作業であり、製品機能として提供していない。 KIZAMI を SaaS として提供するフェーズ(解約フローと退去期間の設計が必要になる)で扱う。