v1
PoC / 設計方針

minedia-www 変更点

インタビュールーム(WebRTC 機能)を新基盤(core)へ切り出したあと、minedia-www(調査ドメイン)側で何がどう変わるか。GitHub Issue #5317。

3 行サマリ

① minedia-www 側で何が変わる?

Room・録画・文字起こし・加工動画の正本が core へ移る。www には薄い参照テーブルを 4 つ新設し、既存 3 モデルは FK を uuid 参照に繋ぎ直す。旧 Room/OtArchive 系は read-only 化 → 廃止。

エンティティの変化 →

② 新基盤との結合点は?

www → core の REST(命令・取次ぎ・参照)と core → www の Webhook 5 種の 2 方向だけ。core 側に www 専用の公開 API は追加しない。

結合点 →

③ 追加で必要な開発は?

4 フェーズ。Phase 0(統一 IF)と Phase 1(スキーマ)は core 非依存で今すぐ着手可。Phase 2 は core の OpenAPI 公開待ち、Phase 3 のみ core 稼働待ち。

実装フロー →

全体像 — 何が core へ移り、何が残るか

core へ移るもの

Room / Session / Recording / Transcription / 加工動画(ProcessedRecording) / 入退室・チャットログ

WebRTC 接続・録画・文字起こし・加工動画・ルーム内ログは、新基盤が単一の事実根拠(source of truth)を持つ。

minedia-www は uuid 参照と REST API / Webhook で受ける。

minedia-www に残るもの

調査ドメイン:オファー / 確定 / 出欠 / 謝礼 / 配信 / 振り返り画面 / 加工依頼管理 / 録画の外部共有

調査業務ロジックと、オペレーター / 従業員向けの管理画面(振り返り)は残す。録画・ログは core API から取得し、同一 UI で描画する。

個人情報保護加工の依頼管理(Slack 依頼・人手待ち)と録画の外部共有(OtArchiveAccess)も、調査側の関心事として残す。

① エンティティの変化

変化は 3 パターンに畳める。

  • 移管 外部化テーブルは「core の正本」と「www の薄い参照」に分かれる(4 組)。ログ類はコピーを持たず都度取得。
  • 変更 www に残る事後処理モデルは、物理 FK belongs_to :ot_archiverecording_uuid(文字列参照)へ繋ぎ直す。
  • 新設 前身のない同期基盤(outbox・Webhook 冪等受信・エンジン切替フラグ)を足す。
エンティティ変化マップ — 外部化テーブルは「core の正本」と「www の薄い参照」に分かれる 事実(source of truth)は常に core。minedia-www が持つのは表示・集計・検索のための薄いコピーだけ。 現在の minedia-www(外部化対象) core(新基盤)= 事実の正本 minedia-www 新設 = 薄い参照・コピー Room / OtSession WebRTC ルーム・入室トークン Session / Room 予約枠と WebRTC ルーム interview_room_refs アンカー:Slot に 1:1・uuid と同期状態 正本を移管 core_session_uuid / core_room_uuid OtArchive 録画ファイル・メタ Recording 録画ファイル・署名 URL 払い出し recording_refs 録画 1 行=1 レコードのメタ(一覧・月次集計) recording_uuid TranscriptionRequest (+analyses) 文字起こし・全文検索 Transcription 文字起こし(VTT 正本・非公開) interview_transcripts 本文コピー(横断検索・字幕DL・AI 入力) transcription_uuid ProcessedOtArchive 加工動画+保護加工の依頼管理(混載) ProcessedRecording 加工動画(トリム/手動アップロード) recording_processing_requests 保護加工の依頼管理(Slack 依頼〜完了) processed_recording_uuid RoomChatMessage / RoomEntryHistory PlaybackEvent チャット・入退室・資料操作ログ chat_messages / entry_logs room_events ルーム内ログ一式 ローカルコピーなし 振り返り画面が core API を都度取得(on demand) GET(都度取得・保存しない) 残留 — FK の繋ぎ直しのみ(www に残る既存モデル) StatementSummaryRequest / OtArchiveSummary / OtArchiveAccess belongs_to :ot_archive(物理 FK)→ recording_uuid(文字列参照)へ。役割・画面は不変 新設 — 前身なし(同期基盤) core_outbox / core_webhook_events / projects.webrtc_engine 命令の非同期リトライ / Webhook 冪等受信 / エンジン切替フラグ(default: opentok) core へ移管(read-only 化 → 廃止) core の正本 www 新設テーブル 既存モデル変更 正本の移管 uuid 参照(API / Webhook で同期)

図 1:エンティティ変化マップ。行が対応関係(左の 1 テーブル → core 正本 + www 薄い参照)。

