要件定義書より原文(docs/requirements/REQUIREMENTS.md):
P7
preferences (Email/App); categories: System Alert, Ticket issued, New user joined, Subscription updated; desktop notifications; read/unread
この1行から実装されているのは3つのサブ機能で、当初(初版)はコード上もほぼ互いに参照し合っていなかった。2026-07-27のクロス仕様レビュー後、①と②は共通のnotificationsテーブル(issue #43)でつながった。③(デスクトップ通知)のうちチャット/会議リマインダーは今も独立だが、issue #47でP7の4カテゴリ自身の「テスト通知」ボタンからはデスクトップ通知を発火できるようになった(§6-2b)。
min_severity(この重要度未満は配信しない)を持つ。旧: グローバルスイッチ2つ(email_enabled/in_app_enabled)× カテゴリ別フラグ4つという2階層構造だったため「System AlertはメールでほしいがTicket issuedはアプリ内だけでいい」を表現できなかった(レビュー指摘そのもの)。profiles.notification_preferences(JSONB)への永続化は変わらない。notificationsテーブル + GET /api/notifications経由で、既読(read_at)はサーバー側・デバイス間で同期する(旧: GET /monitor/alerts直読み + localStorage)。Notification APIで権限を取得し、チャットの新着メッセージと会議開始時にOS通知を出す(この経路は今も権限のみでゲート、カテゴリ別設定は参照しない)。issue #47の「テスト通知」ボタンは、選んだカテゴリのdesktopチャネルがONならクリック時にその場でOS通知を1件発火する — P7カテゴリの実配信(ポーリングで新着を検知した瞬間の自動発火)への接続はまだ無い(§8、意図的なスコープ外)。2026-07-27のクロス仕様レビュー(issue #43)以降、①(設定)と②(アラート一覧)は共通のnotificationsテーブルでつながっている。issue #47で①がチャネル×カテゴリのマトリクスになり、②の全ての発火元(Monitorアラート + issue #46の3カテゴリ)がこのマトリクスを直接参照するようになった。③(チャット/会議リマインダーのデスクトップ通知)は今も独立。
既読状態(read_at)は今やサーバー側 — 同じユーザーが別のブラウザ/端末で開いても既読・未読が一致する(旧: localStorageのみ、§8参照)。issue #47以降、マトリクスの4カテゴリ×email/in_appは全て実際にゲートしている(§6-2b)。desktopチャネルは値としては保存・往復するが、P7カテゴリの自動発火(ポーリングでの新着検知→OS通知)への接続はまだ無く、テスト通知ボタン経由でのみ発火する(§8)。
merge_notification_preferences()(issue #44)はcolumn || patchというシャロー(トップレベルのみ)マージのため、channelsを部分的にパッチすると他カテゴリを丸ごと消してしまう — update_notification_preferences()は現在値を先に読み、呼び出し元の部分更新をPython側でマージしてから完全な{channels, min_severity}をRPCに渡すことで、この制約の上でも見かけ上の部分更新を実現している(§4)。
2箇所の独立ポーリング自体は残っている(単一クエリキャッシュへの統合、例えばReact Queryの導入は見送り — 新規ライブラリ依存を増やす判断は本チケットの範囲外としてスコープアウト、#45)。その代わりissue #45で対応したのは実害の大きい2点: (1) useVisibilityPollingフックにより、タブがバックグラウンドの間はsetIntervalを完全停止し、フォアグラウンド復帰時に即座に再フェッチ — 開きっぱなしのバックグラウンドタブが無駄にAPIを叩き続けることがなくなった。(2) 同一オリジンのBroadcastChannel("arch:notifications")により、あるタブでの既読化が他タブへ即時伝播 — 複数タブを開いていても既読状態が食い違わない(タブ自身の次回ポーリングを待つ必要がない)。ブラウザ非対応時は機能検出でフォールバックし何もしない。
Test ID対応(issue #54): data-testidは付与済み(2026-07-28)だが、対応するPlaywrightテストはまだ無い — V{"document.hidden?"}分岐はpage.evaluateでvisibilitychangeを発火させnotifications-header-ping-dot等の更新を確認する形でテスト可能、IのBroadcastChannel分岐は本セッション中に手動Playwright検証済み(issue #45の実装時)だが自動テストとしては存在しない。testid-spec.html §4参照。
Test ID対応(issue #54): notifications-desktop-banner/notifications-desktop-enable-button/notifications-desktop-dismiss-buttonは付与済み(BrowserNotificationBanner.tsx)。D/I(実際のOS通知発火)はDOM要素ではなくブラウザAPI呼び出しそのものなので、testidではなくpage.exposeFunctionやwindow.Notificationのスパイで検証する必要がある — 未実装。F/G(チャット新着・会議リマインダー)はuse-chat.ts側の管理でこの仕様書のスコープ外(§8参照)。全分岐ともPlaywrightテストは現状無い。
2026-07-27追加(issue #43): notificationsテーブル — 1行 = 1受信者への1通知(組織全体向けのMonitorアラートも、対象メンバー数だけ行がファンアウトする)。(user_id, source_type, source_id)の一意制約により、_fire_alert()のリトループから呼ばれても二重生成しない。
CREATE TABLE notifications (
id SERIAL PRIMARY KEY,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
category TEXT NOT NULL, -- system_alert | ticket_issued | user_joined | subscription_updated
severity TEXT NOT NULL DEFAULT 'info', -- info | warning | critical
title TEXT NOT NULL,
body TEXT,
source_type TEXT, -- 'monitor_alert' | (将来: 'ticket' | 'membership' | 'subscription')
source_id TEXT, -- 例: 発生源の monitor_alerts.id
link_url TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
-- カテゴリ固有の構造化データ。system_alert は {tenant, ticket_id} を格納
-- (AlertItem.tsx の TENANT:/TICKET: 表示に使用)
read_at TIMESTAMPTZ, -- NULL = 未読。サーバー側・デバイス間で同期(旧: localStorageのみ)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON notifications (user_id, source_type, source_id);
CREATE INDEX ON notifications (user_id, read_at, created_at DESC);
CREATE INDEX ON notifications (org_id, created_at);
2026-07-28改修(issue #46): 残り3カテゴリにも発火元が付いた。ticket_issuedはSupport Deskの内部作成(create_ticket())+公開ウィジェット(verify_public_ticket_email()、issue #38の「メール確認まで通知しない」不変条件を踏襲)、user_joinedは/api/auth/join-orgとサインアップ時の招待コード自動参加(ensure_profile())、subscription_updatedはStripe Webhook(/api/billing/webhook)の2箇所のdb.update("organizations", ...)直後。いずれも受信者は組織のAdminのみ(Monitorアラートのような全メンバー可視ではない — notify_staff()のメール通知が元々使っていたのと同じAdminスコープ)。
-- profiles テーブルの該当カラムのみ抜粋
notification_preferences JSONB NOT NULL DEFAULT '{}'
chat_read_state JSONB NOT NULL DEFAULT '{}' -- 2026-07-28追加(issue #44)
2026-07-28改修: チャット既読状態(旧: 内部キー_chat_readsとしてnotification_preferencesに間借り)を専用カラムchat_read_stateに分離。設定トグルの更新とチャット既読の更新が同じJSONBカラムを取り合う競合が構造的に無くなった(§8)。実際の格納例(notification_preferencesはissue #47でこの形に変わった — 旧6フラットブール形式はnormalize()が読み取り時に自動変換する、下記§6-1):
-- notification_preferences (issue #47以降)
{
"channels": {
"system_alert": {"email": true, "in_app": true, "desktop": true},
"ticket_issued": {"email": false, "in_app": true, "desktop": false},
"user_joined": {"email": false, "in_app": true, "desktop": false},
"subscription_updated": {"email": true, "in_app": true, "desktop": false}
},
"min_severity": "info"
}
-- chat_read_state
{ "org": 42, "group:7": 12, "dm:38719712-...": 5 }
両カラムとも、書き込みはPostgres関数(merge_notification_preferences() / merge_chat_read_state()、SECURITY指定なしのプレーンSQL関数)経由のアトミックなcolumn = column || patchマージで行う — アプリ側でread→Python辞書マージ→列ごと書き戻す、という旧方式(§6-1)が持っていた「同時書き込みの後勝ち上書き」を構造的に排除する(§8)。このマージはトップレベルのみのシャローマージなので、channelsの一部カテゴリだけをパッチすると他カテゴリが丸ごと消える — update_notification_preferences()は現在値を先に読んでPython側でマージしてから、完全な{channels, min_severity}をパッチとして渡す(§3-A)。
| メソッド/パス | 説明 |
|---|---|
GET /api/notifications/preferences |
呼び出しユーザーの{channels: {category: {email,in_app,desktop}}, min_severity}を返す(issue #47。normalize()が未設定分・旧フラット形式をDEFAULT_CHANNELSで補完) |
PUT /api/notifications/preferences |
{channels: {category: {channel: bool}}}および/または{min_severity}を部分更新(渡したキー・カテゴリ・チャネルだけ書き換え、他は現状維持)。未知のカテゴリ/severityは400 |
POST /api/notifications/test |
NEW(issue #47){category}を受け取り、呼び出しユーザー自身の現在のチャネル設定に従ってテスト通知を送信。{in_app, email}(実際に送信されたチャネル)を返す |
GET /api/notifications?cursor=&limit=&unread_only= |
NEW(issue #43)呼び出しユーザー宛の通知一覧。{items, next_cursor}、created_at降順。既定limit=50(フロントの初回取得はlimit=100で単発、ページネーションUIはP2 #50) |
GET /api/notifications/unread-count |
NEW {unread_count, has_critical_unread}。ヘッダーバッジとクリティカルバナー(App.tsx)が両方ともこの1呼び出しで済ませる |
POST /api/notifications/{id}/read |
NEW read_atを現在時刻にセット(冪等)。呼び出しユーザー以外の通知は404 |
POST /api/notifications/{id}/unread |
NEW read_atをNULLに戻す(既読トグルの逆方向、冪等) |
POST /api/notifications/read-all |
NEW 呼び出しユーザーの未読を全て既読化 |
DEFAULT_CHANNELS(service/notifications/prefs.py)— System Alertはemail/in_app/desktop全部ON、Ticket issued/New user joinedはin_appのみ、Subscription updatedはemail+in_app。既存ユーザーが一度もこの画面を開いていなくても、GETは常に4カテゴリ×3チャネルの完全なマトリクスを返す(normalize()が未保存分をデフォルトで補完)。要件変更で廃止された旧カテゴリ(survey_reminders/task_updates/weekly_digest)は issue #44のマイグレーションで全プロフィールから一括除去済み。email_enabled/in_app_enabled+カテゴリ別フラグ4つ)が保存されたままのプロフィールも、normalize()が読み取り時に等価なチャネルマトリクスへ変換する(_legacy_channels())— マイグレーションSQLは無し、初めてPUTされたタイミングで新形式へ書き換わる。旧形式にはdesktopの概念が無かったため、変換後のdesktop値は常にDEFAULT_CHANNELSのものになる。PUTの書き込みはmerge_notification_preferences(profile_id, patch)(Postgres関数、UPDATE ... SET notification_preferences = notification_preferences || patch ... RETURNING ...)をPostgRESTの/rpc/経由で呼ぶアトミック更新に変更。旧: Pythonで現在値を読み→辞書をマージ→カラム全体を書き戻す方式で、チャット既読の同時書き込みと衝突すると後勝ちで一方が消えていた(§4、§8)。チャット既読(chat_read_state)も同じ理由で専用カラム+専用RPC関数(merge_chat_read_state())に分離済み。issue #47でpatchの中身がネストしたオブジェクトになったため、このマージがシャロー(トップレベルのみ)である制約を守るには「渡したキーだけ書き換え」ではなく、Pythonで完全な{channels, min_severity}を組み立ててから渡す必要がある(§3-A)。channels.system_alert.emailは、Monitorのアラートメール・デイリーレポートの宛先解決(filter_recipients_by_notification_prefs()、service/monitor/settings/parsers.py)で参照される(旧: email_enabled+monitor_alertsの2つのAND)。ALERT_RECIPIENT_ROLESで解決されたプロフィール由来のアドレスのみが対象 — ALERT_EXTRA_EMAILSやメールリストのアドレスにはそもそもプロフィールが無いのでオプトアウトできない(意図した挙動)。severity=criticalのアラートはmin_severityともども個人設定に関わらず必ず配信される。ticket_issued/user_joined/subscription_updatedの3つ(issue #46)はchannels.<category>.in_appを参照(Admin宛のみ、§6-2b)。デスクトップ通知(チャット新着・会議リマインダー)は今もchannels.*.desktopを参照しない — 別系統(§6-3、§8で未解決)。notifications.read_atとしてサーバーに保存される。同じユーザーが別のブラウザ/デバイスで開いても既読/未読が一致する(旧: localStorage["arch:read_alerts"]のみ、デバイスごとに独立していた)。_fire_alert()の両方のsave_alert()呼び出し箇所)にemit_monitor_alert_notifications()を呼び、組織の全メンバー分の行をファンアウトする — 一覧に表示される対象自体は以前(GET /monitor/alerts、全メンバー閲覧可)と同じ範囲を維持している。channels.system_alert.in_appがOFFのメンバー、またはmin_severityがこのアラートのseverityを上回るメンバーはこの時点でスキップされる(severity=criticalはどちらもバイパス、§6-1と同じ理屈)。use-shell.ts)はGET /notifications/unread-countを、Notificationsページの一覧(use-notifications.ts)はGET /notifications?limit=100を、それぞれ独立にポーリングする — ポーリング自体の統合(単一クエリキャッシュ化)はまだ(#45でスコープ外と判断、§3-B参照)。useVisibilityPolling(fn, intervalMs)(lib/use-visibility-polling.ts)経由に変更。document.visibilitychangeを監視し、タブがバックグラウンドの間はsetIntervalを止め、フォアグラウンド復帰時に即fetch + 再開する。POST /notifications/{id}/readまたは/unread(既読状態を見てどちらかを選ぶ)、全既読はPOST /notifications/read-all。いずれも成功後にwindow.dispatchEvent(new Event("arch:alerts_refreshed"))(同一タブ内の即時反映用)に加え、issue #45でbroadcastNotificationsEvent("alerts_refreshed")(lib/notifications-channel.ts、BroadcastChannel("arch:notifications")ラッパー)も発火するようになった — 同一ブラウザの別タブが購読していれば即座に再フェッチする(旧: 他タブへは伝播せず、そのタブ自身の次回ポーリングまで既読状態が食い違って見えていた)。チャットの既読化(use-chat.tsのmarkRead/markUnread)も同じチャンネルで"chat_read"を発火し、シェルの未読バッジをタブ横断で同期する。title/bodyはMonitorアラートのタイトル/要約をそのままコピーしたもの。tenant/ticket_idはMonitor固有の情報なのでmetadata JSONBに格納し、AlertItem.tsxがalert.metadata?.tenant/alert.metadata?.ticket_idとして読む(§4)。_org_admin_profiles()、service/notifications/emit.py)— Monitorアラート(§6-2、全メンバー可視)とは異なり、この3つは「担当者が対応すべき管理系イベント」という性質のため、notify_staff()のメール通知が元々使っていたのと同じAdminスコープに合わせた。各カテゴリともchannels.<category>.in_appがONのAdminだけに配信する(issue #47でin_app_enabled+個別フラグの2段ゲートから単一のチャネル値に統合、critical該当なし・バイパスも無い)。create_ticket()(内部作成、service/comms/support/tickets.py): 作成した本人(スタッフ)は除外し、他のAdminだけに通知。② verify_public_ticket_email()(公開ウィジェット、api/public/public.py): create_public_ticket()自体では発火しない — issue #38の「メール確認前の顧客に通知の存在を教えない」不変条件と同じ理由で、notify_staff()のメール通知と全く同じタイミング(確認リンクを踏んだ時点)まで遅延させている。Monitor起票チケット(_fire_alert()由来)は既存のemit_monitor_alert_notifications()経路のまま — 3つ目の発火元ではなく、Monitorのsystem_alertカテゴリの一部。POST /api/auth/join-org(参加コード入力)、② サインアップ時に招待コードを持っていた場合の自動参加(ensure_profile() → _resolve_org()、service/platform/auth.py)。新規組織作成(create-org/org_action="create")では発火しない — 作成者以外にまだ誰もいないため。参加した本人は自分の参加を通知されない(exclude_user_id)。POST /api/billing/webhook)の customer.subscription.* と checkout.session.completed の両方のdb.update("organizations", ...)直後。Webhookには「操作したユーザー」という概念が無いためexclude_user_idは使わない。冪等性はsource_idにStripeのイベントid(event["id"])を使うことで担保 — Webhookの再送(同一イベントの再配信)で重複通知しない。ticket_id/new_user_idのような自然な受信者スコープの資源idがこのイベントには無いため、他の2カテゴリのticket_id/new_user_idとは異なる考え方。min_severityはservice/notifications/prefs.pyの内部ランクテーブル(info < low < medium < warning≈high < critical)で比較するmeets_min_severity()で判定。選択肢自体は4段階(info/medium/high/critical)に絞っているが、Monitorが実際に書き込むlowやDDLコメントが示すwarningも内部ランクには存在するため、両方の語彙が混在するnotifications.severity(§8)を矛盾なく比較できる。POST /api/notifications/test、{category})は、呼び出したユーザー自身の現在のチャネル設定に従って配信する — in_appがONならその場でemit_notification()(source_type="test"、毎回ユニークなsource_idで重複除外の対象外にし、クリックの度に新しい行が見える)、emailがONなら本人のメールアドレス宛に実際にテストメールを送る。desktopがONならフロントエンドがクリック時にその場でwindow.Notification(...)を発火する(サーバー側にプッシュ経路が無いため)。あるチャネルをOFFにして再送すれば、そのチャネルだけ届かなくなることを自分で確認できる — issue #39/#46がどちらも「設定が実際に効いているか誰も検証できなかった」ことに起因して見つかった不具合だったための再発防止策。Notification.permission === "default"(まだ聞いていない)の間だけ、Notificationsページ上部に許可を促すバナーを表示。許可後は確認用の1件をその場で発火してからバナーを閉じる。use-chat.ts内: (a) チャットの未読サマリーが増えた時(タブが非アクティブな場合)、(b) 会議の開始時刻になった時。どちらも判定条件はNotification.permission === "granted"のみで、通知設定の6カテゴリを一切参照しない(§8)。「Notifications」ページ(左メニューのベルアイコン相当のタブ)は上から: デスクトップ通知許可バナー(未許可時のみ)→ 通知設定カード → クリティカル/その他アラート一覧、の縦1カラム構成。

未読のcriticalアラートが1件でもあると、Notificationsページ以外のどの画面を開いていてもメイン画面上部にこのバナーが表示される(App.tsx、クリックでNotificationsページへ遷移)。

ハンバーガーメニューアイコン右上の小さいドット(未読が無ければ非表示)。critical未読があれば赤、それ以外の未読(chat含む)なら紫でパルスアニメーション。

6個のトグル。この画面の「Ticket issued」はこのスクリーンショットの時点でOFFになっているが、これはたまたまで — ONでもOFFでも他のどの画面・挙動にも影響しない(§8)。

実データ(Test Org)のスクリーンショット、issue #46/#47(ticket_issued/user_joined/subscription_updatedの発火実装)より前のもの — 撮影時点では表示されるのはSystem Monitorのアラートのみだった。TICKET: #182のようにSupport Deskの自動起票チケットへのリンク情報が併記されることがある(issue #34のライブ検証で作成されたチケット)。今は他の3カテゴリもAdmin宛に同じ一覧に混在して表示される(§6-2b)。
2026-07-27、ドキュメント整備の一環でコードを実地調査(grepで全参照箇所を確認)。初版公開後にレビューを受け、P0指摘(§8-1〜§8-4)に加えてSprint 1の本丸(共通notificationsテーブル、issue #43)、チャット既読の競合修正(issue #44)、ポーリングのVisibility対応+タブ間同期(issue #45)、残り3カテゴリのイベント発火実装(issue #46)、チャネル×カテゴリのマトリクス設定UI + テスト通知ボタン(issue #47)まで対応した。残りのSprint 1項目(#48〜#49)とSprint 2以降は未着手。
ALERT_RECIPIENT_ROLESによるロール解決だけで宛先が決まり、そのロールを持つ本人が「Email notifications」「System monitor alerts」をOFFにしても配信され続けていた(notification_preferences参照ゼロ)。→ filter_recipients_by_notification_prefs()(service/monitor/settings/parsers.py)をアラートメール・デイリーレポート両方の宛先解決に追加。ロールで解決されたプロフィール由来のアドレスだけがオプトアウトの対象で、ALERT_EXTRA_EMAILS・メールリスト・環境変数SMTP_TO由来のアドレス(プロフィールが無い)は対象外。ただしseverity=criticalはオプトアウトを無視して必ず配信(個人の希望より障害対応を優先する設計判断)。GET /monitor/alertsはget_today_alerts()(UTC 0時起点の当日分のみ)を返しており、深夜に発火したアラートは既読/未読に関わらず翌朝には一覧・バッジ・クリティカルバナーの全てから消えていた。→ get_recent_alerts(days=3)を新設し、?days=クエリで直近N日(既定3日 = DB_RETENTION_DAYSの既定値)を返すよう変更。get_today_alerts()自体はデイリーレポート等の「当日分」を前提とする既存の呼び出し元のため変更していない。AlertItem.tsxの{alert.title}/{alert.summary}、App.tsxのクリティカルバナー文言はいずれもReact標準のJSX子要素として描画されており、dangerouslySetInnerHTMLは不使用(コード確認済み、2026-07-27)。Support Desk issue #36と同種のリスクは無い。notificationsテーブルを導入(issue #43) — emit_notification()/emit_monitor_alert_notifications()を追加し、Monitorアラート発火を組織全メンバーへファンアウト。フロントエンド(Notifications.tsx/use-shell.ts/use-notifications.ts/AlertItem.tsx)もGET /monitor/alerts + localStorage直読みからGET /api/notifications*経由に移行済み(§2、§6-2)。read_atがサーバー側(notificationsテーブル)に移り、localStorage["arch:read_alerts"]は完全に廃止。同一ユーザーがどのブラウザ/端末で開いても既読/未読が一致する。notification_preferencesのread-modify-write競合を解消(issue #44) — 旧: チャット既読(_chat_reads)が同じJSONBカラムに間借りしており、設定トグルの更新とチャット既読更新が同時に走ると後勝ちで一方が消えうる状態だった。→ chat_read_stateを専用カラムに分離、両カラムとも書き込みはPostgres関数(merge_notification_preferences()/merge_chat_read_state())経由のアトミックcolumn || patchマージに変更(§4、§6-1)。useVisibilityPolling導入によりタブ非アクティブ時は両ポーリング(シェルのunread-count、Notificationsページの?limit=100)を完全停止、フォアグラウンド復帰時に即再開。加えてBroadcastChannel("arch:notifications")で既読化・チャット既読を同一ブラウザの他タブへ即時伝播するようにした。ただし2系統のポーリングを単一クエリキャッシュ(React Query等)へ統合する対応は見送り — 新規状態管理ライブラリの導入は本チケットの実害(トラフィック浪費・タブ間不整合)を標準APIのみで解消できる以上、別途の意思決定が要る大きめの選択として意図的にスコープ外とした(§3-B)。min_severityによる重要度フィルタを追加、「テスト通知を送る」ボタンで各チャネルの設定が実際に効いているか自分で検証できるようにした(§6-2c)。旧フラット形式で保存済みのプロフィールは読み取り時に自動変換され、データ移行は不要(§6-1)。ただしquiet_hours(時間帯によるミュート)は意図的にスコープ外 — 全発火経路(Monitorメール・デイリーレポート・in-app4カテゴリ)にタイムゾーン考慮の時間帯判定を一貫して組み込む必要があり、UI改修1本に相乗りさせるには大きすぎる独立した機能だと判断した(下記未解決参照)。notificationsテーブルへのバックフィルは行っていない(意図的なトレードオフ)。Monitorの既定保持期間が3日(DB_RETENTION_DAYS)なので、移行から最大3日で自然に解消する一時的なギャップ。quiet_hours: {start, end, tz, override_critical}が含まれていたが、タイムゾーンを考慮した時間帯判定を全発火経路(Monitorメール・デイリーレポート・in-app4カテゴリ)に一貫して組み込む必要があり、UI改修1本に相乗りさせず独立した機能として別途扱うことにした(意図的なスコープ外)。unread-count、Notificationsページ: ?limit=100)— issue #45でVisibility対応・タブ間同期は解消したが、単一クエリキャッシュへの統合自体は意図的に見送り(上記✅参照)。read_at)とインシデントのACK/RESOLVED(monitor_incidents、system-monitor-spec.html)は別概念として設計する必要がある(#49)。オンコール複数人運用を想定するなら必須。deniedからの復帰導線が無い、iOS Safariでは動作しない(PWA化が必要)、バナー文言(「チャットとアラート」)と実装(チャットのみ)の乖離。