# クライアントを信じない設計：オフライン同時編集の試合記録アプリで、整合性と認可をPostgreSQLに寄せる

> 電波の悪い球場で複数人が同じ試合を同時にスコアリングする——その整合性と多層的な認可を、WebSocketブロードキャストやクライアント側の出し分けに頼らず、決定的な冪等性キーとPostgreSQLの行レベルセキュリティ(RLS)でDB層に寄せた実装を、Supabase/Expo/Next.jsモノレポの実コードとともに解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: アーキテクチャ設計, TypeScript, PostgreSQL, Supabase, React Native, オフラインファースト, RLS, リアルタイム
- URL: https://tomodahinata.com/blog/untrusted-client-postgres-rls-offline-first

## 要点

- 設計の背骨は一貫して「クライアントを信じない」で、信頼境界をサーバーとDBの側に置く
- 同時編集の整合性は WebSocket ブロードキャストでなく、決定的な冪等性キー＋DBの一意制約＋オフライン永続キューで守る
- 認可はアプリのif文でなく PostgreSQL の RLS で強制し、全69テーブル・約280ポリシーで立場別アクセスを表現する
- 状態遷移を伴う操作は SECURITY DEFINER な RPC に閉じ込め、MFA 検証結果も JWT に焼き込んで DB で強制する
- 不正な状態は型と DB の CHECK制約・生成列で二重化し、pgTAP・型カバレッジ・スキーマdrift検出で退行を機械的に止める

---

アマチュア野球向けの「リアルタイム試合記録アプリ」を、ドメイン設計からモバイル・運営管理画面・DB・CI/CD・運用まで一人で作り、運用しています。モバイルアプリ（iOS / Android）と運営用Web管理画面、そして両者を支えるPostgreSQLからなる、Expo + Next.js + Supabase のモノレポです。

このプロダクトには、フロントエンドの華やかさとは別の「地味だが致命的」な難所が凝縮されていました。**複数人が同じ1試合を同時に記録しても破綻しないこと**。しかも**電波の悪い球場でオフラインになっても**です。さらに、選手・チーム管理者・スコアラー・スカウト・運営という立場ごとに、見える情報と操作できる機能を厳密に分ける**多層的な認可**が要求されました。

本記事は、この2つの難所——**同時編集の整合性**と**ゼロトラストな認可**——を、私がどう解いたかの設計記録です。結論を一言でいえば、設計の背骨は一貫して「**クライアントを信じない**」でした。

## 設計の背骨：「クライアントを信じない」

モバイルアプリは、ユーザーの手元で動き、改ざんもされ得ます。ネットワークは切れ、リクエストは重複し、順序は乱れます。だから私は、**信頼境界をアプリの外——サーバーとデータベースの側——に置く**ことを最初の原則にしました。

この原則は、2つの具体的な設計判断に落ちます。

1. **整合性は「楽観的なクライアント」ד厳格なDB制約」で守る。** クライアントは速く動いてよい。ただし真実はDBの一意制約とCHECK制約が決める。
2. **認可はアプリのif文ではなく、PostgreSQLの行レベルセキュリティ(RLS)で強制する。** UIが権限チェックを忘れても、DBが拒否する。

以下、それぞれを実コードで見ていきます。

## 1. 同時編集の整合性 — WebSocketに頼らず、冪等性キーで守る

### なぜブロードキャストにしなかったのか

「リアルタイム共同編集」と聞くと、まずWebSocketで全イベントを撒く設計を思い浮かべます。実際、Supabase RealtimeでもPostgresの変更を購読できます。しかし、1球ごとに高頻度で発生する入力をそのままブロードキャストする設計は、このプロダクトには合いませんでした。

- **コスト**: 全クライアントへ全イベントを配信する負荷とコストが、利用規模に対して見合わない。
- **障害時の脆さ**: 接続が切れた瞬間のイベントは欠落する。再接続後の整合をどう取るかが別問題として残る。
- **順序の問題**: 複数の書き手から届くイベントの順序保証は、配信層では本質的に難しい。