新設テーブルは 5 つ。Project 系の中心は 01 のアンカー、即席は 02 の実体テーブルが同じ役を担い、03〜05 は core uuid を介してこの 2 つにぶら下がる。

01
アンカーモデル — すべての参照の起点 interview_room_refs別名 LivekitRoom・薄いローカル参照モデル
新設

外部化 Slot ごとに 1 行。「統一 IF のアダプタ先」「一覧用キャッシュ」「詳細 API 用 uuid の保持先」「Webhook の更新先」の 4 役を兼ねる。

Slot
id調査側スロット
…既存列無変更
1 : 1Project 系専用
interview_room_refs (別名 LivekitRoom)
アンカー(Project 系専用)
slot_idSlot に 1:1(NOT NULL)
core uuid 参照(事実は core が正)
core_session_uuid詳細API・Webhook 突合
core_room_uuid録画/ログ参照
access_token入室URL(core 発行)
非正規化キャッシュ(一覧・集計用)
first_entered_atW1初回受信=Room数カウント
last_synced_at同期鮮度
teardown_state削除伝達の進行状態
子テーブル
recording_refs[]録画1行=1レコード(下記)
uuid 参照API / Webhook
core(新基盤)
Session↔ core_session_uuid
Room↔ core_room_uuid
Recording[]↔ recording_refs

索引:unique [slot_id] / unique [core_session_uuid]本テーブルは Project 系(Slot)専用——即席は 02 の extemporary_sessions が実体を兼ねるため二股 FK は存在しない。OpenTok legacy 案件はこの行を持たず slot.room で従来どおり。エンジン選択は projects.webrtc_engine(default: opentok)で持ち Slot が継承する。即席は extemporary_folders.webrtc_engine(フォルダ単位・枠単位の混在なし)。

設計メモ:なぜ「薄い参照モデル」か
  • Slot への列直付けは、uuid・キャッシュ・同期鮮度の置き場が散らかる。1:1 付帯テーブルに隔離するほうが移行の継ぎ目が明確。
  • core の完全ローカルミラーは「事実は core が正」(B-DEC-02)と矛盾し、整合性責務が重い。採らない。
  • 薄い参照は「事実は core が正」を保ちつつ、一覧性能だけローカルで担保する最小の方法。
02
即席実査の実体 — ExtemporarySlot の後継 extemporary_sessionsフォルダ配下・core Session 直参照
新設

Project に紐付かない実査(即席)は、新エンジンでは ExtemporarySlot を作らない。枠の実体(時間枠・RTC 構成)は core の Session / Room に置き、www 側は本テーブルがフォルダ紐付けと参照だけを持つ。external_ref は {"ExtemporarySession", id}

