Skip to content
Merged

canary #6985

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,42 @@ This handbook defines how automation agents collaborate safely and effectively o
- Do not treat CI after a push as a substitute for local validation before the
commit or push.

### Publishing gate

docs/・README・PR のタイトルと本文・レビュー返信・issue(他リポジトリのものを含む)
は、push や投稿をした時点で取り消せない公開物になる。TrainLCD のリポジトリは公開
されており、書いた内容はそのまま作者の設計判断として読まれる。文を足す前に次を
満たすこと。

- **出典の無い主張を書かない。** 書いてよいのは、(a) リポジトリのコードや設定
ファイル、(b) 実際に取得した一次情報(公開ページの原文、仕様書)、(c) メンテナの
回答、のいずれかに辿れる内容だけ。辿れないものは書かずにタスクスレッドで聞く。
記憶から書いた製品仕様・ストアの審査要件・条文の要旨は、原文を当たるまでは
出典の無い主張として扱う。
- **「未確認」「〜かもしれない」で逃げない。** アプリ・基盤・ポリシーはいずれも
同じメンテナのものなので、自分たちの構成が不明である旨を公開物に書くのは事実に
反するうえ、調べていないようにしか読めない。分からないことは書かず、聞けば済む。
ソースから確定できるものは、聞く前に読んで確定させる。
- **実装から言えることと、運用の結果を混同しない。** 「ルータに定義が無い」はコード
の事実、「リクエストが届いていない」は運用の結果で、前者から後者は導けない。片方
しか確かめていないなら、確かめた方だけを書く。仮説を立てて別リポジトリを探しに
行く前に、確定済みの事実で足りるかを見直す。
- **引用は一次情報から取る。** 日付・条文・公開文書の文言は、要約ではなく原文を
当たる。要約ツールの出力をそのまま引用として貼らない。
- **メンテナに質問中の事項は公開しない。** 確認を出したなら、その答えに依存する
記述は回答を得るまで書かない。質問と並行して公開すると、回答前に誤りが出回る。
- **運用上の秘匿情報は粒度を落とす。** サーバの設置場所やネットワーク構成など、
目的に対して不要な粒度は書かない。公開済みのプライバシーポリシーと同じ粒度に
留める。
- **レビューボットの指摘も検証してから従う。** 指摘が誤っていることもある。原文や
コードで裏を取り、違う対応を採るならその理由を返信に書く。裏取りをせずに指摘の
文面をそのまま本文へ反映しない。

公開済みの文に誤りや裏付けの無い記述を見つけたら、直すより先にタスクスレッドで
報告する。どの記述が・なぜ問題で・どう直すつもりかを示し、メンテナの指示を得てから、
push・PR 本文の編集・Issue の編集・訂正コメントやレビュー返信の投稿など、訂正内容を
公開する操作を行う。自分の判断で訂正内容を公開しない。

## Repository Map

- `src/`: Expo React Native app code.
Expand Down Expand Up @@ -166,6 +202,16 @@ This repository is managed with **Git**, and agents drive version control throug
- [ ] Update cross-references (README, docs/) to prevent drift.
- [ ] Spell-check or self-review for clarity and typos.

**Before publishing prose (docs, PR body, review replies, issues)**

- [ ] Every claim traces to repository code, a primary source you fetched, or a maintainer answer.
- [ ] No "未確認" / "かもしれない" hedges about our own app, backend, or policy; unknowns are questions, not sentences.
- [ ] Code-level facts and runtime outcomes are not conflated.
- [ ] Quotes come from the original text, not from a summary.
- [ ] Nothing depends on a question you have asked and not yet had answered.
- [ ] Operationally sensitive details are written at the same granularity as the public privacy policy.
- [ ] Review-bot findings are verified against code or the original text before you act on them.

**For workflow, release, or CI updates**