そこで私は、リアルタイム配信そのものを「主役」から降ろしました。**書き込みの正しさは冪等性キーとDB制約で保証し、他クライアントへの反映は短間隔の再取得（準リアルタイム）に委ねる**——この割り切りが、オフライン頻発という現実に最も強い構成でした。

### 決定的な冪等性キー（スロットキー）

鍵になるのが、**決定的（deterministic）な冪等性キー**です。打席や投球は「試合のどのスロット（位置）か」が決まれば一意に定まります。ランダムなUUIDではなく、スロットから機械的に算出するキーを採用しました。

```ts
// apps/mobile … scoringIdempotencyKeys.ts
export const atBatIdempotencyKey = (input: AtBatSlotKeyInput): string =>
  `game:${input.gameId}:inn:${input.inning}:${input.half}:seq:${input.sequenceInHalf}`;

export const pitchIdempotencyKey = (input: PitchKeyInput): string =>
  `ab:${input.atBatId}:pitch:${input.sequence}`;
```

ポイントは2つ。

- **ランダム性ゼロ**。同じスロットからは、何度計算しても同じキーが出る。だからリトライしても、別端末から計算しても、衝突せず**同じ論理行**を指す。
- **チーム非依存**。キー自体はチームを含まない。記録チームのスコープは、DB側の一意制約で表現する。

DB側の一意制約は「記録チーム × 冪等性キー」です。これにより、同一チームの複数スコアラーが同じスロットを同時に入力しても、行は1つに収束します。

```sql
-- 記録チームごとに idempotency_key を一意化（チーム内の重複を吸収）
create unique index at_bats_idem_team_key
  on public.at_bats (recording_team_id, idempotency_key);
```

### upsert と「自チームの行を返す」RPCフォールバック

書き込みは「INSERTか、すでにあるならその行を取る」というセマンティクスにしたい。素朴な「SELECTしてなければINSERT」は競合に弱いので、**`upsert` + `ignoreDuplicates`** を使い、衝突時だけ**自チームの行を解決するRPC**にフォールバックします。

```ts
// apps/mobile … useStartAtBat.ts（抜粋・簡略化）
const { data: inserted } = await supabase
  .from('at_bats')
  .upsert(
    {
      game_id: input.gameId,
      inning: input.inning,
      half: input.half,
      batter_id: input.batterId,
      pitcher_id: input.pitcherId,
      batting_order: input.battingOrder,
      sequence_in_half: input.sequenceInHalf,
      idempotency_key: idempotencyKey,
    },
    { onConflict: 'recording_team_id,idempotency_key', ignoreDuplicates: true },
  )
  .select('id')
  .maybeSingle();

if (inserted) {
  return { id: inserted.id };
}

// 競合パス: すでに同じスロットの行がある → 自チームの行をRPCで解決
const { data: existingId } = await supabase.rpc('resolve_own_at_bat_id', {
  p_game_id: input.gameId,
  p_idempotency_key: idempotencyKey,
});
```

なぜ「ただSELECTする」ではなく**RPC**なのか。`resolve_own_at_bat_id` は**権限スコープ付き**で、呼び出し元が属するチームの行だけを返すよう設計されています。クライアントに任意の行を引かせないことで、競合解決すらも信頼境界の内側（DB）に置けます。

### オフラインファーストな永続キューとドレインワーカー

球場ではオフラインが日常茶飯事です。だから書き込みは**端末ローカルに永続化してから**、バックグラウンドで安全に送信します。

設計はこうです。投球タップはまず**Zustandのストアに楽観反映**し、同時に**AsyncStorageに永続化したキュー**へ操作を積む。そして、**`pitches`テーブルへ書き込むのは「単一のドレインワーカー」だけ**にします。

```ts
/**
 * Durable-pitch drain worker — the SOLE writer of the `pitches` table.
 *
 *   - Single-flight gating（同じopを二重発火させない。サーバーの
 *     idempotency_key が第二の防衛線）
 *   - Self-healing at-bat open（スコアラーがスロットを進めても、
 *     アプリ再起動後でも、保留中の投球を独立してドレインできる）
 *   - Exponential backoff（成功で即ドレイン、失敗で次回をスケジュール）
 */
```