カラム説明
extemporary_folder_idFK NOT NULLフォルダ紐付け(www 固有の管理単位。営業先・担当・検索・集計)
memotextwww 固有の運用メモ(core に送らない)
core_session_uuid / core_room_uuidstringcore 参照(unique [core_session_uuid]・作成 2 段階目で書き戻し)
access_tokenstring入室 URL 用トークン(core 発行の書き戻しコピー・unique
name / start_time / end_timestring / datetimeライトスルーキャッシュ(正は core Session。一覧表示・フォルダ検索・URL 開示可否判定用)
first_entered_atdatetime nullableW1 初回受信で set。フォルダ一覧の Room 数バッジのカウント材料(B-DEC-14)
cancelled_atdatetime nullableキャンセル事実(www 起点操作のためライトスルーで自明に記録)
last_synced_atdatetimeWebhook / リコンサイルの鮮度

動き — 作成は 2 段階・設定は core へ封入

  1. 行 INSERT(id 確定)→ core POST /rooms(nested session{}external_ref={"ExtemporarySession", id}archive_*・文字起こし構成・入室文言はフォルダ設定から解決して Room 直下に封入)→ uuid / token 書き戻し。失敗時は core_session_uuid IS NULL の行をリトライ / 掃除ジョブで回収。
  2. RTC・録画・文字起こし設定は www に保存しない(二重管理の消滅)。編集は core PATCH /sessions 成功後にローカル更新(ライトスルー)。
  3. キャンセルは core DELETE /sessions(Room close にカスケード)→ cancelled_at 記録。行は消さない(録画・集計の親)。
設計メモ:なぜ ExtemporarySlot を残さないか
  • ExtemporarySlot 13 列のうち www が正本として持つ固有情報はフォルダ紐付けと memo だけ。実体を残すと RTC 設定が www と core の二重管理になる。
  • Slot(Project 系)には募集・割付・謝礼という www 台帳が残るが、即席には存在しない——この非対称が「Project 系=薄い参照・即席=直参照」の根拠。
  • 既存データは移行しない(即席枠は使い捨て)。OpenTok フォルダの ExtemporarySlot はレガシー経路で従来どおり、新エンジンフォルダの新規枠から本テーブル。
  • 提示物(handout)は www に行を作らず core へ直接登録(external_ref なし)。書類置き場(extemporary_folder_files)は www 完結のまま無変更。
03
行キャッシュ — 一覧・月次集計の材料 recording_refs01 / 02 の子テーブル
新設

一覧・集計用の録画メタ。録画 1 行=1 レコードで持ち、合計値には畳み込まない。SUM / GROUP BY は読み取り時に行う(B-DEC-11)。

カラム説明
interview_room_ref_id / extemporary_session_idFK nullable(どちらか一方)親。Project 系=01、即席=02。Webhook の room uuid 突合は親テーブルの core_room_uuid 解決を経由
slot_id / project_idextemporary_folder_idbigint nullable非正規化コピー。集計 JOIN を短縮(Room ホップ除去)。Project 系は slot / project、即席はフォルダ
recording_uuidstringcore Recording 参照(unique
statusstringavailable / failed 等。一覧の「録画あり/失敗」表示ヒント
duration_secinteger録画時間(core Recording.duration_sec のコピー)
recorded_atdatetime録画日時(月次集計のバケット)

動き — Webhook 契機の同期(B-DEC-12)

  1. 録画 Webhook 受信 → payload を直書きせず core GET /rooms/{id}/recordings を read-back → room 単位で冪等 upsert(消えた行は削除)→ last_synced_at 更新。
  2. Webhook 欠落は日次リコンサイルで補足。
  3. 録画削除(www 起点 DELETE /recordings/{id})も同一フローで行を削除。

読み手 — どの画面がこの表を見るか

即席フォルダ一覧の録画時間合計・ダッシュボード月次統計・Room#movie_duration_sec 後継。Slot 詳細ページは読まない(core API on demand・B-DEC-03)。

設計メモ:なぜ集約列ではなく行キャッシュか(B-DEC-11)

ダッシュボードの月次録画時間統計は「録画ごとの duration × 録画日時」がないと再現できない。合計 1 値の集約列では月別に割れないため、行のまま持つ。「薄い参照」から半ミラーへ一歩踏み込むが、正は core のまま・用途は表示/集計ヒントに限る。

04
本文コピー — 横断検索・字幕DL・AI の材料 interview_transcripts
新設

文字起こしのコア化で本文正本(VTT)は core へ移り非公開になる。Employee ジョブ一覧の「文字起こし内容」横断検索と派生機能(字幕 DL・AI チャットボット・要約)の材料を残すため、Webhook 契機で本文を取得しローカル保存する(B-DEC-06)。core に検索 API は足さない。

カラム説明
transcription_uuidstring NOT NULLcore Transcription 参照(unique)。legacy 受け入れ列は初期スキーマに仕込まない(B-DEC-09)
recording_uuidstring消去整合の突合キー(index)
slot_id / project_idbigintローカル JOIN 用。検索 JOIN は project→slots→本テーブルの 2 hop に短縮
kindstringtranscription / translation全 kind 保存=現行の日英区別なしヒットを踏襲
languagestringBCP-47
statusstringdone / error。検索は常に status: :done 条件(B-DEC-13)
textmediumtext nullableVTT から抽出した本文(fulltext index)。error 行は null
raw_vttmediumtext nullableVTT 原文(cue 表示・字幕 .docx/.txt/.csv の材料)

動き — Webhook 契機の取得・消去整合

  1. transcription.completed 受信 → GET /transcriptions/{id}result_url(TTL 3600 秒)→ VTT をフェッチし保存。error 時も失敗行を保存(ダッシュボード表示用)。
  2. 依存機能はすべて本テーブルを読む:ジョブ横断検索 / 字幕 DL / cue 表示 / AI・要約の入力 / ダッシュボード「直近アウトプット」。
  3. 消去整合(B-DEC-07):www の録画削除フロー内で当該 recording_uuid の行を同時削除。削除系 Webhook は新設しない。
設計メモ:1 文字起こし=1 行(チャンク分割をやめる)/並行運用期の 2 系統検索(B-DEC-09)

チャンク分割の廃止:現行 TranscriptionAnalyzer の 65,535 バイト分割は TEXT 型上限の産物。InnoDB の FULLTEXT は MEDIUMTEXT(16MB)にも張れるため踏襲しない。チャンク境界をまたぐフレーズがヒットしない現行欠点も解消。実装時は現行 index の実 DDL(parser 指定の有無)を SHOW CREATE TABLE で確認し、同一構成にする。

並行運用期:OpenTok 案件は現行 transcription_request_analyses、新エンジン案件は本テーブル(dual-write なし・書き込み経路は独立)。search_text はアプリ層で 2 系統を別クエリ実行し、research_project id の和集合で絞る。1 ジョブ=1 エンジンのためマージはクリーンに成立。

完全移行での一本化:旧経路の新規発生が止まったら、旧 analyses → 本テーブルへ一括バックフィル(チャンク連結)し 2 系統検索を退役。legacy 参照キーは、データ移行範囲(過去録画・チャット・入退室の core 移送 or read-only 残置)の決定と併せて確定。

05
依頼管理 — 人手の保護加工フロー recording_processing_requests
新設

core は加工の「ファイルの事実」だけ持ち、加工内容(ブラー / 音声変換)のメタは持たない。現行 ProcessedOtArchive が混載していた個人情報保護加工の依頼管理(Slack 依頼 → 人手待ち → 手動アップロードで完了)の後継を www に新設する。

カラム説明
ot_archive_id / 将来 recording_uuidFK / string加工元録画への参照(二重運用期は engine 分岐)
processesjson依頼内容 ["blur", "voice_change"]
statusenumrequested / completed / cancelled の 3 値のみ
processed_recording_uuidstring nullable成果物=core ProcessedRecording 参照。完了で埋まる
requested_at / completed_atdatetime依頼・完了時刻

動き — 依頼から完了まで

① オペレーターが保護加工を選択 → レコード作成(requested)+ Slack 依頼(現行踏襲) → ② 人手加工後、core へ手動アップロード(署名 PUT → complete) → ③ processed_recording.status_changed(available)受信で completed 化。

設計メモ:持ち込まないもの

トリムのみの依頼はレコードを作らない(core のトリム指示 API 直行・現行 auto? 分岐の消滅)。unprocessed/started/expired のような曖昧な中間状態も廃止する。

残留モデルの繋ぎ直し — 物理 FK → recording_uuid

モデル現状To-Be
TranscriptionRequestbelongs_to :ot_archivecore へ移管 調査側からは廃止。本文は interview_transcripts が後継
ProcessedOtArchivebelongs_to :ot_archivecore へ移管 依頼管理のみ recording_processing_requests が後継
StatementSummaryRequest(要約)belongs_to :ot_archive変更 recording_uuidtranscription_uuid+自前 slot_id/project_id を保持
OtArchiveSummarybelongs_to :ot_archive変更 recording_uuid 保持
OtArchiveAccess(外部共有)belongs_to :ot_archive変更 recording_uuid 保持

動画再生 URL:ot_archive.video_s3_key → 新エンジンは core GET /recordings/{id}/download-url(短命の署名 S3 URL)。旧 OpenTok 案件は ot_archive のまま(二重運用)。

① 読み取り経路の変化 — 統一インターフェース

二重運用期(OpenTok 既存+新基盤)の分岐は Slot#interview_room が吸収する(B-DEC-01)。呼び出し側はエンジンを知らない。

Slot#interview_room
  ├─ engine == opentok → OpentokRoom.new(room)        # 既存ローカル Room をラップ・無変更
  └─ engine == livekit → LivekitRoom.new(interview_room_ref, core_api_client)

共通インターフェースは 6 メソッド:#chat_messages(usage:) / #entry_histories / #recordings / #playback_events / #access_url(role:) / #movie_duration_sec

各アダプタの実装イメージ

OpentokRoom — 既存のローカル Room / OtArchive をそのまま読む薄いラッパ。挙動は無変更。

class OpentokRoom
  def initialize(room) = @room = room

  def chat_messages(usage:) = @room.room_chat_messages.where(usage:)
  def entry_histories       = @room.room_entry_histories
  def recordings            = @room.ot_archives
  def playback_events       = @room.playback_events
  def access_url(role:)     = @room.room_access_token(role)
  def movie_duration_sec    = @room.ot_archives.sum(:file_duration_sec)  # DB集計
end

LivekitRoom — 一覧・集計は recording_refs で即返し、詳細はその場で core API を叩く。API 障害時はデグレード値を返す。

class LivekitRoom
  def initialize(ref, client)
    @ref, @client = ref, client
  end

  # 一覧・集計:API を叩かずローカル行キャッシュで即返し
  def movie_duration_sec = @ref.recording_refs.sum(:duration_sec)

  # 詳細:core API on demand。障害時は「一時的に取得できません」+uuid
  def chat_messages(usage:)
    @client.get("/rooms/#{@ref.core_room_uuid}/chat-messages", channel: usage)
  rescue Core::ApiError
    Core::Degraded.new(@ref.core_room_uuid)
  end

  def entry_histories   = @client.get("/rooms/#{@ref.core_room_uuid}/entry-histories")
  def recordings        = @client.get("/rooms/#{@ref.core_room_uuid}/recordings")
  def playback_events   = @client.get("/rooms/#{@ref.core_room_uuid}/room-events", type: "handout")
  def access_url(role:) = Core::RoomAccessToken.sign(room: @ref.core_room_uuid, role:) # 自己署名(core への往復なし)
end

主な読み取り書き換え

箇所現状To-Be
振り返り画面@room.room_chat_messages 等を直接@interview_room.chat_messages(usage:)。Livekit は core API on demand
ダッシュボード「直近アウトプット」TranscriptionRequest / ProcessedOtArchive を Room 経由 JOIN材料を差し替え(B-DEC-13):interview_transcriptsrecording_processing_requests を自前 slot_id/project_id でローカル JOIN
ダッシュボード月次録画時間統計OtArchive.movie_duration_sec_per_monthrecording_refsrecorded_at の月別 GROUP BY(B-DEC-11)
一覧 N+1 prefetchincludes(room: :ot_archives)prefetch ごと削除——表示に使われていない dead code
即席フォルダ一覧(録画時間・Room 数)joins(room: :ot_archives).sum / Room 行数recording_refs.duration_sec の SUM(extemporary_folder_id 絞り・JOIN レス)/ extemporary_sessions.first_entered_at IS NOT NULL をカウント(B-DEC-14)
チャット CSV ダウンロード(3 画面)room_chat_messages を DB 直読み → CSVBFF が core 履歴 API を全ページ取得し CSV 整形(B-DEC-10)。添付は attachment_name のみ・スタンプは www 側 I18n 辞書
🛡️

API on demand の劣化設計:core API 障害時は、チャット / 録画セクションのみ「一時的に取得できません」+ uuid のデグレード表示。ページ全体は調査データで描画を継続する。

② 新基盤との結合点

結合は www → core の REST(①命令 ②取次ぎ ③参照)と core → www の Webhook(④通知)の 2 方向だけ。core が www を同期呼び出しすることはなく、core 側に www 専用の公開 API も追加しない。

新基盤との結合点 — www → core の REST と、core → www の Webhook の 2 方向だけ 結合点は ①命令 ②取次ぎ ③参照 ④通知 の 4 種に分類できる。core 側に minedia-www 専用の公開 API は追加しない。 minedia-www(調査ドメイン) 起点:Slot 作成・キャンセル・枠削除・退会・入室・振り返り core(実査基盤) Room・録画・文字起こし・加工動画の正本 core_outbox ローカル更新と同一 Tx で書く → 非同期リトライ 入室エンドポイント(BFF) 参加者ブラウザの取次ぎ窓口・①本人確認はここ LivekitRoom アダプタ 振り返り画面・詳細ページの読み取り Webhook receiver event id で冪等受信(core_webhook_events) REST API ApiKey 認証・Idempotency-Key Webhook 配信 順序保証なし・重複配送あり前提 ① 命令(outbox 経由・冪等再送) POST /sessions(Slot 作成 → Room 準備) DELETE /sessions/{id}(キャンセル・枠削除) DELETE /recordings/{id}(録画削除+本文コピー同時削除) erasure(退会 → 匿名化 ※core 契約に追加依頼中) ② 入室 JWT の自己署名(入室のたび・www 内で完結) tenant 0 の秘密鍵で参加 JWT を署名 → ブラウザへ返す(core への往復なし) 署名前に room_access_token 照合 or セッション+Ability を必須化(B-DEC-08) ③ 参照(詳細画面の on demand・保存しない) GET /rooms/{id}/chat-messages・entry-histories・recordings GET /recordings/{id}/download-url(再生用・短命署名 URL) ④ 通知(Webhook・core → www) participant.entered → attend_at / first_entered_at(Room 数) recording.status_changed → recording_refs を read-back で full sync transcription.completed → 本文取得 → interview_transcripts 保存 processed_recording.status_changed → 加工依頼を完了化 / 失敗通知 erasure.completed → 消去の完了確認 実線 = www → core の REST 呼び出し / 破線 = core → www の Webhook。core が www を同期呼び出しすることはない。 通知は「事実が変わった」の合図に留め、受信後は必ず core の GET で正本を再取得する(payload 直書きしない・B-DEC-12)。

図 2:結合点マップ。実線= www → core の REST、破線= core → www の Webhook。

権威の分界(B-DEC-05)— 衝突時の勝者を先に決めておく

権威乖離時の是正
ライフサイクル(キャンセル・中止・時間枠)調査core へ DELETE / PATCH を再送
事実(入退室・録画 / 文字起こし status・ban)coreGET でローカルコピー(recording_refs 等)を上書き
出欠・謝礼・合否調査core へ書き戻さない(調査内で完結)

「GET が真実の源泉」は事実系にのみ適用され、ライフサイクル系は逆向き。この非対称の明文化が勝者規則の本体。LWW(タイムスタンプ比較)は採らない。

www → core(命令・取次ぎ・参照)

契機API備考
Slot 作成POST /sessionsoutbox +リトライ。失敗しても Slot 保存は成功させる(入室 URL は「準備中」)
キャンセル・枠削除DELETE /sessions/{id}outbox 経由・404=成功扱いの冪等。teardown_state で伝達状態を追跡
録画削除DELETE /recordings/{id}Transcription 連鎖削除。同一フローで interview_transcripts も削除(B-DEC-07)
退会 → 個人情報消去erasure(匿名化)core 契約への追加依頼(要調整)。完了は erasure.completed Webhook
入室(毎回)—(自己署名・core 呼び出しなし)BFF が tenant 0 の秘密鍵で参加 JWT を署名し、ブラウザへ返す(下記の認可要件)
緊急発行(force の後継)—(自己署名・時間窓チェックを www 側でスキップ)generate_force_access_token を置換。nbf / exp の導出はテナント裁量(検証側は上限強制のみ)
詳細画面の表示GET /rooms/{id}/… 各種on demand・保存しない。出欠・評価の材料(在室時間)も entry-histories を都度参照

core → www(Webhook 5 種)

Webhookwww 側の処理
participant.entered(W1)Interview.attend_at(panelist 初回・冪等)+ first_entered_at 記録(Project 系=01・即席=02。Room 数カウント材料・B-DEC-14)
recording.status_changedcore を read-back して recording_refs を room 単位 full sync(B-DEC-12)→ 事後処理のトリガー
transcription.completed本文(VTT)を取得し interview_transcripts へ保存 → 要約 / 集計ジョブのトリガー
processed_recording.status_changedavailable → 加工依頼を完了化。failed → オペ向け通知(現行 Slack 失敗通知の後継)
erasure.completed消去の完了確認(契約合意後)

Session の終了に push はない。終了検知は GET /sessions(status は時刻からの導出値)。

整合性担保の三層防御

FK を持たない uuid 境界では、削除・同期の整合を DB が保証しない。以下の三層で担保する(🧩 タスクとして実装フローに割り付け済み)。

仕組み割り付け先
① 削除伝達(一次防御)ローカル削除と同一 Tx で outbox に書く → ワーカーが冪等 DELETE → teardown_state: confirmedPhase 1(schema)/ Phase 2(実装)
② 突合ジョブ(二次防御・GC)日次 reconciliation=死参照掃除+孤児掃除。grace period 24h・検知→是正の段階有効化Phase 3(実 core 前提)
③ 権威表(衝突の決着)上記「権威の分界」をフィールド単位の定数にし、乖離時の勝者を機械的に決定Phase 2(定数化・是正ロジック)
📮

core 契約への追加依頼(要調整):(a) erasure API(external_ref 単位の匿名化+erasure.completed)——退会時に core 側へ残る display_name 等の個人情報を消すために必須。(b) 突合用一覧 GET /sessions?updated_since=erasure 合意前に二重運用を始めると、livekit 案件の退会処理に個人情報残留ギャップが生じる

即席調査の入室 To-Be — ロール偽造の封じ込め

即席実査は extemporary_sessions① §02)で実体化するが、入室トークンの持ち方だけは As-Is の穴を引き継がないよう作り替える。即席は User / Answer ドメインを持たず、パネリスト・モデレーター・見学者・運用が同じ枠へ入るため、ロールを詐称されやすい。

⚠️

As-Is の穴:1 枠に単一 room_access_token、ロールは ?interview_role= クエリ(lib/tb.rb の 4 値)で申告する。CSV は同じトークンで 4 本の URL を出し、違いはクエリだけ。panelist の URL を受け取った人が ?interview_role=minedia_observer に書き換えれば、UI 経由でそのまま権限昇格できる(生 API も申告ロールを信用)。

To-Be — ロール別トークン + サーバー確定署名

単一トークン+ロールクエリをやめ、ロールごとに別トークンを発行する。?interview_role=廃止。入室時、www(tenant 0 = 発行者)がトークンからロールを引き当て、ロールを participant JWT のクレームに封入して署名する(Model B)。基盤コアは署名を検証するだけで、ロールの申告は下流に一切流れない。参加者はロールを選べず、署名後は改ざんできない。

実装は extemporary_sessions に紐づく www 側のロール別トークン表で持つ。ロールの権威は www に閉じ、core は関与しない(core は JWT の role クレームを検証するだけ)。

カラム説明
extemporary_session_idFK NOT NULL枠紐付け(extemporary_sessions
roleenumpanelist / moderator / observer / minedia_observer(基盤定義の閉じた語彙)
access_tokenstring配布 URL 用のロール別トークン(has_secure_tokenunique
revoked_atdatetime nullableロール単位の個別失効・再発行用

索引:unique [extemporary_session_id, role] / unique [access_token]。枠作成時(webrtc_engine=livekit)に 4 ロール分を一括生成。単一 access_token は本表に置き換える——「トークンが当たれば申告ロールを信じる」構造を、トークン自体がロールを担う構造へ。

入室フロー(認証ページ「即席調査」タブに対応)

  1. 配布 URL(ロール別トークン)クリック → www が access_token から (session, role) を確定。revoked_at / 時間窓を検証。?interview_role= は読まない
  2. www が core Room uuid を確定し、participant JWT {tenant:0, room, participant, role, exp, jti} を tenant 0 の秘密鍵で署名(基盤への往復なし)。
  3. フラグメント付きリダイレクト → room SPA が JWT を回収。
  4. core が署名・クレーム・越境・失効を検証 → RoomPolicy から grants 導出 → LiveKit トークン発行。
  5. 入室待機画面で表示名のみ入力(即席は identity 不要の形式)。
即席調査の入室フロー(platform=livekit)。配布リンクの grant トークンからロールを確定し、role を JWT クレームに封入して署名する。
🔑

ロールはラベルであって能力ではない。JWT に載るのはロールラベルのみで、canPublish 等の能力は基盤の RoomPolicy が専有する(認可設計)。テナントがどんな JWT を署名しても、メディア層の能力は基盤の導出結果に限定される。

移行とスコープ

  • エンジン選択は extemporary_folders.webrtc_engineフォルダ単位・default opentok)。opentok フォルダの即席枠は現行 legacy 経路(単一トークン+ロールクエリ)のまま——手を入れず OpenTok 撤去まで併存する。livekit フォルダの新規枠から本方式に乗る。既存配布 URL は壊れない。
  • 本 To-Be のスコープはロール偽造(権限昇格)の防止のみname(入室待機画面で入力する表示名)と uuid(接続識別用の派生 ID)は身元を主張せず、即席は identity 不要の形式のため、identity なりすましの残余リスクは実質存在しない。
  • 入室時、www はトークン照合(現行の入室ページ認可の継承)を経てから署名する。ロール別トークン化はその上に「申告ロールを信じない」構造を足すもの——トークンが当たっても、名乗ったロールではなくトークンに紐づくロールで署名される。

③ 追加で必要な開発 — 実装フロー

主軸は A主軸ハイブリッド:既存を壊さない継ぎ目(統一 IF)を先に完結させ、新エンジン部は契約モックで先行 TDD し、core を待つ。core 依存度で 4 フェーズに割れる。

Phase 0seam リファクタcore 非依存・最優先

統一 IF Slot#interview_room を導入し、既存 OpenTok Room を OpentokRoom でラップ。この段階では常に OpentokRoom を返す(engine 分岐なし)。

主なタスク
  • OpentokRoom クラスを追加し、共通 IF 6 メソッドを既存 Room/OtArchive 委譲で実装
  • Slot#interview_room を追加(暫定で常に OpentokRoom.new(room) を返す)
  • 振り返り画面と ot_archives_controller@interview_room.* 経由へ書き換え
  • OpentokRoom の IF 単体テスト+既存リグレッションテストを通す

受け入れ:挙動完全無変更・全テスト緑(純リファクタ)。

Phase 1スキーマ migrationcore 非依存

エンジン分岐・薄い参照モデル・繋ぎ直しのスキーマを用意する。新列は未使用(default/null)でも既存集計が壊れない状態にする。

主なタスク
  • migration: projects.webrtc_engine / extemporary_folders.webrtc_engine(default opentok)
  • migration: interview_room_refsslot_id 単独・uuid 群・first_entered_at・unique 索引)/ extemporary_sessions(即席の実体)+モデル/関連定義
  • migration: recording_refs(録画メタの行キャッシュ)
  • migration: interview_transcriptstext mediumtext + fulltext index)
  • migration: recording_processing_requests(加工依頼管理)
  • migration: 事後処理モデルへ recording_uuid / 自前 slot_id / project_id を追加
  • 物理 FK belongs_to :ot_archive を nullable 化(廃止は Phase 3)
  • ローカル JOIN / 集計経路を engine 分岐で用意。dead な一覧 N+1 prefetch は削除
  • 🧩 migration: core_outbox / interview_room_refs.teardown_state / core_webhook_events(同期基盤)