- [ ] Cross-check `.github/workflows/` for consistency.
Expand Down
6 changes: 3 additions & 3 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,12 @@ android {
dimension "environment"
applicationId "me.tinykitten.trainlcd.dev"
versionNameSuffix "-dev"
versionCode 100000763
versionCode 100000765
versionName "10.15.1"
}
prod {
dimension "environment"
versionCode 100000763
versionCode 100000765
versionName "10.15.1"
}
// ストア配信する dev (Canary) と applicationId を分けた、ローカル検証専用のフレーバー。
Expand All @@ -177,7 +177,7 @@ android {
dimension "environment"
applicationId "me.tinykitten.trainlcd.local"
versionNameSuffix "-local"
versionCode 100000763
versionCode 100000765
versionName "10.15.1"
}
}
Expand Down
6 changes: 3 additions & 3 deletions android/wearable/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,12 @@ android {
dimension = "environment"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
versionCode = 100000764
versionCode = 100000766
versionName = "10.15.1"
}
create("prod") {
dimension = "environment"
versionCode = 100000764
versionCode = 100000766
versionName = "10.15.1"
}
// :app の local フレーバーと対になるローカル検証専用フレーバー。Wear の Data Layer は
Expand All @@ -100,7 +100,7 @@ android {
dimension = "environment"
applicationIdSuffix = ".local"
versionNameSuffix = "-local"
versionCode = 100000764
versionCode = 100000766
versionName = "10.15.1"
}
}
Expand Down
4 changes: 2 additions & 2 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default {
userInterfaceStyle: 'automatic',
// Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する
deploymentTarget: '16.4',
buildNumber: '2911',
buildNumber: '2912',
scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD',
bundleIdentifier: IS_DEV
? 'me.tinykitten.trainlcd.dev'
Expand All @@ -71,7 +71,7 @@ export default {
? 'me.tinykitten.trainlcd.dev'
: 'me.tinykitten.trainlcd',
permissions: [],
versionCode: 100000763,
versionCode: 100000765,
},
owner: 'trainlcd',
experiments: {
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- [本番リリース PR でのバージョン更新](./bump-version-on-release-pr.md)
- [AI コードレビュー (GPT-5.6 Sol)](./ai-code-review-workflow.md)
- [Firebase Test Lab の Robo テスト](./test-lab-robo.md)
- [テレメトリの本番オプトイン: リスク評価](./telemetry-production-optin-risk.md)
- [AIエージェント設計書](./spec/ai-agent/architecture.md)
- [リモートTTS(iOS)設計書](./spec/tts/remote-tts.md)
- [オンデバイスTTS(VOICEVOX / iOS)設計書](./spec/tts/on-device-tts-ios.md)
185 changes: 185 additions & 0 deletions docs/telemetry-production-optin-risk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# テレメトリの本番オプトイン: リスク評価

試験的機能として canary 限定で動いているテレメトリを、本番 (App Store / Google Play
配布ビルド) でもオプトイン可能にする場合に何が起きるかを整理したもの。実装前の
意思決定用メモであり、この文書の時点でコードには一切手を付けていない。

結論として、**プライバシーポリシーの改定は必須**で、かつそれだけでは足りない。
コード側の前提を 3 点変えないと本番投入は危険。

## 現状のゲート

```text
isTelemetryEnabledByBuild = isDevApp && ENABLE_EXPERIMENTAL_TELEMETRY === 'true'
```

- `src/utils/telemetryConfig.ts`: ビルド時定数。canary (dev アプリ) でのみ true
- `src/screens/AppSettings.tsx`: 試験的機能の導線自体が `isDevApp` でガードされている
- `src/screens/ExperimentalSettings.tsx`: ユーザー側トグル。既定は false (オプトイン)
- `src/components/Permitted.tsx`: MMKV (`STORAGE_KEYS.TELEMETRY_ENABLED`) から復元

つまり「本番でオプトイン」は、ビルド時ゲートを外すことと、試験的機能画面の
`isDevApp` ガードを外す (あるいはトグルを通常の設定画面へ出す) ことの両方を指す。

これとは別に、送信先 URL (`EXPERIMENTAL_TELEMETRY_ENDPOINT_URL`) が空なら、下に挙げる
送信経路はいずれも早期 return する。コンソールテレメトリの場合は `console.*` の差し替え自体が
行われない。ビルド時ゲートを外しても、本番ビルドへ URL を焼き込まない限り送信は
起きない。

## オプトインすると実際に何が送信されるか

| 経路 | 中身 | 頻度 |
| --- | --- | --- |
| `sendLocation` | 緯度・経度 (生の精度)、accuracy、速度、lineId / stationId、移動状態、バッテリー残量・充電状態、端末モデル名、アプリバージョン (ビルド番号込)、OS、channel、sessionId | 最短 1 秒に 1 回 (`TELEMETRY_THROTTLE_MS = 1000`) |
| `sendLogEvent` / `sendInteractionEvent` | アプリ内ログ、`app_launch` などの操作イベント | 随時 |
| `useConsoleTelemetry` | `console.log` / `debug` / `warn` / `error` の全出力 | 5 秒ごとにフラッシュ |

送信先はいずれも `EXPERIMENTAL_TELEMETRY_ENDPOINT_URL`。GraphQL ミューテーション
(`/graphql`) と、コンソールテレメトリだけが使う REST (`/api/log`) の 2 系統がある。

ただし後者には対応するルートが無い。THQ (`TrainLCD/THQ`) のルータに定義されて
いるのは `/`、`/ws`、`/healthz`、`/graphql` の 4 つで、`/api/log` は含まれない
(`src/server.rs`)。GraphQL 側には同じ役割の `sendLogEvent` があり、そちらは
`sessionId` / `appVersion` / `platform` / `channel` も受け取れる。クライアント側は
カスケードエラーを防ぐため送信結果を見ないので、この不一致はアプリ側からは
分からない。どちらへ寄せるかの方針決めは TrainLCD/THQ#38。

### 同意文言と取得範囲のズレ

トグルの説明は `telemetryDescription` (`assets/translations/ja.json:351`,
`assets/translations/en.json:350`) の「お使いの端末の地理的座標を解析用サーバに
送信します」だけ。バッテリー・端末モデル・ログ本文・操作イベントは説明されて
いない。同意を取る地点の文言が実際の取得範囲を下回っているので、ポリシーと
あわせてこの文言も直す必要がある。

## プライバシーポリシーとの矛盾

現行ポリシー (`https://trainlcd.app/privacy-policy` / 最終更新 令和 8 年 8 月 13 日)
には、本番テレメトリと正面から矛盾する記述が 2 つある。

1. **「取得した位置情報は、駅の判定のために一時的に TinyKitten の管理するサーバーへ
送信されますが、サーバー上に保管されることはありません。」**
本番テレメトリは位置ログを解析基盤に永続化して集計するのが目的なので、この
一文はそのままでは事実に反する。「解析目的で送信された位置情報は、利用者が
明示的に有効化した場合に限り N 日間保管される」という趣旨へ書き換える。
1. **「位置情報を、広告の配信や利用者様の行動の追跡 (トラッキング) のために
利用することは一切ありません」**
広告目的でないのは事実だが、1Hz の連続測位ログは移動軌跡そのもので、
sessionId によりプロセス単位で連結できる。「やらないこと」だけを書くのでは
なく、「セッション単位の移動軌跡を解析目的で保管する。ただし個人の特定・
広告配信・アプリ横断の追跡には利用しない」と、やることを書いた上で否定する
形にする。

### 新設が必要な項目

現行ポリシーに項目自体が無く、追記が要るもの。

- 取得項目の追加: バッテリー残量・充電状態、端末モデル名、アプリバージョン、
アプリ内ログ本文、操作イベント
- 保管期間の明示: 現状は「一定の期間」で数値が無い。位置ログは日数を切る
- オプトアウト導線: 「端末の設定から停止できる」だけでなく、アプリ内設定から
個別に無効化できることを明記する
- 保管先: テレメトリ基盤 (THQ) の記載が無い。THQ は TinyKitten が管理する国内の
サーバで動き、Cloudflare 経由で公開している。保管も国内で完結し、第三者への
提供も無いので、外国にある第三者への提供 (個人情報保護法 28 条) と、外国で
取り扱う場合の「外的環境の把握」(安全管理措置) はどちらも論点にならない。
経路に入る Cloudflare は現行ポリシーが既に外部サービスとして記載している
- 開示・削除請求への応答方針: 後述のとおり、特定可能性の評価と手順の決めが要る

## コード側で先に直すべき 3 点

### 1. コンソールテレメトリは本番に出さない

`useConsoleTelemetry` は `console.*` を横取りして送る。`babel.config.js` は
production 環境のビルドで `transform-remove-console` を有効にし、`warn` / `error`
以外の `console.*` を除去するので、本番ビルドでこの経路に乗るのは警告とエラーに
なる。それでもサードパーティ製ライブラリのエラーや、ディープリンク URL・ユーザー
入力を含む例外メッセージは `console.error` で出るため、何が入るかは制御できない。
`sanitizeTelemetryMessage` の防御は正規表現 4 本
(`src/utils/sanitizeTelemetryMessage.ts`) で、キー名が想定外の秘密情報や生の JWT は
素通りする。

加えて `/api/log` への送信ペイロードには `channel` も `sessionId` も入っていない
ため、本番ログと canary ログが基盤側で区別できない。

前述のとおり、この経路の送信先 `/api/log` は THQ のルータに無い。受け口を用意すれば
上記の内容がそのまま流れ込むので、本番へ出す前にコンソール経路を `sendLogEvent` へ
寄せるか、経路ごと落とすかを決めておく。

対応は、本番では `FxConsoleTelemetry` (`src/screens/Main.tsx`) をマウントしない。
出すなら `warn` / `error` 限定かつ送信元を許可リストで絞る作りに変えてから。

### 2. 静的 Bearer トークンをやめる

`EXPERIMENTAL_TELEMETRY_TOKEN` は Babel プラグイン (`babel.config.js` の
`module:react-native-dotenv`) がビルド時に値へ置き換えるので、トークンはバンドルの
中身そのものになる。canary は配布先が限られるため許容できていたが、本番は公開配布
なので、アプリを入手した第三者は正規クライアントと同じ資格情報を持つことになる。
書き込み権限を端末ごとに絞れないため、汚染された位置ログを後から見分ける手段も
無い。

`src/lib/session.ts` に installId を短期セッショントークンへ交換する仕組み
(`/auth/token`) が既にあるので、これに乗せ替えるのが筋。

### 3. リモートキルスイッチを付ける

現在のゲートはビルド時定数なので、事故ってもストア審査を通さないと止められない。
`REMOTE_CONFIG_KEYS.AI_AGENT_ENABLED` に「障害・コスト超過時にサーバー側から機能を
止められるようにするキルスイッチ」という先例があるので、同じ形で
`telemetry_enabled` を追加する。

## その他のリスク

### ストア申告

App Store のプライバシーラベルと Google Play のデータセーフティは、アプリが実際に
収集する内容と一致している必要がある。位置情報を解析目的で収集する状態になるので、
申告内容を見直す。ATT が必要かどうかは「アプリ横断の追跡に使うか」「データブローカー
へ提供するか」で決まるため、そこを広げない設計を前提にする。

### 開示・削除請求への対応方針

`sessionId` はプロセスごとの使い捨て UUID (`src/hooks/useTelemetrySender.ts` の
`getOrCreateSessionId`) で端末に残らない。ただし「端末に ID が残らない」ことだけを
根拠に特定不能と決めつけない。送信されるのは連続した移動軌跡と時刻・端末モデル・
アプリバージョンなので、保管側の他の情報と突き合わせれば特定できる場合がある。
まず照合可能性の評価が要る。

そのうえで方針を決める。GDPR 17 条の削除請求に対して 11 条 (特定を要しない取扱い)
を援用できるのは、特定できないことを管理者が示せる場合に限られ、請求者が特定に
足る追加情報を出してきたときは同条を理由に一律で断れない。請求を受けたときの手順
(申請内容との照合、対象データの特定、削除可否と理由の記録、特定できない場合の説明、
追加情報が出てきたときの再評価) を決めておく。削除に応じられる形にするなら
installId 紐づけへ変える選択肢もあるが、匿名性は下がる。基盤側の論点として
TrainLCD/THQ#37 に起票済み。

### コストと電池

スロットル (`TELEMETRY_THROTTLE_MS`) が効くのは `sendLocation` だけで、最大 1 秒 1
リクエストに制限されるものの、バッチングもリトライ制御も無い。毎秒の測位が続けば
30 分乗車で 1 ユーザーあたり 1,800 リクエストになる。他の経路はこの制限の外にあり、
`sendLogEvent` / `sendInteractionEvent` は呼ばれるたびに送信し、コンソール経路は
5 秒ごとのフラッシュでキューに溜まったログ 1 件につき 1 リクエストを出す。送信は画面を見ている間に限らない。`index.js` の `TaskManager`
タスクが `handleTrackingLocation` 経由で `locationAtom` を更新し、
`FxTelemetrySender` がそれを購読しているため、バックグラウンド測位が動いている
あいだは測位のたびに送信が続く。canary の母数では問題にならなかった数字が、本番の DAU では桁が
変わる。本番はサンプリング (対象ユーザーの N 分の 1) か送信間隔の緩和 (5〜10 秒)
が現実的。省電力測位モードの効果を毎秒の通信で部分的に打ち消す点も検討が要る。

### 同意のスコープクリープ

MMKV に `'true'` が永続化されるだけなので、将来取得項目を増やしたときに古い同意が
そのまま流用される。同意バージョンを持たせ、範囲を広げるときは再同意を取る設計に
しておく。

## 推奨する出し方

段階を踏むなら、次の構成で本番へ出す。

- 位置イベントのみ (コンソールテレメトリとインタラクションイベントは canary 据え置き)
- サンプリングまたは送信間隔の緩和
- Remote Config によるキルスイッチ
- セッショントークン認証

プライバシーポリシーはこの「位置イベントのみ」の範囲で書き換え、取得項目を
増やすときに改めて改定する。
Loading
Loading