この3点が効きます。

- **single-flight制御**: 連打や再レンダーでローカルに二重発火させない。万一抜けても、サーバーの冪等性キーが第二の防衛線になる。
- **自己修復**: スコアラーが操作位置を先へ進めても、アプリを再起動しても、保留中の投球を「今のライブ状態とは独立に」流し込める。
- **指数バックオフ**: 失敗は責めず、間隔を空けて再送する。決定的キーのおかげで、再送は**何度繰り返しても安全**。

「楽観的に速いUI」と「壊れない永続化」を両立させる、オフラインファーストの定石を、冪等性キーが下支えしている構図です。

### クライアント間の反映：ミューテーション駆動のキャッシュ無効化

では他のスコアラーの画面にはどう伝わるのか。ここはTanStack Queryの**キャッシュ無効化 + 短間隔の再取得**で実現しています。書き込みが成功したら、依存するキャッシュだけを的確に無効化します。

```ts
// useRecordPitchEvent.ts（抜粋）
onSuccess: (_data, variables) => {
  // pitch_events に依存する2つのキャッシュだけを無効化する
  queryClient.invalidateQueries({ queryKey: gameStateQueryKey(variables.gameId) });
  queryClient.invalidateQueries({ queryKey: gameEarnedRunsQueryKey(variables.gameId) });
  queryClient.invalidateQueries({ queryKey: gamePitchEventCountsQueryKey(variables.gameId) });
},
```

クエリキーは階層的なファクトリ（`['games', gameId, サブリソース]`）に一元化し、無効化の取りこぼし・撒きすぎを防いでいます。読み取りには短いstaleTimeを設定し、変更後は速やかに再取得が走る。さらにDBの`game_states`には**`version`列**を持たせ、必要に応じて楽観ロック（compare-and-swap）も掛けられるようにしています。

```sql
create table public.game_states (
  ...
  current_inning smallint not null default 1 check (current_inning between 1 and 99),
  outs           smallint not null default 0 check (outs between 0 and 3),
  version        integer  not null default 0,  -- 楽観ロック用
  ...
);
```

これが「準リアルタイム」と正直に呼んでいる理由です。ミリ秒単位の双方向ストリームではなく、**「速く書ける」「壊れない」「数秒で揃う」**を選んだ。オフライン頻発という制約下では、これが最も堅牢でした。

## 2. 認可をPostgreSQLに寄せる — ゼロトラストRLS

### なぜアプリ側の出し分けに頼らないのか

認可をUIやAPI層のif文で実装すると、画面が増え、ロールが増えるほど、チェック漏れの確率が上がります。漏れた1か所が情報漏洩になります。

そこで私は、認可を**PostgreSQLの行レベルセキュリティ(RLS)**でDB層に強制しました。アプリがうっかりチェックを忘れても、DBが行を返さない・書かせない。**公開スキーマの全69テーブルでRLSを有効化し、約280のポリシー**で立場別アクセスを表現しています（カバレッジ100%）。

### RLSとヘルパー関数

ポリシーは「この行を、この役割の、この条件のユーザーだけが見られる/書ける」を宣言的に書きます。チームスコープや期限の判定は、再利用可能なヘルパー関数（`is_team_admin` など）に切り出しています。

```sql
-- 自チームの game_requests だけを SELECT 可能にする
create policy game_requests_select_requester on public.game_requests
  for select to authenticated
  using (
    exists (
      select 1 from public.team_members tm
      where tm.team_id = requester_team_id
        and tm.user_id = auth.uid()
        and tm.left_at is null
        and (tm.expires_at is null or tm.expires_at > now())  -- 期限切れは除外
    )
  );
```

監査ログのような機微テーブルは、`authenticated`にはSELECTすら**restrictiveポリシーで全面拒否**し、`service_role`だけが触れるようにしています。「見えてはいけないものは、そもそも返さない」を徹底します。

### 期限付き権限（time-bound）