受け入れ:migration 可逆・既存 OpenTok 経路は無変更で緑。

Phase 2新エンジン部 contract-first TDDcore OpenAPI 公開が着手条件

LivekitRoom 経路一式を core 契約に対して mock で完成させる。契約逸脱を mock で検出できる状態にする。

主なタスク
  • core 公開 OpenAPI を vendoring、openapi_parser+WebMock で契約テスト土台を用意
  • Core::ApiClient 実装(ApiKey 認証・Idempotency-Key・outbox リトライ)
  • LivekitRoom を共通 IF で実装(cache 即返し+ API on demand +障害時デグレード)
  • Webhook receiver +同期ハンドラ 4 種(録画 full sync/入室/文字起こし保存/加工完了化)
  • search_text の 2 系統マージ(B-DEC-09)と録画削除フローのコピー行同時削除(B-DEC-07)
  • 入室 BFF に認可検証(room_access_token 照合 or セッション+Ability・B-DEC-08)
  • 🧩 Webhook 冪等化(event id 重複排除)・削除伝達の outbox 化・権威表の定数化+是正ロジック
  • 🧩 退会 → erasure 連携 client(core 契約に入り次第)
  • Slot#interview_room の engine 分岐を有効化

受け入れ:mock 相手に「作成 → 入室 URL → 読み取り → Webhook 同期」が契約テストで緑。

Phase 3core 結合core 稼働待ち・現状ブロック

mock を実 core へ差し替え、二重運用を経て完全移行する。core 稼働でアンブロックされる唯一のフェーズ。

主なタスク
  • mock → 実 core へ差し替え、1 案件を新エンジンで e2e(作成→配布→入室→録画→振り返り→事後処理)
  • 二重運用投入:opentok / livekit の案件割り当てルールを確定
  • 🧩 突合ジョブ投入(grace period 24h・検知のみ → 乖離ゼロ確認後に自動是正 ON)
  • 🧩 erasure e2e・整合性メトリクス計装(乖離検知・自動是正件数)
  • 完全移行:物理 FK 廃止、legacy Room/OtSession/OtArchive を read-only 化
  • 過去データ(録画・チャット・入退室)の core 移送 or read-only 残置の範囲を確定

依存関係

Phase 0 (seam) ──┐
                 ├─► Phase 2 (contract-first, mock) ──► Phase 3 (core 結合) ★core稼働待ち
Phase 1 (schema)─┘
  • Phase 0 / 1 は core 非依存で先行完了できる。
  • Phase 2 は core の OpenAPI 公開が着手条件=稼働サービスは不要だが契約公開は必要(minedia-www は pure consumer)。
  • Phase 3 のみ core 稼働がブロッカー。