このドメインには「一時的に与える権限」が多くあります。スコアラー権限、MFAセッション、対戦申請、招待——いずれも有効期限を持ちます。

```sql
create table public.team_members (
  ...
  role       public.team_role not null,
  left_at    timestamptz,
  expires_at timestamptz,
  -- expires_at を持てるのは scorer ロールだけ、という不変条件
  constraint team_members_expires_at_scorer_only
    check (expires_at is null or role = 'scorer')
);
```

「アクティブなメンバー」の判定は、`left_at is null and (expires_at is null or expires_at > now())` という述語で各ポリシーに一貫して織り込まれます。**期限はビジネスルールではなく、データの不変条件として**扱う——これがゼロトラストの肝です。

### スカウト開示カーネル：request → grant → audit

最も神経を使ったのが、**スカウトへの選手情報開示**です。個人情報を扱うため、「誰が・どの項目を・誰の承認で見られるのか」を厳密に管理し、かつ**監査に耐える**必要がありました。

設計は3つのテーブルの協調です。

| テーブル | 役割 |
| --- | --- |
| `scout_disclosure_requests` | スカウトが「この項目を見たい」と要求する（状態機械: pending → accepted / declined / withdrawn） |
| `scout_disclosure_grants` | 実際に開示される項目を保持。**選手の承認 ∩ チーム管理者の承認**の積集合だけが有効 |
| `scout_disclosure_audit` | 追記専用の監査ログ（個人情報保護法に配慮）。`authenticated`には全面非公開 |

開示対象は `name` / `position` / `physical` / `handedness` / `grade` の5項目。重要なのは、**開示は「選手が承認した項目」と「チーム管理者が承認した項目」の積集合**でしか成立しないこと。片方が承認しても、もう片方が承認しなければ開示されません。すべてのDMLはSECURITY DEFINERなRPC経由に限定し、`authenticated`の直接INSERT/UPDATEは拒否しています。選手がチームを移籍すると、トリガが開示ポリシーを自動的にリセットし、その事実も監査ログに残ります。

### SECURITY DEFINER と search_path 固定

RLSは「行が見えるか」を守りますが、状態遷移を伴う操作（承認・受諾・冪等な作成）は**RPC**で表現し、関数の入口で認可を検証します。Supabase/PostgreSQLでの定石は、`SECURITY DEFINER` + `search_path` 固定です。

```sql
create or replace function public.accept_game_request(p_request_id uuid)
returns public.games
language plpgsql security definer
set search_path = public          -- search_path インジェクション対策
as $$
declare
  v_caller  uuid := auth.uid();
  v_request public.game_requests;
begin
  if v_caller is null then
    raise exception '認証が必要です' using errcode = '28000';
  end if;

  select * into v_request from public.game_requests
   where id = p_request_id
   for update;                     -- 行ロックで競合を直列化

  -- 認可: 相手チームの admin のみが承認できる
  if not public.is_team_admin(v_request.requested_team_id) then
    raise exception '相手チームの admin のみ承認できます' using errcode = '42501';
  end if;

  -- 冪等: すでに accepted なら同じ game を返す（再実行に強い）
  if v_request.status = 'accepted' and v_request.accepted_game_id is not null then
    return (select g from public.games g
              where g.id = v_request.accepted_game_id and g.deleted_at is null);
  end if;

  -- 期限切れ・不正な状態遷移を拒否し、ここから atomically に games を作る …
end
$$;
```

`for update` の行ロックで競合を直列化し、状態機械の検証と冪等な再実行（accepted なら同じ結果を返す）を関数内に閉じ込めています。RLSが「データの可視性」を、RPCが「操作の正しさ」を担う、役割分担です。

### MFAをDBで強制する

機微な操作はMFA（メールOTP）でゲートします。ここで重要なのは、**MFAの検証結果をJWTのクレームに焼き込み、DB側で強制する**こと。クライアントの「MFA済みフラグ」を信じないためです。

カスタムアクセストークンフックが、セッションの検証状態を見て `app_metadata.mfa_verified` をJWTに注入します。

```sql
-- custom_access_token_hook（抜粋）: セッションが MFA 検証済みかを JWT に入れる
v_session_id := nullif(v_claims->>'session_id', '')::uuid;
if v_session_id is not null then
  select exists (
    select 1 from public.mfa_verified_sessions s
     where s.session_id = v_session_id
       and s.expires_at > now()          -- 検証には有効期限がある
  ) into v_mfa;
end if;
v_app_metadata := jsonb_set(v_app_metadata, '{mfa_verified}', to_jsonb(v_mfa));
```

そして、機微なRPCは入口で `require_mfa()` を呼びます。

```sql
create or replace function public.require_mfa()
  returns void language plpgsql stable as $$
begin
  if not public.session_is_mfa_verified() then
    raise exception 'MFA verification required'
      using errcode = '42501', hint = 'MFA_REQUIRED';
  end if;
end
$$;
```

クライアント側は、OTP検証に成功した直後に `supabase.auth.refreshSession()` を呼び、`mfa_verified=true` を載せたJWTを再発行させます。**「MFAしたつもり」をアプリが主張するのではなく、DBが事実として確認する**——これがゼロトラストの徹底です。

## 3. ドメインの正当性を型とDBで二重化する

整合性と認可を固めても、**ドメインのモデルが間違っていれば**意味がありません。野球のスコアリングは状態遷移が複雑です。不正な状態は、型とDB制約で「そもそも作れない」ようにします。

### Zod enum と「出塁理由」の直交

打席結果・球種・出塁理由などは、`packages/domain`のZodスキーマで一元定義し、モバイル・管理画面・Edge Functionで共有します。設計上の工夫は、**「打席結果」と「出塁理由」を直交させた**ことです。

```ts
// packages/domain … scoring.ts / enums.ts（抜粋）
export const AtBatResult = z.enum([
  'single', 'double', 'triple', 'home_run',
  'strikeout_swinging', 'strikeout_called',
  'groundout', 'flyout', 'lineout', 'popout',
  'walk', 'intentional_walk', 'hit_by_pitch',
  'sacrifice_bunt', 'sacrifice_fly',
  'fielders_choice', 'double_play', 'triple_play',
  'dropped_third_strike', 'catcher_interference',
  // …
]);

export const OnBaseReason = z.enum([
  'out', 'hit', 'walk', 'intentional_walk', 'hit_by_pitch',
  'fielding_error', 'dropped_third_strike', 'sacrifice',
  'fielders_choice', 'catcher_interference', 'interference',
]);
```

たとえば「ゴロだが、フィールダーズチョイスで打者は生きた」のような状況は、`result: 'groundout'` × `on_base_reason: 'fielders_choice'` の組み合わせで自然に表現できます。1つのenumに無理やり詰め込むと、ありえない値や曖昧な値が生まれます。**直交させることで、表現力と正しさを同時に得る**のです。

### CHECK制約と生成列

型はコンパイル時の防壁ですが、DBは最後の砦です。同じ不変条件を**DBのCHECK制約でも二重化**します。アウト数は0〜3、イニングは1〜99、打順は1〜9、投球コースは正規化座標で-1.0〜1.0——いずれもDBが拒否します。

```sql
create table public.at_bats (
  ...
  batting_order      smallint not null check (batting_order between 1 and 9),
  idempotency_key    text not null,
  -- 派生値はアプリで計算させず、DBの生成列に閉じ込める（単一真実源）
  is_hit             boolean generated always as (public.at_bat_is_hit(result)) stored,
  is_official_at_bat boolean generated always as (public.at_bat_is_official(result)) stored,
  ...
);

create table public.pitches (
  ...
  location_x real not null check (location_x between -1.0 and 1.0),
  location_y real not null check (location_y between -1.0 and 1.0),
  ...
);
```

「ヒットか」「正式な打数か」といった派生値は、アプリで計算させず**生成列**にしました。集計とUIで計算式がズレる事故を、構造的に防ぎます。プロジェクト全体ではこうしたCHECK制約が**約349個**あり、不正な状態を「データとして存在できない」ものにしています。