付録 — 確定 14 論点(B-DEC-01〜14)

現行画面の実査(Operator 実ログイン・Employee 管理画面・ダッシュボード / 即席フォルダ)で確定した設計分岐の一覧。根拠と詳細は本文の各セクションに移したため、ここは一行の決定記録のみ。

データモデル
B-DEC-02事後処理の繋ぎ直しは物理 FK を廃止し recording_uuid(文字列参照)へ→ ①
B-DEC-11録画メタは集約列でなく録画 1 行=1 レコードrecording_refs(月次統計が集約列では割れない)→ ①
B-DEC-12recording_refs の書き込みは Webhook payload を信用せず read-back の room 単位 full sync+日次リコンサイル→ ①
B-DEC-13ダッシュボード「直近アウトプット」は縮退を受容:完了 / 失敗のみ表示(進行中は再現不可)→ ①
B-DEC-14即席フォルダの Room 数バッジは W1 契機の extemporary_sessions.first_entered_at IS NOT NULL カウント(「実際に使われた部屋数」を踏襲)→ ①
文字起こし・検索
B-DEC-06ジョブ横断検索は Webhook 契機で本文を取得し interview_transcripts にローカル保存(core に検索 API は足さない・全 kind 保存)→ ①
B-DEC-07本文コピーの消去は www の録画削除フロー内で同時削除(削除系 Webhook は新設しない)→ ①
B-DEC-09並行運用期はアプリ層で旧・新 2 系統を検索し id をマージ(dual-write なし)。完全移行時にバックフィルで一本化→ ①
読み取り経路・UI
B-DEC-01二重運用は統一 IF Slot#interview_room(OpentokRoom / LivekitRoom アダプタ)で吸収→ ①
B-DEC-03読み取りは混合:詳細=core API on demand / 一覧・集計の hot 列だけローカルキャッシュ→ ①
B-DEC-04振り返り管理画面は www に残し core API 取得。二重運用期も同一 UI で両エンジンを見る→ ①
B-DEC-10チャット CSV は www 側 BFF が core 履歴 API から生成(core に CSV API は追加しない)。列・TZ・Excel 書式・ファイル名は現行踏襲→ ①
同期・セキュリティ
B-DEC-05権威の分界:Room 解放 / 入室可否 / 録画=core、オファー / 確定 / 謝礼 / 出欠=調査。Webhook で双方向同期→ ②
B-DEC-08入室 BFF は取次ぎ前の本人検証を必須化(room_access_token 照合 or セッション+Ability)。推測可能な id のみによる発行を構造的に禁止→ ②

本ページは設計ドキュメントであり、本番コード(app/, db/)は変更しない。GitHub Issue #5317。