### TS と SQL で共有する純粋関数（DRY）

ドメインのルールは、TSとSQLの両方に現れます。たとえば「この守備プレーが刺殺（putout）を生むか」。これをアプリと集計でそれぞれ実装すると、必ずズレます。そこで**ルールを純粋関数に集約**し、TS側とSQL側で同じ定義を共有しました。

```ts
// 刺殺成立条件: 捕球者が居て、かつ捕球者本人が捕球失策をしていない
export function playEarnsPutout(play: DefensivePlay): boolean {
  return play.catcherPosition !== null && !catcherCommittedFieldingError(play);
}
```

この関数は、守備記録エディタの「アウト行 / エラー行」分類、ウィザードの「行数 = アウト数」整合、そしてDB側の集計が共有する**単一真実源**です。DRYは「コードを短くする技」ではなく、**「真実をひとつにする規律」**だと考えています。

## 4. 退行を機械的に防ぐ — pgTAP・型カバレッジ・スキーマdrift

一人で開発・運用するからこそ、**退行を人間の注意力に頼らない**ことが死活問題でした。CIに「壊れたら止まる」仕組みを大量に仕込んでいます。

### pgTAPでRLSをテストする

RLSは強力ですが、**書いただけでは正しさを保証しません**。だから認可ポリシーには必ずpgTAPテストを伴わせるルールにしました。`set local request.jwt.claims` で立場を切り替え、**許可されるケースと拒否されるケースの両方**を検証します。

```sql
select plan(4);

-- 1. requester admin は自分の game_requests を SELECT できる
select is(
  (select count(*)::int from public.game_requests
     where requester_team_id = '…' and requested_team_id = '…'),
  1,
  'requester admin can SELECT their own game_requests'
);

-- 2. 無関係なチームの admin は見えない
set local request.jwt.claims = '{"sub":"…","role":"authenticated"}';
select is(
  (select count(*)::int from public.game_requests where …),
  0,
  'unrelated team admin cannot see game_requests'
);

-- 3. authenticated の直接 INSERT は拒否される（42501 / GRANT なし）
select throws_ok(
  $$ insert into public.game_requests (…) values (…) $$,
  '42501', null,
  'authenticated cannot directly INSERT game_requests'
);

select finish();
```

DB層だけで**243本のpgTAPテストファイル**が、RLS・整合性・状態遷移・移籍時のリセットといった「壊れたら気づきにくい」挙動を常時検証します。アプリ層（Jest / Vitest / fast-checkによるプロパティテスト）と合わせると、テストファイルは**約1,200本**規模になります。

### 型カバレッジをCIで強制する

TypeScriptは`strict`に加え、`noUncheckedIndexedAccess` や `exactOptionalPropertyTypes` まで有効化したベース設定を全パッケージで共有しています（Next.js都合で一部だけ緩めるなど、緩和は明示的に・最小限に）。さらに、`any`の侵食を**型カバレッジ閾値**でCIに守らせています。

```jsonc
// 例: apps/admin/package.json
"typeCoverage": { "atLeast": 99.5, "strict": true, "detail": false }
```

閾値はパッケージごとに**96.7%〜100%**。共有ドメインやUIは100%、アプリ側もほぼ全域が型付けされている状態を、機械的に維持しています。

### 「マージ済みなのに未デプロイ」を捕まえる

運用で一度ヒヤリとしたのが、**「mainにマージされたのに、本番DBに反映されていない」**という状態です。マイグレーションが古いまま、アプリだけ進む——気づきにくく、危険です。

そこで、CIに**スキーマdrift監視**を追加しました。毎日、ローカルのマイグレーション/Edge Functionと、デプロイ済みリモートの同期を検査します。

```yaml
# supabase-drift-watch.yml — 「マージ済みなのに未デプロイ」を毎日検知する安全網
# （デプロイワークフロー自体が "実行されない" 故障モードを捕まえる）
- run: node scripts/check-supabase-drift.mjs --strict
       --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}
```

このスクリプトは、マイグレーションの並びとEdge Functionのデプロイ状態を突き合わせ、ローカルのみ／リモートのみの差分を検出して終了コードで知らせます（0=同期 / 1=drift / 2=環境不全）。マイグレーションの破壊的変更は別途**squawk**で静的検査し、全テーブルがRLS有効かつ少なくとも1ポリシーを持つことも検証します。CIワークフローは全部で**11本**——型・Lint・型カバレッジ・pgTAP・RLSカバレッジ・a11yポリシー・drift検出・OTA配信までを担います。

## 5. 可観測性とプライバシー — PIIスクラバ

本番の異常は検知したい。でも、個人情報をログに流したくない。この2つは両立できます。Sentry（モバイル / 管理画面）へ送る前に、**7階層のPIIスクラバ**を必ず通します。

```ts
// packages/observability … scrubPii.ts（抜粋）
// メールはローカル部を完全マスク、ドメインだけ残す（運用時の切り分け用）
export function obfuscateEmail(value: string): string {
  const at = value.indexOf('@');
  if (at <= 0 || at === value.length - 1) return value;
  const domain = value.slice(at + 1);
  return `[REDACTED]@${domain}`;
}
```

スクラバは、トークン（`Authorization` / `*_token` / Bearer・JWTらしき値）、IP、電話番号、メール、ユーザーID（先頭8文字 + `…`）を、**キー名と値の両方**から検出してマスクします。Sentryの`beforeSend`で全イベントに適用するので、付け忘れが起きません。

```ts
// 管理画面側 beforeSend: 全イベントの extra / tags / user.id をスクラブ
export function buildBeforeSend(): (event: SentryEvent) => SentryEvent {
  return (event) => {
    if (event.extra) event.extra = scrubPii(event.extra);
    if (event.tags) event.tags = scrubPii(event.tags);
    if (event.user && typeof event.user.id === 'string') {
      event.user = { ...event.user, id: obfuscateUserId(event.user.id) };
    }
    return event;
  };
}
```

「メールのローカル部は残さない（ドメインだけ残す）」のような判断は、過去の監査で**“短いローカル部 + 企業ドメイン”だと個人が特定され得る**と気づいたことに基づいています。可観測性とプライバシーのトレードオフは、設計の意図としてコードに残してあります。

## まとめ：この設計が効いた理由とトレードオフ

このプロダクトで私が一貫して守ったのは、**「クライアントを信じない」**という一点でした。それが、整合性と認可という2つの難所に、それぞれ具体的な形で効きました。

- **整合性**: WebSocketブロードキャストではなく、**決定的な冪等性キー + DBの一意制約 + オフライン永続キュー**。速いUIと壊れない永続化を、再送に強い形で両立した。
- **認可**: アプリのif文ではなく、**PostgreSQLのRLS + SECURITY DEFINERなRPC + DBで強制するMFA**。69テーブル・280ポリシーで、立場・チーム・期限・項目別開示までを宣言的に表現した。
- **正当性**: 型とDB制約の二重化、生成列、TS/SQL共有の純粋関数で、不正な状態を「作れない」ものにした。
- **持続性**: pgTAP・型カバレッジ・スキーマdrift検出で、一人でも退行を機械的に止める。

もちろんトレードオフはあります。RLSへ認可を寄せると、ポリシーとRPCの設計・テストに前払いの設計コストがかかります。準リアルタイムは、ミリ秒単位の双方向同期が要る用途には向きません。しかし、**「オフライン頻発・多層認可・単独運用」**という制約の下では、この設計が最も堅牢で、最も安く、最も壊れにくい——そう確信しています。

「リアルタイム共同編集」の本当の難しさは、UIではなく、**信頼境界をどこに引くか**にあります。クライアントを信じない、と決めるだけで、設計は驚くほど素直になります。

---

同種の「複数人同時編集」「ゼロトラストな認可」「オフラインファースト」「モバイル + 管理画面の一気通貫」といった要件の設計・実装・運用について相談したい方は、お気軽にご連絡ください。要件定義からインフラ・運用まで、一人でワンストップに対応します。
