# Mastra Agent Runtime Production 設計書

LINE Memory Pro を production に持っていくための、Mastra を中心にした AI 実行基盤、tenant ごとのデータ分離、Slack アプリとしてのタスク管理 UX、GraphQL API、管理画面、DB schema を定義する。

## 目次

1. [結論](#1-結論)
2. [対象機能](#2-対象機能)
3. [システム構成](#3-システム構成)
4. [Tenant 分離](#4-tenant-分離)
5. [管理画面仕様](#5-管理画面仕様)
6. [Slack App UX](#6-slack-app-ux)
7. [Slack Lists 同期](#7-slack-lists-同期)
8. [Mastra Runtime](#8-mastra-runtime)
9. [GraphQL API 方針](#9-graphql-api-方針)
10. [GraphQL Schema](#10-graphql-schema)
11. [GraphQL Query / Mutation 例](#11-graphql-query--mutation-例)
12. [DB 詳細 Schema](#12-db-詳細-schema)
13. [Security / Audit](#13-security--audit)
14. [実装補足観点](#14-実装補足観点)
15. [Deployment](#15-deployment)
16. [Roadmap](#16-roadmap)
17. [References](#17-references)

## 1. 結論

production 方針は以下。

- Postgres を唯一の正規 DB とする。
- API は GraphQL を中心にする。
- Mastra は DB にアクセスする AI workflow runtime とする。
- Slack は operator が自然に操作できる UI とし、DB の代替にはしない。
- Slack Lists は DB から投影される shared task view とする。
- LINE は顧客接点と outbound delivery channel として扱う。
- tenant ごとに Mastra profile/container/永続 volume/LINE token/Slack token を分離する。

重要な境界は、Mastra も Slack も DB の代わりにしないこと。Mastra は判断と生成、Slack は人間の操作面、DB は状態と履歴の正を担う。

### API 方針

管理画面、Slack App、operator 補助、タスク管理、agent 実行依頼は GraphQL に寄せる。

ただし、外部 provider 仕様上、以下は GraphQL ではなく HTTP endpoint として残す。

- LINE webhook
- Slack OAuth callback
- Slack Interactivity request URL
- Slack Events API を使う場合の event endpoint
- health check

これらの provider endpoint は受信後に tenant/actor を解決し、内部では GraphQL resolver と同じ service layer を呼ぶ。

## 2. 対象機能

| 機能                | 主担当                        | 説明                                                                     | 保存先                                |
| ------------------- | ----------------------------- | ------------------------------------------------------------------------ | ------------------------------------- |
| LINE bot onboarding | LINE + App API                | tenant ごとの LINE チャネル連携、Webhook 登録、権限チェック、初期設定    | DB + Secret Manager                   |
| Slack onboarding    | Slack OAuth + App API         | workspace 連携、bot token 管理、App Home/Lists 権限設定                  | DB + Secret Manager                   |
| 会話理解            | Mastra                        | LINE/Slack 会話から intent、緊急度、返信要否、タスク候補、要望候補を抽出 | DB                                    |
| 返信案              | Mastra + Operator             | 顧客文脈、過去対応、ポリシーに基づき返信案を作成。送信は承認制           | DB + outbound queue                   |
| 要約                | Mastra                        | スレッド要約、顧客別履歴要約、引き継ぎ要約、朝サマリー                   | DB                                    |
| 会話ログ検索        | Mastra + GraphQL              | full-text search、時系列 window、message ID 指定で過去会話を取得         | DB + search index                     |
| 機能要望受付        | Mastra + App API              | 会話から要望を抽出し、重複候補、影響範囲、優先度案を作る                 | DB                                    |
| タスク管理          | DB + GraphQL + Slack App      | タスク作成、担当者、期限、優先度、ステータス、履歴、外部リンクを管理     | DB canonical / Slack Lists projection |
| operator 補助       | Slack App + 管理画面 + Mastra | App Home、Modal、Button、管理画面からタスク・返信案・要約・承認を扱う    | DB                                    |
| 管理画面            | Web Admin + GraphQL           | tenant 設定、連携管理、タスク管理、会話確認、agent 設定、監査ログ確認    | DB                                    |

## 3. システム構成

```text
LINE Webhook / Slack Interactivity / Admin Web / Scheduler
  -> provider signature verification
  -> tenant resolution
  -> actor authorization
  -> GraphQL service layer
  -> Postgres canonical write
  -> Queue enqueue

Queue
  -> Mastra tenant runtime
  -> typed DB tools
  -> agent outputs saved to DB
  -> Slack projection jobs
  -> LINE outbound jobs

Admin Web / Slack App Home / Slack Lists
  -> GraphQL API
  -> Postgres
```

### コンポーネント

| コンポーネント          | 役割                                                                  |
| ----------------------- | --------------------------------------------------------------------- |
| Admin Web               | 管理画面。GraphQL client として動く                                   |
| GraphQL API             | 認可、tenant guard、transaction、service layer 呼び出し               |
| Provider Webhook API    | LINE/Slack の署名検証、payload 正規化、GraphQL service layer 呼び出し |
| Postgres                | tenant、顧客、会話、タスク、要望、agent run、監査ログの正             |
| Queue                   | Mastra jobs、Slack projection、LINE outbound、digest を非同期化       |
| Mastra Runtime          | 会話理解、返信案、要約、要望受付、朝サマリー、operator 補助           |
| Slack Projection Worker | DB の状態を App Home、Slack Lists、channel message に反映             |
| LINE Outbound Worker    | 承認済み返信だけを LINE に送信し、結果を audit に残す                 |

### 3.1 技術スタック

production 実装は TypeScript monorepo を前提にする。GraphQL API、管理画面、Mastra runtime、worker、provider webhook を同じ domain/application package に寄せ、外部技術は adapter として外側に置く。

| 領域              | 推奨技術                                                | 役割                                                        |
| ----------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
| Monorepo          | pnpm workspace + TypeScript strict                      | app/package を分けつつ型を共有する                          |
| Admin Web         | React + Vite + Tailwind CSS + shadcn/ui + Apollo Client | 管理画面。GraphQL client として動く                         |
| GraphQL API       | Node.js + GraphQL Yoga + GraphQL Code Generator         | resolver、auth context、usecase 呼び出し、型生成            |
| Schema validation | Zod                                                     | webhook payload、GraphQL input、usecase input の境界検証    |
| ORM               | Prisma Client + tenant-scoped client extension          | tenant_id の自動付与、transaction、migration                |
| DB                | Cloud SQL for PostgreSQL                                | canonical state、audit、full-text search                    |
| Queue             | Cloud Tasks / Pub/Sub                                   | Mastra jobs、Slack projection、LINE outbound                |
| Agent runtime     | Mastra                                                  | workflow、agent、typed tools、eval                          |
| IaC               | Pulumi TypeScript                                       | dev/prd の GCP 環境、Cloud Run、Cloud SQL、Secret Manager   |
| External adapters | Slack SDK / LINE Messaging API SDK                      | Slack/LINE との通信                                         |
| Observability     | Cloud Logging + OpenTelemetry                           | request_id、tenant_id、agent_run_id の相関                  |
| Test              | Vitest + Testcontainers                                 | usecase/unit、repository integration、GraphQL resolver test |

Prisma を使う理由は、TypeScript 型と migration を揃えやすく、client extension で tenant-scoped client を作りやすいため。raw SQL が必要な full-text search や vector search は repository adapter に閉じ込める。

### 3.2 ディレクトリ構造

Clean Architecture の依存方向は `domain <- application <- interface/infra` とする。domain/application は GraphQL、Prisma、Slack、LINE、Mastra SDK に依存しない。

```text
apps/
  admin-web/
    src/
      app/
      routes/
      components/
        ui/
        layout/
        tasks/
        conversations/
        settings/
      graphql/
        generated/
        fragments/
        mutations/
        queries/
      lib/
        apollo.ts
        cn.ts
      pages/
      styles/
        globals.css
  graphql-api/
    src/
      server.ts
      context.ts
      schema.ts
      resolvers/
      loaders/
  provider-webhooks/
    src/
      lineWebhook.ts
      slackInteractivity.ts
      slackOAuth.ts
  agent-runtime/
    src/
      mastra.ts
      agents/
      workflows/
      tools/
  workers/
    src/
      slackProjectionWorker.ts
      lineOutboundWorker.ts
      digestWorker.ts

packages/
  domain/
    src/
      entities/
      value-objects/
      policies/
      events/
  application/
    src/
      usecases/
        tasks/
        conversations/
        reply-drafts/
        slack-sync/
        agent-runs/
        admin/
      ports/
      context/
  interface-graphql/
    src/
      generated/
      resolvers/
      mappers/
  infrastructure/
    src/
      db/
        prisma/
        tenantDb.ts
        adminDb.ts
      repositories/
      slack/
      line/
      mastra/
      queue/
      secrets/
  shared/
    src/
      ids/
      errors/
      logging/
      result/
```

`apps/admin-web` は Tailwind CSS と shadcn/ui を標準 UI 基盤にする。shadcn/ui の生成コンポーネントは `components/ui/` に置き、業務コンポーネントは `components/tasks/`、`components/conversations/`、`components/settings/` のように画面領域ごとに分ける。GraphQL の型は Code Generator で `graphql/generated/` に出力し、page/component から raw query string を散らさない。

### 3.3 各レイヤの役割

| レイヤ                | 役割                                                                                      | 禁止事項                                                     |
| --------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Domain                | entity、value object、domain policy、状態遷移ルールを持つ                                 | DB、GraphQL、Slack、LINE、Mastra SDK に依存しない            |
| Application / Usecase | tenantId と actor を受け取り、domain policy と repository port を使って業務処理を実行する | Prisma client、GraphQL resolver、provider SDK を直接呼ばない |
| Ports                 | repository、queue、secret、external service の interface を定義する                       | 具体実装を持たない                                           |
| Interface GraphQL     | GraphQL input を usecase input に変換し、auth/tenant context を作る                       | 業務ロジックを書かない                                       |
| Provider Webhook      | LINE/Slack 署名検証、payload 正規化、tenant/actor 解決                                    | DB を直接更新しない                                          |
| Infrastructure        | Prisma repository、Slack/LINE adapter、queue、secret、Mastra tool 実装                    | domain rule を再実装しない                                   |
| Composition root      | DI、設定、logger、tenantDbFactory を組み立てる                                            | usecase に framework detail を漏らさない                     |

### 3.4 Usecase の tenantId 契約

tenant scoped な usecase は `tenantId` を必須にする。GraphQL input に `tenantId` がある場合でも、usecase は resolver から渡された `TenantUsecaseContext` を正とする。

```ts
type TenantUsecaseContext = {
  tenantId: TenantId;
  actor: Actor;
  requestId: string;
};

type AdminUsecaseContext = {
  actor: AdminActor;
  requestId: string;
  admin: true;
};

export class AssignTaskUsecase {
  async execute(
    ctx: TenantUsecaseContext,
    input: AssignTaskInput,
  ): Promise<Task> {
    // ctx.tenantId is required and is the only tenant scope used by repositories.
  }
}
```

ルール:

- `tasks`, `conversations`, `reply-drafts`, `slack-sync`, `agent-runs` など tenant data を扱う usecase は必ず `TenantUsecaseContext` を受け取る。
- tenant scoped usecase の input に optional tenantId を置かない。`ctx.tenantId` を唯一の tenant scope とする。
- GraphQL resolver は `input.tenantId` と actor の所属 tenant を照合し、許可された場合だけ `TenantUsecaseContext` を作る。
- admin usecase は `AdminUsecaseContext` で実行できる。tenant を横断する一覧、tenant 作成、global audit、billing などは tenantId なしでよい。
- admin が特定 tenant の task/conversation を操作する場合は、admin 権限を検証したうえで `TenantUsecaseContext` に target tenantId を入れて tenant scoped usecase を呼ぶ。

### 3.5 ORM tenant guard

ORM には tenant scoped client を用意し、tenant scoped table への query に `tenant_id` を自動付与する。usecase と repository は raw Prisma client を直接受け取らない。

```ts
const tenantDb = tenantDbFactory.forTenant(ctx.tenantId);

await tenantDb.task.findMany({
  where: { status: "open" },
});
// 実際の query は tenant_id = ctx.tenantId が自動付与される。
```

実装方針:

- `tenantDbFactory.forTenant(tenantId)` は Prisma Client extension で tenant scoped model に `tenantId` を注入する。
- `findMany`, `findFirst`, `count`, `updateMany`, `deleteMany` は `where.tenantId = tenantId` を自動付与する。
- `create`, `createMany`, `upsert` は `data.tenantId = tenantId` を自動付与し、別 tenantId が渡されたら例外にする。
- `update`, `delete` は `where` に id だけが渡されても、repository 側で `tenantId + id` の複合条件に変換する。
- tenant scoped client では `$queryRaw` と `$executeRaw` を禁止する。full-text search の raw SQL は `MessageSearchRepository` のような専用 repository で tenantId bind を必須にする。
- nested write は原則禁止する。必要な場合は repository method と domain policy を通して明示的に実装する。
- admin 用には `adminDb` を別に用意する。`adminDb` は admin usecase と migration/maintenance だけで使い、通常 usecase には注入しない。
- DB 側では `tenant_id` 必須、複合 unique/index、foreign key、repository guard の組み合わせで分離を担保する。
- repository integration test で tenant guard を必須検証し、tenant scoped table への raw query は専用 repository 以外から呼べないようにする。

tenant scoped table の例:

```ts
const TENANT_SCOPED_MODELS = [
  "Customer",
  "Conversation",
  "Message",
  "InternalTask",
  "TaskAssignment",
  "ReplyDraft",
  "FeatureRequest",
  "SlackTaskListLink",
  "SlackTaskListConflict",
  "AgentRun",
] as const;
```

## 4. Tenant 分離

データ分離を重視するため、tenant ごとに以下を分ける。

| 層             | 分離方針                                                                         | 理由                                               |
| -------------- | -------------------------------------------------------------------------------- | -------------------------------------------------- |
| Mastra profile | tenant ごとに profile/workflow config を分ける                                   | prompt、memory、tool permission、eval の混線を防ぐ |
| Container      | 高分離 tenant は dedicated container/service、通常 tenant は guarded worker pool | runtime memory、cache、環境変数、障害影響を分離    |
| 永続 volume    | tenant ごとの vector index、agent artifact、transient files を分離               | AI 作業ファイルや embedding index の混線を防ぐ     |
| Secrets        | LINE token、Slack token、model provider key は tenant scope で暗号化             | 誤送信、権限漏れ、token 流出時の影響を限定         |
| Queue          | job payload に tenant_id 必須。worker 起動時にも tenant scope を固定             | job 取り違えを防ぐ                                 |
| DB             | 全業務テーブルに tenant_id 必須。repository guard と DB 制約を適用               | 横断読み書きを防ぐ                                 |

GCP project は環境ごとに分ける。`dev` は `line-memory-pro-dev`、`prd` は `line-memory-pro-prd` とし、dev/prd 間で Cloud SQL、Secret Manager、Cloud Run、Cloud DNS、queue を共有しない。各環境 project の中で、tenant ごとの Cloud Run service/job または worker pool を分ける。大口 tenant は DB schema/database 分離へ上げられる設計にしておく。

## 5. 管理画面仕様

管理画面は GraphQL API を使う Web Admin として実装する。Slack App は日常操作、管理画面は設定・監査・詳細編集を担当する。

### 5.0 フロントエンド UI 方針

管理画面は Tailwind CSS と shadcn/ui を使い、業務アプリとして情報密度と操作速度を優先する。見た目の独自実装よりも、shadcn/ui の既存 primitive を組み合わせて、一貫した keyboard 操作、focus ring、dialog、form validation、table 操作を保つ。

| 用途           | 推奨コンポーネント                                | 方針                                                              |
| -------------- | ------------------------------------------------- | ----------------------------------------------------------------- |
| 一覧           | `Table`, `DataTable`, `DropdownMenu`, `Checkbox`  | filter、sort、bulk action、列表示切替、pagination を標準化する    |
| 詳細           | `Tabs`, `Sheet`, `Separator`, `Badge`             | タスク詳細、会話、audit、Slack sync 状態をタブで分ける            |
| 編集           | `Dialog`, `Form`, `Input`, `Textarea`, `Select`   | React Hook Form + Zod で GraphQL input と同じ制約をかける         |
| 状態更新       | `Button`, `ToggleGroup`, `Command`, `Popover`     | status、priority、assignee は少ない操作回数で更新できるようにする |
| 通知 / エラー  | `Toast`, `Alert`, `Tooltip`                       | optimistic update 失敗、Slack Lists conflict、権限不足を明示する  |
| 管理者向け設定 | `Card` ではなく section + form group を基本にする | 設定画面はカード過多にせず、監査しやすい縦方向のフォームにする    |

Tailwind の theme token は tenant ごとに変えない。tenant branding を入れる場合も、logo、tenant name、accent color の最小範囲にとどめ、管理画面の操作性と監査性を優先する。

### 5.1 画面一覧

| 画面                   | URL 例                                           | 対象ロール    | 主な機能                                                 |
| ---------------------- | ------------------------------------------------ | ------------- | -------------------------------------------------------- |
| ログイン / tenant 選択 | `/admin/login`, `/admin/tenants`                 | 全ロール      | SSO/login、参加 tenant 選択                              |
| ダッシュボード         | `/admin/:tenantId`                               | operator 以上 | 今日の未対応、期限超過、返信待ち、P0/P1、agent 実行状況  |
| タスク一覧             | `/admin/:tenantId/tasks`                         | operator 以上 | filter、sort、status 更新、担当変更、一括操作            |
| タスク詳細             | `/admin/:tenantId/tasks/:taskId`                 | operator 以上 | 詳細、関連会話、履歴、Slack Lists 同期状態、audit        |
| 担当者管理             | `/admin/:tenantId/handlers`                      | manager 以上  | handler 作成、role 変更、Slack/LINE/email 紐づけ         |
| 会話一覧               | `/admin/:tenantId/conversations`                 | operator 以上 | 顧客別会話、要返信、緊急度、未読、Mastra 要約            |
| 会話ログ検索           | `/admin/:tenantId/messages/search`               | operator 以上 | full-text search、期間指定、conversation/customer filter |
| 会話詳細               | `/admin/:tenantId/conversations/:conversationId` | operator 以上 | message timeline、返信案、要約、タスク化、要望化         |
| 返信案レビュー         | `/admin/:tenantId/reply-drafts`                  | operator 以上 | draft 編集、承認、差し戻し、LINE 送信                    |
| 機能要望               | `/admin/:tenantId/feature-requests`              | manager 以上  | 要望候補、重複、優先度、ステータス管理                   |
| 朝サマリー             | `/admin/:tenantId/digests/morning`               | operator 以上 | 日次サマリー、担当別一覧、Slack 共有                     |
| LINE 連携              | `/admin/:tenantId/settings/line`                 | admin         | channel 設定、webhook status、token secret ref           |
| Slack 連携             | `/admin/:tenantId/settings/slack`                | admin         | OAuth、workspace、channel、List、App Home 設定           |
| Mastra 設定            | `/admin/:tenantId/settings/agent`                | admin         | runtime mode、workflow 有効化、policy version、model     |
| 監査ログ               | `/admin/:tenantId/audit-logs`                    | admin         | actor、action、target、期間で検索                        |
| 同期状態               | `/admin/:tenantId/sync`                          | admin         | Slack Lists、App Home、LINE outbound、agent queue の状態 |

### 5.2 ダッシュボード

表示する KPI:

- 未対応タスク数
- P0/P1 タスク数
- 期限超過タスク数
- 未割当タスク数
- 承認待ち返信案数
- LINE 送信失敗数
- Slack Lists 同期失敗数
- agent 実行失敗数

主要アクション:

- `タスク一覧を開く`
- `返信案を確認`
- `朝サマリーを生成`
- `Slackに共有`
- `同期失敗を再試行`

GraphQL:

- Query: `adminDashboard(tenantId: ID!): AdminDashboard!`
- Mutation: `generateMorningDigest(input: GenerateMorningDigestInput!): MorningDigest!`
- Mutation: `retryFailedProjectionJobs(input: RetryProjectionJobsInput!): RetryProjectionJobsPayload!`

### 5.3 タスク一覧

必須 filter:

- status
- priority
- assigneeHandlerId
- dueDate range
- sourceType
- customerId
- conversationId
- hasSlackSyncConflict
- createdByAgent

列:

- title
- status
- priority
- assignee
- dueAt
- source
- customer
- latestActivityAt
- slackSyncStatus

一括操作:

- 担当者変更
- status 変更
- dueAt 変更
- Slack Lists 再同期
- archive/cancel

一括更新も stale write を防ぐため、各 task の `expectedVersion` を必須にする。古い version の task は更新せず、`BulkTaskConflict` として返す。

GraphQL:

- Query: `tasks(tenantId: ID!, filter: TaskFilter, first: Int, after: String): TaskConnection!`
- Mutation: `updateTask(input: UpdateTaskInput!): Task!`
- Mutation: `assignTask(input: AssignTaskInput!): Task!`
- Mutation: `transitionTaskStatus(input: TransitionTaskStatusInput!): Task!`
- Mutation: `bulkUpdateTasks(input: BulkUpdateTasksInput!): BulkUpdateTasksPayload!`

### 5.4 タスク詳細

表示:

- 基本情報
- description
- source message/conversation
- customer context
- assignee history
- status history
- related reply drafts
- related feature requests
- Slack Lists item link
- audit logs

アクション:

- 編集
- 担当変更
- status 変更
- 返信案生成
- 要約生成
- Slack Lists 再同期
- 関連会話を開く

### 5.5 担当者管理

handler は DB 上の担当者であり、Slack user を直接担当者にはしない。

必須項目:

- displayName
- role
- status
- Slack user ID
- email
- LINE account ID

GraphQL:

- Query: `handlers(tenantId: ID!, filter: HandlerFilter): [Handler!]!`
- Mutation: `createHandler(input: CreateHandlerInput!): Handler!`
- Mutation: `updateHandler(input: UpdateHandlerInput!): Handler!`
- Mutation: `linkHandlerExternalAccount(input: LinkHandlerExternalAccountInput!): HandlerExternalAccount!`

### 5.6 会話詳細

表示:

- message timeline
- customer profile
- conversation summary
- intent/urgency/sentiment
- reply drafts
- task candidates
- feature request candidates

アクション:

- 返信案生成
- 返信案編集/承認/送信
- 会話内検索
- message ID で根拠確認
- タスク化
- 要望化
- 要約再生成
- operator memo 追加

GraphQL:

- Query: `conversationTimeline(input: ConversationTimelineInput!): ConversationTimeline!`
- Query: `searchMessages(tenantId: ID!, filter: MessageSearchFilter!, first: Int, after: String): MessageSearchConnection!`
- Query: `message(tenantId: ID!, id: ID!): Message`
- Query: `messagesByIds(input: MessagesByIdsInput!): [Message!]!`

### 5.7 返信案レビュー

返信案は必ず `draft -> approved -> queued -> sent` の状態を通す。

承認 UI で表示する情報:

- 送信先 customer
- 元会話
- draft body
- Mastra の根拠
- risk level
- policy warnings
- approver

GraphQL:

- Mutation: `generateReplyDraft(input: GenerateReplyDraftInput!): ReplyDraft!`
- Mutation: `updateReplyDraft(input: UpdateReplyDraftInput!): ReplyDraft!`
- Mutation: `approveReplyDraft(input: ApproveReplyDraftInput!): ReplyDraft!`
- Mutation: `enqueueApprovedReply(input: EnqueueApprovedReplyInput!): OutboundMessage!`

### 5.8 Slack 連携設定

設定項目:

- workspace
- bot user
- default notification channel
- task list id
- App Home enabled
- Lists sync enabled
- polling interval
- allowed channels
- Slack scopes status

GraphQL:

- Query: `slackIntegration(tenantId: ID!): SlackWorkspaceIntegration`
- Mutation: `configureSlackIntegration(input: ConfigureSlackIntegrationInput!): SlackWorkspaceIntegration!`
- Mutation: `connectSlackTaskList(input: ConnectSlackTaskListInput!): SlackTaskListLinkConfig!`
- Mutation: `syncSlackTaskList(input: SyncSlackTaskListInput!): SyncJob!`

### 5.9 LINE 連携設定

設定項目:

- LINE channel ID
- channel name
- channel secret ref
- access token secret ref
- webhook URL
- webhook verification status
- outbound queue status

GraphQL:

- Query: `lineIntegration(tenantId: ID!): LineChannelIntegration`
- Mutation: `configureLineIntegration(input: ConfigureLineIntegrationInput!): LineChannelIntegration!`

注意点:

- token 本体は DB に保存せず、Secret Manager の参照名だけを保存する。
- webhook の署名検証が成功するまで outbound を有効化しない。
- tenant ごとに LINE token を分け、誤 tenant 送信を防ぐ。

### 5.10 Mastra 設定

設定項目:

- runtime mode: `SHARED_POOL`, `DEDICATED_CONTAINER`
- mastra profile id
- container name
- volume name
- model provider
- model name
- policy version
- enabled workflows
- max daily runs
- human approval policy

GraphQL:

- Query: `tenantAgentConfig(tenantId: ID!): TenantAgentConfig!`
- Mutation: `updateTenantAgentConfig(input: UpdateTenantAgentConfigInput!): TenantAgentConfig!`
- Mutation: `runAgentWorkflow(input: RunAgentWorkflowInput!): AgentRun!`

## 6. Slack App UX

Slack surface は役割で分ける。

| Surface         | 役割             | 内容                                                         |
| --------------- | ---------------- | ------------------------------------------------------------ |
| App Home        | 個人の作業入口   | 自分の未対応、今日期限、承認待ち、返信案、朝サマリー         |
| Slack Lists     | チーム共有の一覧 | tenant のタスク一覧。status、owner、due、priority を軽微編集 |
| Modal           | 重要操作の編集面 | タスク作成、担当変更、LINE 返信送信、却下理由、要望登録      |
| Button          | 低摩擦な確定操作 | 自分に割当、完了、承認、差し戻し、返信案を見る               |
| Shortcut        | 文脈からの登録   | Slack message からタスク化、要望化、要約、返信案作成         |
| Channel message | 例外と共有通知   | P0/P1、期限超過、承認依頼、同期失敗、日次 digest             |

UX ルール:

- 一覧は Slack Lists、個人の作業は App Home。
- 重要な状態変更は GraphQL service layer を通す。
- AI は下書きまで。外部送信は operator 承認が必要。
- 通知は例外中心にする。
- 権限エラーは ephemeral に返す。
- 誤操作が痛い操作は Modal にする。

## 7. Slack Lists 同期

Slack Lists は DB の task projection として作る。DB から Slack Lists へ反映する流れを主にし、Slack Lists 側で編集された差分は polling で検出して DB に取り込む。

| 方向                      | 方式                                                                | 用途                                              | 注意点                            |
| ------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------- |
| DB -> Slack Lists         | projection worker が `slackLists.items.create/update/delete` を呼ぶ | タスク作成、status 更新、担当変更、期限変更、完了 | rate limit を考慮し queue + retry |
| Slack Lists -> DB         | `slackLists.items.list` を定期 polling して diff                    | Lists で owner/status/due を直接変更した場合      | 専用 webhook/event を前提にしない |
| Slack Interactivity -> DB | button/modal/shortcut が App API を呼ぶ                             | 重要操作、承認、LINE送信、要望登録                | 最も信頼できる Slack 操作経路     |

### 7.1 コンフリクト検知

Slack Lists の item を polling した時点で、以下を比較する。

| 比較対象                | 保存先                                           | 用途                                    |
| ----------------------- | ------------------------------------------------ | --------------------------------------- |
| DB task version         | `internal_tasks.version`                         | DB 側で最後に確定した業務状態           |
| 前回同期時の version    | `slack_task_list_links.last_synced_task_version` | Slack Lists に最後に投影した DB version |
| 前回同期時の Slack hash | `slack_task_list_links.last_seen_list_hash`      | Lists 側 item の前回状態                |
| 現在の Slack hash       | polling した Slack Lists item                    | Lists 側で直接編集されたかの判定        |

判定:

- DB version が進んでいて、Slack hash が変わっていない場合: DB の変更を Slack Lists へ再投影する。
- DB version が進んでおらず、Slack hash だけ変わっている場合: Slack 側変更を DB に取り込めるか validation する。
- DB version も Slack hash も変わっている場合: 同じ task に対して両側で編集があったため conflict とする。
- Slack item が消えている、または task/list mapping が壊れている場合: conflict とする。

### 7.2 自動解決できるケース

| ケース               | 解決方法                                                                               | 条件                                                 |
| -------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| DB だけ変更          | DB の内容で Slack Lists を更新                                                         | Slack hash が前回同期時から変わっていない            |
| Slack Lists だけ変更 | Slack 側の変更を DB に取り込む                                                         | 変更対象が `status`, `owner`, `due`, `priority` のみ |
| 両方変更だが同じ値   | DB version を進め、Slack link を最新化                                                 | 正規化後の値が一致する                               |
| Slack owner 変更     | `handler_external_accounts` で Slack team + user を handler に解決して assignment 更新 | 対象 user が tenant の active handler                |
| Slack status 変更    | `transitionTaskStatus` と同じ validation を通して更新                                  | 許可された status transition                         |

### 7.3 自動解決しないケース

以下は自動上書きせず、`slack_task_list_links.sync_status = 'conflict'` とし、`slack_task_list_conflicts` に詳細を残す。

- DB と Slack Lists が同じ field を別々の値に変更している。
- Slack user を DB の handler に解決できない。
- Slack 側変更が actor の権限を超えている。
- status transition が不正。
- P0/P1 への変更、削除、archive、LINE 送信、返信承認など重要操作に関わる。
- DB 側 task が既に `done` / `canceled` になっているのに Slack 側で再オープンされた。
- Slack list item が削除されたが、DB task は active のまま。
- mapping 先の `slack_list_item_id` が別 task を指している可能性がある。

### 7.4 解決 UX

管理画面の `/admin/:tenantId/sync` と App Home に「Slack Lists 同期コンフリクト」として出す。

表示内容:

- task title / task id
- Slack list item id
- conflict type
- 変更 field
- DB 側の値
- Slack Lists 側の値
- 最終同期時の値
- Slack user と解決済み handler
- 発生時刻
- 推奨アクション

解決アクション:

| アクション                     | 内容                                                               |
| ------------------------------ | ------------------------------------------------------------------ |
| DB を正として Slack を上書き   | DB の現在値を Slack Lists に再投影し、conflict を解消する          |
| Slack Lists 側を DB に取り込む | Slack 側の変更を validation 後に DB へ反映する                     |
| 手動 merge                     | operator が field ごとに値を選び、DB 更新後に Slack を再投影する   |
| handler 紐づけを修正して再同期 | unknown Slack user を handler に紐づけてから取り込みを再試行する   |
| Slack 側変更を破棄             | Slack item を DB の現在値で上書きする                              |
| 今回は無視                     | conflict を resolved とするが、DB/Slack は変更しない。監査ログ必須 |

### 7.5 解決ルール

- 原則は DB wins。
- Slack Lists は shared view なので、業務上重要な状態は DB/API 経由を優先する。
- `status`, `owner`, `due`, `priority` だけは Slack Lists 側からの軽微編集として取り込み可能にする。
- 取り込み可能な場合でも、GraphQL mutation と同じ validation、permission、audit を通す。
- conflict 解決は `audit_logs` に `slack_list_conflict.resolve` として残す。
- 解決後は `slack_task_list_links.last_synced_task_version` と `last_seen_list_hash` を更新する。

## 8. Mastra Runtime

Mastra は DB を直接自由に触るのではなく、tenant scope の typed tool だけを使う。

```text
apps/agent-runtime/
  src/
    mastra.ts
    tenants/loadTenantRuntime.ts
    tenants/tenantSecrets.ts
    agents/conversationAgent.ts
    agents/replyDraftAgent.ts
    agents/summaryAgent.ts
    agents/taskAgent.ts
    agents/featureRequestAgent.ts
    agents/operatorCopilotAgent.ts
    workflows/conversationUnderstanding.workflow.ts
    workflows/replyDraft.workflow.ts
    workflows/threadSummary.workflow.ts
    workflows/taskCandidate.workflow.ts
    workflows/featureRequestIntake.workflow.ts
    workflows/morningDigest.workflow.ts
    tools/conversations.tools.ts
    tools/messageSearch.tools.ts
    tools/memoryRetrieval.tools.ts
    tools/customers.tools.ts
    tools/tasks.tools.ts
    tools/featureRequests.tools.ts
    tools/replyDrafts.tools.ts
    tools/policies.tools.ts
    tools/audit.tools.ts
```

主な workflow:

- `conversationUnderstanding`
- `replyDraft`
- `threadSummary`
- `taskCandidate`
- `featureRequestIntake`
- `morningDigest`
- `operatorCopilot`
- `slackTaskListReconcile`

主な tool:

| Tool                              | 許可操作                                          | 禁止操作                                |
| --------------------------------- | ------------------------------------------------- | --------------------------------------- |
| `getConversationContext`          | tenant 内の会話、顧客、過去要約を取得             | agent に tenant_id を指定させない       |
| `searchConversationLogs`          | full-text search で過去 message を検索            | raw log 全件を context に投入しない     |
| `getConversationTimeline`         | conversation/customer の時系列 window を取得      | tenant/customer 境界を跨がない          |
| `getMessageById`                  | message ID から単一 message と周辺 context を取得 | ID があっても tenant 不一致なら返さない |
| `getMessagesByIds`                | 根拠 message ids をまとめて取得                   | 権限のない message を黙って混ぜない     |
| `createReplyDraft`                | 返信案を draft として保存                         | LINE に直接送信しない                   |
| `createTaskCandidate`             | タスク候補を保存し重複候補を紐づける              | agent 単独で正式タスクにしない          |
| `summarizeThread`                 | 要約と根拠 message ids を保存                     | 要約のみを事実として扱わない            |
| `registerFeatureRequestCandidate` | 要望候補、重複、影響、優先度案を保存              | product backlog へ直接確定しない        |

### 8.1 Memory / 会話ログ検索 tool 設計

Mastra の memory は、thread と message history、resource scoped working memory、semantic recall を組み合わせる考え方に寄せる。Hermes の memory 構造からは、常時注入する短い記憶と、必要時に検索する session search を分ける考え方を採用する。

このアプリでは以下の 3 層に分ける。

| 層                  | 役割                                                                            | 実装                                                      |
| ------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------- |
| Working memory      | tenant/customer/operator に関する短い重要事実。毎回の判断に必要なものだけを注入 | `customer_memory_cards`, `conversation_summaries`         |
| Episodic log search | 過去の会話ログを必要時に検索する層。常時 prompt に入れない                      | `messages.search_vector`, `message_search_documents` 相当 |
| Semantic recall     | 表現ゆれや意味検索が必要な場合の補助                                            | `message_embeddings` または外部 vector store              |

Mastra の `resourceId` は原則 `customer_id`、`threadId` は `conversation_id` に対応させる。tenant 横断を避けるため、tool 実行時の tenant scope は runtime 側で固定し、agent prompt から tenant_id を指定させない。

### 8.2 追加する Mastra tools

#### `searchConversationLogs`

目的:

- operator が「前にこの話をしたか」「同じ要望が過去にあるか」を探す。
- reply draft / summary / task candidate workflow が、根拠となる過去 message を検索する。

入力:

```ts
type SearchConversationLogsInput = {
  query: string;
  customerId?: string;
  conversationId?: string;
  senderTypes?: Array<"customer" | "operator" | "agent" | "system">;
  from?: string;
  to?: string;
  limit?: number;
  mode?: "full_text" | "semantic" | "hybrid";
};
```

出力:

```ts
type SearchConversationLogsResult = {
  hits: Array<{
    messageId: string;
    conversationId: string;
    customerId?: string;
    sentAt: string;
    senderType: string;
    snippet: string;
    score: number;
    matchReason: "full_text" | "semantic" | "hybrid";
  }>;
};
```

制約:

- default limit は 20。
- snippet は前後を短く切り出し、長文 raw body は必要時に `getMessageById` で取る。
- agent run ごとに検索 query、hit ids、利用 workflow を `message_retrieval_events` に記録する。

#### `getConversationTimeline`

目的:

- 会話を時系列で復元する。
- reply draft の直前 context、引き継ぎ、炎上リスク確認で使う。

入力:

```ts
type GetConversationTimelineInput = {
  conversationId?: string;
  customerId?: string;
  anchorMessageId?: string;
  from?: string;
  to?: string;
  before?: number;
  after?: number;
  limit?: number;
};
```

出力:

```ts
type ConversationTimelineResult = {
  conversationId?: string;
  customerId?: string;
  anchorMessageId?: string;
  messages: Array<{
    id: string;
    senderType: string;
    body: string;
    sentAt: string;
    providerMessageId?: string;
  }>;
  hasMoreBefore: boolean;
  hasMoreAfter: boolean;
};
```

制約:

- `anchorMessageId` がある場合は、その message の前後 window を返す。
- `conversationId` がない customer timeline は複数 conversation をまたぐため、source conversation を必ず付ける。
- default は `before=20`, `after=20`。

#### `getMessageById`

目的:

- 要約や返信案の根拠 message を ID で正確に再取得する。
- agent が検索 hit の詳細を確認する。

入力:

```ts
type GetMessageByIdInput = {
  messageId: string;
  includeContext?: boolean;
  contextBefore?: number;
  contextAfter?: number;
};
```

出力:

```ts
type MessageWithContext = {
  message: {
    id: string;
    conversationId: string;
    customerId?: string;
    senderType: string;
    body: string;
    sentAt: string;
    providerMessageId?: string;
  };
  context?: ConversationTimelineResult;
};
```

制約:

- tenant 不一致、削除済み、権限なしの message は `NOT_FOUND` として扱う。
- `includeContext=true` の場合でも最大 10 + 10 件までにする。

#### `getMessagesByIds`

目的:

- `evidenceMessageIds` をまとめて復元する。
- 要約・タスク候補・機能要望候補の根拠確認に使う。

制約:

- 入力 ID の順序を保つ。
- 取得できない ID は `missingIds` に分ける。
- 1 回の上限は 100 件。

### 8.3 Memory への保存ポリシー

- raw message 全文を working memory に保存しない。
- working memory には「顧客の継続的な希望」「対応方針」「既知の制約」「未解決リスク」のような短い fact だけを保存する。
- 会話全文は DB + full-text search の episodic log として保存する。
- semantic recall は full-text search で拾えない表現ゆれの補助に使い、必ず message ID を返す。
- agent が生成した summary / draft / task candidate は、根拠となる `evidence_message_ids` を持つ。
- message retrieval tool の結果を使った agent run は、どの message を読んだかを監査できるようにする。

## 9. GraphQL API 方針

### 9.1 Endpoint

```text
POST /graphql
GET  /graphql      -- playground は non-production のみ
```

外部 provider endpoint:

```text
POST /webhooks/line/:tenantKey
GET  /oauth/slack/callback
POST /webhooks/slack/interactivity
POST /webhooks/slack/events
GET  /healthz
```

### 9.2 認証

管理画面:

- session cookie または OIDC JWT
- GraphQL context に `actor`, `tenantMemberships`, `requestId` を入れる

Slack:

- Slack signature を検証
- `team_id`, `user_id` から tenant と handler を解決
- 解決不能なら ephemeral error

LINE:

- LINE signature を検証
- channel secret から tenant を解決

### 9.3 GraphQL 設計ルール

- `tenantId` は Query/Mutation input に持たせるが、resolver で actor の所属 tenant と照合する。
- tenant scoped resolver は `TenantUsecaseContext` を作り、usecase へ `tenantId` を必ず渡す。
- admin resolver は `AdminUsecaseContext` を使える。tenant 横断の admin operation だけ tenantId なしを許可する。
- admin が tenant data を操作する場合は、target tenant の権限を検証したうえで tenant scoped usecase を呼ぶ。
- すべての DB read/write は tenant guard を通す。
- repository は `tenantDbFactory.forTenant(ctx.tenantId)` から作った tenant scoped ORM client を使う。
- GraphQL resolver、usecase、Mastra tool から raw ORM client を直接使わない。
- mutation は audit log を必ず作る。
- 外部送信は mutation 直送ではなく queue に積む。
- 一覧は cursor pagination にする。
- agent 実行は synchronous response ではなく `AgentRun` を返し、MVP では polling で結果を追う。GraphQL Subscription は運用が必要になった段階の拡張とし、初期 schema には入れない。

## 10. GraphQL Schema

```graphql
scalar DateTime
scalar JSON
scalar UUID

enum Role {
  OWNER
  ADMIN
  MANAGER
  OPERATOR
  VIEWER
}

enum HandlerStatus {
  ACTIVE
  INVITED
  SUSPENDED
  DELETED
}

enum TaskStatus {
  OPEN
  IN_PROGRESS
  WAITING
  DONE
  CANCELED
}

enum TaskPriority {
  P0
  P1
  P2
  P3
}

enum TaskSourceType {
  LINE_CONVERSATION
  SLACK_MESSAGE
  MANUAL
  AGENT
}

enum CandidateStatus {
  PROPOSED
  ACCEPTED
  REJECTED
  MERGED
}

enum ReplyDraftStatus {
  DRAFT
  NEEDS_REVIEW
  APPROVED
  REJECTED
  QUEUED
  SENT
}

enum RiskLevel {
  LOW
  MEDIUM
  HIGH
}

enum AgentWorkflowType {
  CONVERSATION_UNDERSTANDING
  REPLY_DRAFT
  THREAD_SUMMARY
  TASK_CANDIDATE
  FEATURE_REQUEST_INTAKE
  MORNING_DIGEST
  OPERATOR_COPILOT
  SLACK_TASK_LIST_RECONCILE
}

enum AgentRunStatus {
  QUEUED
  RUNNING
  SUCCEEDED
  FAILED
  CANCELED
}

enum SyncStatus {
  OK
  PENDING
  CONFLICT
  FAILED
}

enum SlackListConflictType {
  BOTH_CHANGED
  UNKNOWN_SLACK_USER
  INVALID_STATUS_TRANSITION
  PERMISSION_DENIED
  PROTECTED_FIELD_CHANGED
  SLACK_ITEM_DELETED
  MAPPING_MISMATCH
}

enum SlackListConflictResolution {
  DB_WINS
  SLACK_WINS
  MANUAL_MERGE
  LINK_HANDLER_AND_RETRY
  DISCARD_SLACK_CHANGE
  IGNORE
}

enum SlackListConflictStatus {
  OPEN
  RESOLVED
  IGNORED
}

enum MessageSearchMode {
  FULL_TEXT
  SEMANTIC
  HYBRID
}

enum MessageMatchReason {
  FULL_TEXT
  SEMANTIC
  HYBRID
}

type Tenant {
  id: ID!
  name: String!
  slug: String!
  status: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Handler {
  id: ID!
  tenantId: ID!
  displayName: String!
  role: Role!
  status: HandlerStatus!
  externalAccounts: [HandlerExternalAccount!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type HandlerExternalAccount {
  id: ID!
  tenantId: ID!
  handlerId: ID!
  provider: String!
  providerWorkspaceId: String
  providerAccountId: String!
  displayName: String
  isPrimary: Boolean!
  connectedAt: DateTime!
}

type Customer {
  id: ID!
  tenantId: ID!
  displayName: String!
  externalLineUserId: String
  status: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Conversation {
  id: ID!
  tenantId: ID!
  customer: Customer
  channel: String!
  status: String!
  lastMessageAt: DateTime
  messages(first: Int, after: String): MessageConnection!
  latestSummary: ConversationSummary
}

type Message {
  id: ID!
  tenantId: ID!
  conversationId: ID!
  conversation: Conversation
  customer: Customer
  senderType: String!
  body: String!
  providerMessageId: String
  sentAt: DateTime!
  createdAt: DateTime!
}

type MessageSearchHit {
  message: Message!
  snippet: String!
  score: Float!
  matchReason: MessageMatchReason!
}

type ConversationSummary {
  id: ID!
  tenantId: ID!
  conversationId: ID!
  summary: String!
  evidenceMessageIds: [ID!]!
  agentRunId: ID
  createdAt: DateTime!
}

type InternalTask {
  id: ID!
  tenantId: ID!
  title: String!
  description: String
  status: TaskStatus!
  priority: TaskPriority!
  sourceType: TaskSourceType!
  sourceId: ID
  customer: Customer
  conversation: Conversation
  dueAt: DateTime
  assignees: [TaskAssignment!]!
  latestSlackListLink: SlackTaskListLink
  version: Int!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type TaskAssignment {
  id: ID!
  tenantId: ID!
  taskId: ID!
  assignee: Handler!
  assignedByActorId: ID
  assignedAt: DateTime!
  unassignedAt: DateTime
}

type TaskStatusEvent {
  id: ID!
  tenantId: ID!
  taskId: ID!
  fromStatus: TaskStatus
  toStatus: TaskStatus!
  actorType: String!
  actorId: ID
  reason: String
  createdAt: DateTime!
}

type TaskCandidate {
  id: ID!
  tenantId: ID!
  sourceType: TaskSourceType!
  sourceId: ID
  proposedTitle: String!
  proposedDescription: String
  proposedPriority: TaskPriority!
  proposedDueAt: DateTime
  confidence: Float!
  duplicateTask: InternalTask
  agentRunId: ID
  status: CandidateStatus!
  reviewedByActorId: ID
  reviewedAt: DateTime
  createdAt: DateTime!
}

type ReplyDraft {
  id: ID!
  tenantId: ID!
  conversation: Conversation!
  body: String!
  rationale: String
  riskLevel: RiskLevel!
  policyWarnings: [String!]!
  status: ReplyDraftStatus!
  agentRunId: ID
  approvedByActorId: ID
  createdAt: DateTime!
  updatedAt: DateTime!
}

type OutboundMessage {
  id: ID!
  tenantId: ID!
  provider: String!
  recipientRef: String!
  body: String!
  status: String!
  approvedByActorId: ID
  sentAt: DateTime
  error: String
  createdAt: DateTime!
}

type FeatureRequest {
  id: ID!
  tenantId: ID!
  title: String!
  description: String!
  sourceType: String!
  sourceId: ID
  customer: Customer
  priority: String!
  status: String!
  duplicateOfId: ID
  agentRunId: ID
  createdAt: DateTime!
  updatedAt: DateTime!
}

type LineChannelIntegration {
  id: ID!
  tenantId: ID!
  channelId: String!
  channelName: String
  webhookUrl: String
  webhookVerifiedAt: DateTime
  status: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type TenantAgentConfig {
  tenantId: ID!
  runtimeMode: String!
  mastraProfileId: String
  containerName: String
  volumeName: String
  modelProvider: String!
  modelName: String!
  policyVersion: String!
  enabledWorkflows: [AgentWorkflowType!]!
  humanApprovalPolicy: JSON!
  updatedAt: DateTime!
}

type AgentRun {
  id: ID!
  tenantId: ID!
  workflowType: AgentWorkflowType!
  triggerType: String!
  triggerId: ID
  status: AgentRunStatus!
  inputHash: String
  outputRef: String
  error: String
  startedAt: DateTime
  finishedAt: DateTime
  createdAt: DateTime!
}

type SlackWorkspaceIntegration {
  id: ID!
  tenantId: ID!
  slackTeamId: String!
  slackTeamName: String
  botUserId: String
  defaultChannelId: String
  taskListId: String
  appHomeEnabled: Boolean!
  listsSyncEnabled: Boolean!
  pollingIntervalSeconds: Int!
  status: String!
  installedAt: DateTime!
  updatedAt: DateTime!
}

type SlackTaskListLinkConfig {
  tenantId: ID!
  slackTeamId: String!
  slackListId: String!
  defaultChannelId: String
  listsSyncEnabled: Boolean!
  pollingIntervalSeconds: Int!
  updatedAt: DateTime!
}

type SlackTaskListLink {
  id: ID!
  tenantId: ID!
  taskId: ID!
  slackTeamId: String!
  slackChannelId: String
  slackListId: String!
  slackListItemId: String!
  lastSyncedTaskVersion: Int!
  lastSeenListHash: String
  syncStatus: SyncStatus!
  syncError: String
  lastSyncedAt: DateTime
}

type SlackTaskListConflict {
  id: ID!
  tenantId: ID!
  task: InternalTask!
  link: SlackTaskListLink!
  conflictType: SlackListConflictType!
  status: SlackListConflictStatus!
  fieldDiffs: JSON!
  dbSnapshot: JSON!
  slackSnapshot: JSON!
  lastSyncedSnapshot: JSON!
  slackUserId: String
  resolvedHandler: Handler
  recommendedResolution: SlackListConflictResolution
  resolvedByActorId: ID
  resolvedAt: DateTime
  createdAt: DateTime!
}

type AdminDashboard {
  tenant: Tenant!
  openTaskCount: Int!
  overdueTaskCount: Int!
  unassignedTaskCount: Int!
  p0p1TaskCount: Int!
  pendingReplyDraftCount: Int!
  failedOutboundCount: Int!
  failedSlackSyncCount: Int!
  failedAgentRunCount: Int!
  morningDigest: MorningDigest
}

type MorningDigest {
  id: ID!
  tenantId: ID!
  digestDate: String!
  body: String!
  sections: JSON!
  agentRunId: ID
  createdAt: DateTime!
}

type AuditLog {
  id: ID!
  tenantId: ID!
  actorType: String!
  actorId: ID
  action: String!
  targetType: String!
  targetId: ID
  beforeRedacted: JSON
  afterRedacted: JSON
  requestId: String
  agentRunId: ID
  createdAt: DateTime!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

type InternalTaskEdge {
  cursor: String!
  node: InternalTask!
}

type TaskConnection {
  edges: [InternalTaskEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type MessageEdge {
  cursor: String!
  node: Message!
}

type MessageConnection {
  edges: [MessageEdge!]!
  pageInfo: PageInfo!
}

type MessageSearchEdge {
  cursor: String!
  node: MessageSearchHit!
}

type MessageSearchConnection {
  edges: [MessageSearchEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type ConversationTimeline {
  tenantId: ID!
  conversationId: ID
  customerId: ID
  anchorMessageId: ID
  messages: [Message!]!
  hasMoreBefore: Boolean!
  hasMoreAfter: Boolean!
}

input HandlerFilter {
  roles: [Role!]
  statuses: [HandlerStatus!]
  search: String
}

input CreateHandlerInput {
  tenantId: ID!
  displayName: String!
  role: Role!
  status: HandlerStatus = ACTIVE
}

input UpdateHandlerInput {
  tenantId: ID!
  handlerId: ID!
  displayName: String
  role: Role
  status: HandlerStatus
}

input LinkHandlerExternalAccountInput {
  tenantId: ID!
  handlerId: ID!
  provider: String!
  providerWorkspaceId: String
  providerAccountId: String!
  displayName: String
  isPrimary: Boolean = false
}

input MessageSearchFilter {
  query: String!
  mode: MessageSearchMode = FULL_TEXT
  customerId: ID
  conversationId: ID
  senderTypes: [String!]
  from: DateTime
  to: DateTime
}

input ConversationTimelineInput {
  tenantId: ID!
  conversationId: ID
  customerId: ID
  anchorMessageId: ID
  from: DateTime
  to: DateTime
  before: Int = 20
  after: Int = 20
  limit: Int = 50
}

input MessagesByIdsInput {
  tenantId: ID!
  messageIds: [ID!]!
}

input TaskFilter {
  statuses: [TaskStatus!]
  priorities: [TaskPriority!]
  assigneeHandlerIds: [ID!]
  sourceTypes: [TaskSourceType!]
  customerId: ID
  conversationId: ID
  dueFrom: DateTime
  dueTo: DateTime
  hasSlackSyncConflict: Boolean
  createdByAgent: Boolean
  search: String
}

input CreateTaskInput {
  tenantId: ID!
  title: String!
  description: String
  priority: TaskPriority = P2
  sourceType: TaskSourceType = MANUAL
  sourceId: ID
  customerId: ID
  conversationId: ID
  dueAt: DateTime
  assigneeHandlerIds: [ID!]
}

input UpdateTaskInput {
  tenantId: ID!
  taskId: ID!
  title: String
  description: String
  priority: TaskPriority
  dueAt: DateTime
  expectedVersion: Int!
}

input AssignTaskInput {
  tenantId: ID!
  taskId: ID!
  assigneeHandlerId: ID!
  reason: String
}

input TransitionTaskStatusInput {
  tenantId: ID!
  taskId: ID!
  toStatus: TaskStatus!
  reason: String
  expectedVersion: Int!
}

input GenerateReplyDraftInput {
  tenantId: ID!
  conversationId: ID!
  instruction: String
}

input UpdateReplyDraftInput {
  tenantId: ID!
  replyDraftId: ID!
  body: String!
}

input ApproveReplyDraftInput {
  tenantId: ID!
  replyDraftId: ID!
}

input EnqueueApprovedReplyInput {
  tenantId: ID!
  replyDraftId: ID!
}

input CreateFeatureRequestInput {
  tenantId: ID!
  title: String!
  description: String!
  sourceType: String!
  sourceId: ID
  customerId: ID
  priority: String = "medium"
}

input UpdateFeatureRequestInput {
  tenantId: ID!
  featureRequestId: ID!
  title: String
  description: String
  priority: String
  status: String
  duplicateOfId: ID
}

input RunAgentWorkflowInput {
  tenantId: ID!
  workflowType: AgentWorkflowType!
  triggerType: String!
  triggerId: ID
  payload: JSON
}

input UpdateTenantAgentConfigInput {
  tenantId: ID!
  runtimeMode: String
  mastraProfileId: String
  containerName: String
  volumeName: String
  modelProvider: String
  modelName: String
  policyVersion: String
  enabledWorkflows: [AgentWorkflowType!]
  humanApprovalPolicy: JSON
}

input ConfigureSlackIntegrationInput {
  tenantId: ID!
  defaultChannelId: String
  taskListId: String
  appHomeEnabled: Boolean
  listsSyncEnabled: Boolean
  pollingIntervalSeconds: Int
}

input ConfigureLineIntegrationInput {
  tenantId: ID!
  channelId: String!
  channelName: String
  channelSecretRef: String!
  accessTokenSecretRef: String!
  webhookUrl: String
  status: String = "active"
}

input ConnectSlackTaskListInput {
  tenantId: ID!
  slackListId: String!
  defaultChannelId: String
  listsSyncEnabled: Boolean = true
  pollingIntervalSeconds: Int = 180
}

input SyncSlackTaskListInput {
  tenantId: ID!
  slackListId: String!
  force: Boolean = false
}

input ResolveSlackListConflictInput {
  tenantId: ID!
  conflictId: ID!
  resolution: SlackListConflictResolution!
  mergedFields: JSON
  linkHandlerId: ID
  reason: String
}

input GenerateMorningDigestInput {
  tenantId: ID!
  digestDate: String
  force: Boolean = false
}

type BulkUpdateTasksPayload {
  updatedCount: Int!
  failedIds: [ID!]!
  conflicts: [BulkTaskConflict!]!
}

type BulkTaskConflict {
  taskId: ID!
  expectedVersion: Int
  currentVersion: Int!
  reason: String!
}

input BulkTaskVersionInput {
  taskId: ID!
  expectedVersion: Int!
}

input BulkUpdateTasksInput {
  tenantId: ID!
  tasks: [BulkTaskVersionInput!]!
  status: TaskStatus
  priority: TaskPriority
  assigneeHandlerId: ID
  dueAt: DateTime
}

type RetryProjectionJobsPayload {
  enqueuedCount: Int!
}

input RetryProjectionJobsInput {
  tenantId: ID!
  projectionTypes: [String!]
}

type SyncJob {
  id: ID!
  tenantId: ID!
  status: String!
  createdAt: DateTime!
}

type Query {
  me: Handler
  tenant(id: ID!): Tenant
  adminDashboard(tenantId: ID!): AdminDashboard!

  handlers(tenantId: ID!, filter: HandlerFilter): [Handler!]!

  tasks(
    tenantId: ID!
    filter: TaskFilter
    first: Int = 50
    after: String
  ): TaskConnection!
  task(tenantId: ID!, id: ID!): InternalTask
  taskCandidates(tenantId: ID!, status: CandidateStatus): [TaskCandidate!]!

  customers(tenantId: ID!, search: String): [Customer!]!
  conversation(tenantId: ID!, id: ID!): Conversation
  conversations(tenantId: ID!, customerId: ID, status: String): [Conversation!]!
  message(tenantId: ID!, id: ID!): Message
  messagesByIds(input: MessagesByIdsInput!): [Message!]!
  conversationTimeline(input: ConversationTimelineInput!): ConversationTimeline!
  searchMessages(
    tenantId: ID!
    filter: MessageSearchFilter!
    first: Int = 20
    after: String
  ): MessageSearchConnection!

  replyDrafts(
    tenantId: ID!
    status: ReplyDraftStatus
    conversationId: ID
  ): [ReplyDraft!]!

  featureRequests(tenantId: ID!, status: String): [FeatureRequest!]!
  tenantAgentConfig(tenantId: ID!): TenantAgentConfig!
  agentRun(tenantId: ID!, id: ID!): AgentRun
  agentRuns(tenantId: ID!, workflowType: AgentWorkflowType): [AgentRun!]!

  lineIntegration(tenantId: ID!): LineChannelIntegration
  slackIntegration(tenantId: ID!): SlackWorkspaceIntegration
  slackTaskListLinks(
    tenantId: ID!
    syncStatus: SyncStatus
  ): [SlackTaskListLink!]!
  slackTaskListConflicts(
    tenantId: ID!
    status: SlackListConflictStatus = OPEN
  ): [SlackTaskListConflict!]!

  auditLogs(
    tenantId: ID!
    actorId: ID
    action: String
    targetType: String
    targetId: ID
    from: DateTime
    to: DateTime
  ): [AuditLog!]!
}

type Mutation {
  createHandler(input: CreateHandlerInput!): Handler!
  updateHandler(input: UpdateHandlerInput!): Handler!
  linkHandlerExternalAccount(
    input: LinkHandlerExternalAccountInput!
  ): HandlerExternalAccount!

  createTask(input: CreateTaskInput!): InternalTask!
  updateTask(input: UpdateTaskInput!): InternalTask!
  assignTask(input: AssignTaskInput!): InternalTask!
  transitionTaskStatus(input: TransitionTaskStatusInput!): InternalTask!
  bulkUpdateTasks(input: BulkUpdateTasksInput!): BulkUpdateTasksPayload!

  acceptTaskCandidate(tenantId: ID!, candidateId: ID!): InternalTask!
  rejectTaskCandidate(
    tenantId: ID!
    candidateId: ID!
    reason: String
  ): TaskCandidate!

  generateReplyDraft(input: GenerateReplyDraftInput!): ReplyDraft!
  updateReplyDraft(input: UpdateReplyDraftInput!): ReplyDraft!
  approveReplyDraft(input: ApproveReplyDraftInput!): ReplyDraft!
  enqueueApprovedReply(input: EnqueueApprovedReplyInput!): OutboundMessage!

  createFeatureRequest(input: CreateFeatureRequestInput!): FeatureRequest!
  updateFeatureRequest(input: UpdateFeatureRequestInput!): FeatureRequest!

  runAgentWorkflow(input: RunAgentWorkflowInput!): AgentRun!
  updateTenantAgentConfig(
    input: UpdateTenantAgentConfigInput!
  ): TenantAgentConfig!
  generateMorningDigest(input: GenerateMorningDigestInput!): MorningDigest!

  configureLineIntegration(
    input: ConfigureLineIntegrationInput!
  ): LineChannelIntegration!
  configureSlackIntegration(
    input: ConfigureSlackIntegrationInput!
  ): SlackWorkspaceIntegration!
  connectSlackTaskList(
    input: ConnectSlackTaskListInput!
  ): SlackTaskListLinkConfig!
  syncSlackTaskList(input: SyncSlackTaskListInput!): SyncJob!
  resolveSlackListConflict(
    input: ResolveSlackListConflictInput!
  ): SlackTaskListConflict!
  retryFailedProjectionJobs(
    input: RetryProjectionJobsInput!
  ): RetryProjectionJobsPayload!
}
```

## 11. GraphQL Query / Mutation 例

### 11.1 管理画面ダッシュボード

```graphql
query AdminDashboard($tenantId: ID!) {
  adminDashboard(tenantId: $tenantId) {
    tenant {
      id
      name
    }
    openTaskCount
    overdueTaskCount
    unassignedTaskCount
    p0p1TaskCount
    pendingReplyDraftCount
    failedOutboundCount
    failedSlackSyncCount
    failedAgentRunCount
    morningDigest {
      id
      digestDate
      body
    }
  }
}
```

### 11.2 タスク一覧

```graphql
query Tasks($tenantId: ID!, $filter: TaskFilter, $after: String) {
  tasks(tenantId: $tenantId, filter: $filter, first: 50, after: $after) {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      node {
        id
        title
        status
        priority
        dueAt
        version
        assignees {
          assignee {
            id
            displayName
          }
        }
        latestSlackListLink {
          syncStatus
          syncError
          lastSyncedAt
        }
      }
    }
  }
}
```

Variables:

```json
{
  "tenantId": "tenant_001",
  "filter": {
    "statuses": ["OPEN", "IN_PROGRESS"],
    "priorities": ["P0", "P1"],
    "hasSlackSyncConflict": false
  }
}
```

### 11.3 タスク作成

```graphql
mutation CreateTask($input: CreateTaskInput!) {
  createTask(input: $input) {
    id
    title
    status
    priority
    dueAt
    version
  }
}
```

Variables:

```json
{
  "input": {
    "tenantId": "tenant_001",
    "title": "LINE問い合わせの返答案を確認する",
    "description": "顧客Aの問い合わせに対する返答案を確認して送信する",
    "priority": "P1",
    "sourceType": "LINE_CONVERSATION",
    "conversationId": "conv_001",
    "assigneeHandlerIds": ["handler_001"]
  }
}
```

### 11.4 担当者作成と Slack user 紐づけ

```graphql
mutation CreateHandler($input: CreateHandlerInput!) {
  createHandler(input: $input) {
    id
    displayName
    role
    status
  }
}
```

Variables:

```json
{
  "input": {
    "tenantId": "tenant_001",
    "displayName": "山田 太郎",
    "role": "OPERATOR"
  }
}
```

作成後に返った handler ID を使って Slack user を紐づける。

```graphql
mutation LinkSlackAccount($input: LinkHandlerExternalAccountInput!) {
  linkHandlerExternalAccount(input: $input) {
    id
    provider
    providerWorkspaceId
    providerAccountId
    displayName
    isPrimary
  }
}
```

Variables:

```json
{
  "input": {
    "tenantId": "tenant_001",
    "handlerId": "handler_001",
    "provider": "slack",
    "providerWorkspaceId": "T012ABCDEF",
    "providerAccountId": "U012ABCDEF",
    "displayName": "taro.yamada",
    "isPrimary": true
  }
}
```

### 11.5 LINE / Slack 連携設定

```graphql
mutation ConfigureIntegrations(
  $line: ConfigureLineIntegrationInput!
  $slack: ConfigureSlackIntegrationInput!
  $list: ConnectSlackTaskListInput!
) {
  configureLineIntegration(input: $line) {
    id
    channelId
    status
    webhookUrl
  }
  configureSlackIntegration(input: $slack) {
    id
    slackTeamId
    defaultChannelId
    appHomeEnabled
    listsSyncEnabled
  }
  connectSlackTaskList(input: $list) {
    slackListId
    listsSyncEnabled
    pollingIntervalSeconds
  }
}
```

### 11.6 担当者変更

```graphql
mutation AssignTask($input: AssignTaskInput!) {
  assignTask(input: $input) {
    id
    title
    assignees {
      assignee {
        id
        displayName
      }
      assignedAt
    }
    version
  }
}
```

### 11.7 Status 変更

```graphql
mutation TransitionTaskStatus($input: TransitionTaskStatusInput!) {
  transitionTaskStatus(input: $input) {
    id
    status
    version
    updatedAt
  }
}
```

Variables:

```json
{
  "input": {
    "tenantId": "tenant_001",
    "taskId": "task_001",
    "toStatus": "DONE",
    "reason": "LINE返信送信済み",
    "expectedVersion": 3
  }
}
```

### 11.8 会話詳細と返信案

```graphql
query ConversationDetail($tenantId: ID!, $conversationId: ID!) {
  conversation(tenantId: $tenantId, id: $conversationId) {
    id
    channel
    status
    customer {
      id
      displayName
    }
    latestSummary {
      summary
      evidenceMessageIds
    }
    messages(first: 50) {
      edges {
        node {
          id
          senderType
          body
          sentAt
        }
      }
    }
  }
  replyDrafts(tenantId: $tenantId, conversationId: $conversationId) {
    id
    body
    riskLevel
    policyWarnings
    status
  }
}
```

### 11.9 会話ログ full-text search

```graphql
query SearchMessages(
  $tenantId: ID!
  $filter: MessageSearchFilter!
  $after: String
) {
  searchMessages(
    tenantId: $tenantId
    filter: $filter
    first: 20
    after: $after
  ) {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      node {
        snippet
        score
        matchReason
        message {
          id
          conversationId
          senderType
          body
          sentAt
          customer {
            id
            displayName
          }
        }
      }
    }
  }
}
```

Variables:

```json
{
  "tenantId": "tenant_001",
  "filter": {
    "query": "返金 領収書",
    "mode": "FULL_TEXT",
    "customerId": "customer_001",
    "from": "2026-05-01T00:00:00+09:00",
    "to": "2026-05-13T23:59:59+09:00"
  }
}
```

### 11.10 message ID と時系列 window 取得

```graphql
query MessageEvidence(
  $tenantId: ID!
  $messageId: ID!
  $timeline: ConversationTimelineInput!
) {
  message(tenantId: $tenantId, id: $messageId) {
    id
    conversationId
    senderType
    body
    sentAt
  }
  conversationTimeline(input: $timeline) {
    anchorMessageId
    hasMoreBefore
    hasMoreAfter
    messages {
      id
      senderType
      body
      sentAt
    }
  }
}
```

Variables:

```json
{
  "tenantId": "tenant_001",
  "messageId": "msg_001",
  "timeline": {
    "tenantId": "tenant_001",
    "anchorMessageId": "msg_001",
    "before": 10,
    "after": 10
  }
}
```

### 11.11 返信案生成

```graphql
mutation GenerateReplyDraft($input: GenerateReplyDraftInput!) {
  generateReplyDraft(input: $input) {
    id
    body
    rationale
    riskLevel
    policyWarnings
    status
  }
}
```

### 11.12 返信承認と送信 queue 投入

```graphql
mutation ApproveAndEnqueue(
  $approve: ApproveReplyDraftInput!
  $enqueue: EnqueueApprovedReplyInput!
) {
  approveReplyDraft(input: $approve) {
    id
    status
    approvedByActorId
  }
  enqueueApprovedReply(input: $enqueue) {
    id
    provider
    status
  }
}
```

### 11.13 Slack Lists 再同期

```graphql
mutation SyncSlackTaskList($input: SyncSlackTaskListInput!) {
  syncSlackTaskList(input: $input) {
    id
    tenantId
    status
    createdAt
  }
}
```

### 11.14 Slack Lists コンフリクト一覧

```graphql
query SlackListConflicts($tenantId: ID!) {
  slackTaskListConflicts(tenantId: $tenantId, status: OPEN) {
    id
    conflictType
    fieldDiffs
    dbSnapshot
    slackSnapshot
    lastSyncedSnapshot
    recommendedResolution
    slackUserId
    resolvedHandler {
      id
      displayName
    }
    task {
      id
      title
      status
      priority
      version
    }
    createdAt
  }
}
```

### 11.15 Slack Lists コンフリクト解決

```graphql
mutation ResolveSlackListConflict($input: ResolveSlackListConflictInput!) {
  resolveSlackListConflict(input: $input) {
    id
    status
    conflictType
    resolvedAt
    task {
      id
      title
      status
      priority
      version
      latestSlackListLink {
        syncStatus
        lastSyncedAt
      }
    }
  }
}
```

Variables:

```json
{
  "input": {
    "tenantId": "tenant_001",
    "conflictId": "conflict_001",
    "resolution": "MANUAL_MERGE",
    "mergedFields": {
      "status": "IN_PROGRESS",
      "priority": "P1",
      "dueAt": "2026-05-15T18:00:00+09:00"
    },
    "reason": "Slack側の期限変更だけ取り込み、statusはDB側を維持"
  }
}
```

### 11.16 Mastra workflow 実行

```graphql
mutation RunAgentWorkflow($input: RunAgentWorkflowInput!) {
  runAgentWorkflow(input: $input) {
    id
    workflowType
    status
    createdAt
  }
}
```

Variables:

```json
{
  "input": {
    "tenantId": "tenant_001",
    "workflowType": "CONVERSATION_UNDERSTANDING",
    "triggerType": "conversation",
    "triggerId": "conv_001"
  }
}
```

## 12. DB 詳細 Schema

DB は PostgreSQL を想定する。ID は `uuid`、日時は `timestamptz`、柔軟な payload は `jsonb` とする。

### 12.1 共通方針

- 全 tenant scoped table は `tenant_id uuid not null` を持つ。
- 外部 secret 本体は DB に保存せず、`secret_ref` のみ保存する。
- 重要 table は `created_at`, `updated_at` を持つ。
- state change は event table と audit log に残す。
- optimistic lock が必要な table は `version int not null default 1` を持つ。

### 12.2 DDL

```sql
create table tenants (
  id uuid primary key,
  name text not null,
  slug text not null unique,
  status text not null default 'active',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table handlers (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  display_name text not null,
  role text not null check (role in ('owner', 'admin', 'manager', 'operator', 'viewer')),
  status text not null check (status in ('active', 'invited', 'suspended', 'deleted')),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index handlers_tenant_role_idx on handlers (tenant_id, role);
create index handlers_tenant_status_idx on handlers (tenant_id, status);

create table handler_external_accounts (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  handler_id uuid not null references handlers(id),
  provider text not null check (provider in ('slack', 'line', 'email', 'google')),
  provider_workspace_id text not null default '',
  provider_account_id text not null,
  display_name text,
  is_primary boolean not null default false,
  connected_at timestamptz not null default now(),
  unique (tenant_id, provider, provider_workspace_id, provider_account_id)
);

create index handler_external_accounts_handler_idx
  on handler_external_accounts (tenant_id, handler_id);

create table line_channel_integrations (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  channel_id text not null,
  channel_name text,
  channel_secret_ref text not null,
  access_token_secret_ref text not null,
  webhook_url text,
  webhook_verified_at timestamptz,
  status text not null default 'active',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (tenant_id, channel_id)
);

create table slack_workspace_integrations (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  slack_team_id text not null,
  slack_team_name text,
  bot_user_id text,
  bot_token_secret_ref text not null,
  default_channel_id text,
  task_list_id text,
  app_home_enabled boolean not null default true,
  lists_sync_enabled boolean not null default true,
  polling_interval_seconds int not null default 180,
  status text not null default 'active',
  installed_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (tenant_id, slack_team_id)
);

create table tenant_agent_configs (
  tenant_id uuid primary key references tenants(id),
  runtime_mode text not null check (runtime_mode in ('shared_pool', 'dedicated_container')),
  mastra_profile_id text,
  container_name text,
  volume_name text,
  model_provider text not null,
  model_name text not null,
  policy_version text not null,
  enabled_workflows text[] not null default '{}',
  human_approval_policy jsonb not null default '{}'::jsonb,
  max_daily_runs int,
  updated_at timestamptz not null default now()
);

create table customers (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  display_name text not null,
  external_line_user_id text,
  status text not null default 'active',
  metadata jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (tenant_id, external_line_user_id)
);

create table conversations (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  customer_id uuid references customers(id),
  channel text not null check (channel in ('line', 'slack', 'web')),
  status text not null default 'open',
  last_message_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index conversations_tenant_customer_idx on conversations (tenant_id, customer_id);
create index conversations_tenant_last_message_idx on conversations (tenant_id, last_message_at desc);

create table messages (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  conversation_id uuid not null references conversations(id),
  sender_type text not null check (sender_type in ('customer', 'operator', 'agent', 'system')),
  sender_ref text,
  body text not null,
  body_search_text text,
  search_vector tsvector generated always as (
    to_tsvector('simple', coalesce(body_search_text, body))
  ) stored,
  provider_message_id text,
  raw_payload jsonb not null default '{}'::jsonb,
  sent_at timestamptz not null,
  created_at timestamptz not null default now(),
  unique (tenant_id, provider_message_id)
);

create index messages_conversation_sent_idx
  on messages (tenant_id, conversation_id, sent_at);

create index messages_search_vector_idx
  on messages using gin (search_vector);

-- Optional: enable when pgvector or an external vector store is available.
-- create extension if not exists vector;
-- create table message_embeddings (
--   id uuid primary key,
--   tenant_id uuid not null references tenants(id),
--   message_id uuid not null references messages(id),
--   embedding_model text not null,
--   embedding vector(1536) not null,
--   created_at timestamptz not null default now(),
--   unique (tenant_id, message_id, embedding_model)
-- );

create table conversation_summaries (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  conversation_id uuid not null references conversations(id),
  summary text not null,
  evidence_message_ids uuid[] not null default '{}',
  agent_run_id uuid,
  created_at timestamptz not null default now()
);

create table conversation_memory_cards (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  conversation_id uuid references conversations(id),
  customer_id uuid references customers(id),
  scope text not null check (scope in ('tenant', 'customer', 'conversation')),
  memory_type text not null check (memory_type in ('preference', 'fact', 'decision', 'risk', 'procedure', 'open_issue')),
  content text not null,
  evidence_message_ids uuid[] not null default '{}',
  confidence numeric(5, 4) not null default 1.0,
  status text not null default 'active',
  agent_run_id uuid,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index conversation_memory_cards_scope_idx
  on conversation_memory_cards (tenant_id, scope, customer_id, conversation_id, status);

create table internal_tasks (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  title text not null,
  description text,
  status text not null check (status in ('open', 'in_progress', 'waiting', 'done', 'canceled')),
  priority text not null check (priority in ('p0', 'p1', 'p2', 'p3')),
  source_type text not null check (source_type in ('line_conversation', 'slack_message', 'manual', 'agent')),
  source_id uuid,
  customer_id uuid references customers(id),
  conversation_id uuid references conversations(id),
  due_at timestamptz,
  created_by_actor_id uuid,
  created_by_agent_run_id uuid,
  version int not null default 1,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index internal_tasks_tenant_status_idx on internal_tasks (tenant_id, status);
create index internal_tasks_tenant_due_idx on internal_tasks (tenant_id, due_at);
create index internal_tasks_tenant_priority_idx on internal_tasks (tenant_id, priority);
create index internal_tasks_tenant_conversation_idx on internal_tasks (tenant_id, conversation_id);

create table task_assignments (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  task_id uuid not null references internal_tasks(id),
  assignee_handler_id uuid not null references handlers(id),
  assigned_by_actor_id uuid,
  assigned_at timestamptz not null default now(),
  unassigned_at timestamptz,
  unique (tenant_id, task_id, assignee_handler_id, assigned_at)
);

create unique index task_assignments_active_unique_idx
  on task_assignments (tenant_id, task_id, assignee_handler_id)
  where unassigned_at is null;

create table task_status_events (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  task_id uuid not null references internal_tasks(id),
  from_status text,
  to_status text not null,
  actor_type text not null check (actor_type in ('user', 'agent', 'system', 'slack')),
  actor_id uuid,
  reason text,
  created_at timestamptz not null default now()
);

create index task_status_events_task_idx
  on task_status_events (tenant_id, task_id, created_at desc);

create table task_candidates (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  source_type text not null,
  source_id uuid,
  proposed_title text not null,
  proposed_description text,
  proposed_priority text not null,
  proposed_due_at timestamptz,
  confidence numeric(5, 4) not null,
  duplicate_task_id uuid references internal_tasks(id),
  agent_run_id uuid,
  status text not null check (status in ('proposed', 'accepted', 'rejected', 'merged')),
  reviewed_by_actor_id uuid,
  reviewed_at timestamptz,
  created_at timestamptz not null default now()
);

create index task_candidates_tenant_status_idx on task_candidates (tenant_id, status);

create table reply_drafts (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  conversation_id uuid not null references conversations(id),
  body text not null,
  rationale text,
  risk_level text not null check (risk_level in ('low', 'medium', 'high')),
  policy_warnings text[] not null default '{}',
  status text not null check (status in ('draft', 'needs_review', 'approved', 'rejected', 'queued', 'sent')),
  agent_run_id uuid,
  approved_by_actor_id uuid,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index reply_drafts_tenant_status_idx on reply_drafts (tenant_id, status);
create index reply_drafts_conversation_idx on reply_drafts (tenant_id, conversation_id);

create table outbound_message_queue (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  provider text not null check (provider in ('line', 'slack', 'email')),
  recipient_ref text not null,
  body text not null,
  status text not null default 'queued',
  reply_draft_id uuid references reply_drafts(id),
  approved_by_actor_id uuid,
  attempts int not null default 0,
  sent_at timestamptz,
  error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index outbound_message_queue_status_idx
  on outbound_message_queue (tenant_id, status, created_at);

create table feature_requests (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  title text not null,
  description text not null,
  source_type text not null,
  source_id uuid,
  customer_id uuid references customers(id),
  priority text not null default 'medium',
  status text not null default 'new',
  duplicate_of_id uuid references feature_requests(id),
  agent_run_id uuid,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index feature_requests_tenant_status_idx on feature_requests (tenant_id, status);

create table slack_task_list_links (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  task_id uuid not null references internal_tasks(id),
  slack_team_id text not null,
  slack_channel_id text,
  slack_list_id text not null,
  slack_list_item_id text not null,
  last_synced_task_version int not null default 0,
  last_seen_list_hash text,
  last_synced_at timestamptz,
  sync_status text not null check (sync_status in ('ok', 'pending', 'conflict', 'failed')),
  sync_error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (tenant_id, task_id, slack_list_id),
  unique (tenant_id, slack_list_id, slack_list_item_id)
);

create index slack_task_list_links_sync_idx
  on slack_task_list_links (tenant_id, sync_status);

create table slack_task_list_conflicts (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  task_id uuid not null references internal_tasks(id),
  slack_task_list_link_id uuid not null references slack_task_list_links(id),
  conflict_type text not null check (
    conflict_type in (
      'both_changed',
      'unknown_slack_user',
      'invalid_status_transition',
      'permission_denied',
      'protected_field_changed',
      'slack_item_deleted',
      'mapping_mismatch'
    )
  ),
  status text not null check (status in ('open', 'resolved', 'ignored')),
  field_diffs jsonb not null default '{}'::jsonb,
  db_snapshot jsonb not null default '{}'::jsonb,
  slack_snapshot jsonb not null default '{}'::jsonb,
  last_synced_snapshot jsonb not null default '{}'::jsonb,
  slack_user_id text,
  resolved_handler_id uuid references handlers(id),
  recommended_resolution text check (
    recommended_resolution in (
      'db_wins',
      'slack_wins',
      'manual_merge',
      'link_handler_and_retry',
      'discard_slack_change',
      'ignore'
    )
  ),
  resolved_by_actor_id uuid,
  resolved_at timestamptz,
  resolution_reason text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index slack_task_list_conflicts_status_idx
  on slack_task_list_conflicts (tenant_id, status, created_at desc);

create index slack_task_list_conflicts_task_idx
  on slack_task_list_conflicts (tenant_id, task_id, status);

create table slack_app_home_snapshots (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  slack_user_id text not null,
  handler_id uuid references handlers(id),
  view_hash text not null,
  published_at timestamptz not null default now(),
  unique (tenant_id, slack_user_id)
);

create table slack_projection_jobs (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  projection_type text not null,
  target_ref text not null,
  payload jsonb not null default '{}'::jsonb,
  status text not null default 'pending',
  attempts int not null default 0,
  last_error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index slack_projection_jobs_status_idx
  on slack_projection_jobs (tenant_id, status, created_at);

create table slack_interaction_results (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  slack_team_id text not null,
  action_ts text not null,
  action_id text not null,
  handler_id uuid references handlers(id),
  request_payload_hash text not null,
  result_status text not null check (result_status in ('processing', 'succeeded', 'failed')),
  response_body jsonb not null default '{}'::jsonb,
  error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (tenant_id, slack_team_id, action_ts, action_id)
);

create index slack_interaction_results_handler_idx
  on slack_interaction_results (tenant_id, handler_id, created_at desc);

create table morning_digests (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  digest_date date not null,
  body text not null,
  sections jsonb not null default '{}'::jsonb,
  agent_run_id uuid,
  created_at timestamptz not null default now(),
  unique (tenant_id, digest_date)
);

create table agent_runs (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  workflow_type text not null,
  trigger_type text not null,
  trigger_id uuid,
  status text not null check (status in ('queued', 'running', 'succeeded', 'failed', 'canceled')),
  input_hash text,
  output_ref text,
  error text,
  started_at timestamptz,
  finished_at timestamptz,
  created_at timestamptz not null default now()
);

create index agent_runs_tenant_status_idx on agent_runs (tenant_id, status, created_at desc);
create index agent_runs_tenant_workflow_idx on agent_runs (tenant_id, workflow_type, created_at desc);

alter table agent_runs
  add constraint agent_runs_tenant_id_unique unique (tenant_id, id);

alter table conversation_summaries
  add constraint conversation_summaries_agent_run_fk
  foreign key (tenant_id, agent_run_id) references agent_runs(tenant_id, id);

alter table conversation_memory_cards
  add constraint conversation_memory_cards_agent_run_fk
  foreign key (tenant_id, agent_run_id) references agent_runs(tenant_id, id);

alter table internal_tasks
  add constraint internal_tasks_created_by_agent_run_fk
  foreign key (tenant_id, created_by_agent_run_id) references agent_runs(tenant_id, id);

alter table task_candidates
  add constraint task_candidates_agent_run_fk
  foreign key (tenant_id, agent_run_id) references agent_runs(tenant_id, id);

alter table reply_drafts
  add constraint reply_drafts_agent_run_fk
  foreign key (tenant_id, agent_run_id) references agent_runs(tenant_id, id);

alter table feature_requests
  add constraint feature_requests_agent_run_fk
  foreign key (tenant_id, agent_run_id) references agent_runs(tenant_id, id);

alter table morning_digests
  add constraint morning_digests_agent_run_fk
  foreign key (tenant_id, agent_run_id) references agent_runs(tenant_id, id);

create table agent_tool_calls (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  agent_run_id uuid not null references agent_runs(id),
  tool_name text not null,
  input_redacted jsonb not null default '{}'::jsonb,
  output_redacted jsonb not null default '{}'::jsonb,
  status text not null,
  error text,
  created_at timestamptz not null default now()
);

create index agent_tool_calls_run_idx on agent_tool_calls (tenant_id, agent_run_id);

create table message_retrieval_events (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  agent_run_id uuid references agent_runs(id),
  actor_type text not null check (actor_type in ('user', 'agent', 'system', 'slack')),
  actor_id uuid,
  tool_name text not null,
  query text,
  mode text,
  filter_redacted jsonb not null default '{}'::jsonb,
  returned_message_ids uuid[] not null default '{}',
  used_message_ids uuid[] not null default '{}',
  created_at timestamptz not null default now()
);

create index message_retrieval_events_run_idx
  on message_retrieval_events (tenant_id, agent_run_id, created_at desc);

create table audit_logs (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  actor_type text not null check (actor_type in ('user', 'agent', 'system', 'slack', 'line')),
  actor_id uuid,
  action text not null,
  target_type text not null,
  target_id uuid,
  before_redacted jsonb,
  after_redacted jsonb,
  request_id text,
  agent_run_id uuid references agent_runs(id),
  created_at timestamptz not null default now()
);

create index audit_logs_tenant_created_idx on audit_logs (tenant_id, created_at desc);
create index audit_logs_target_idx on audit_logs (tenant_id, target_type, target_id);
create index audit_logs_actor_idx on audit_logs (tenant_id, actor_type, actor_id);
```

### 12.3 会話ログ検索 index 方針

全文検索は `messages.search_vector` を基本にする。日本語は単語境界が曖昧なので、初期実装では app 側で tokenizer を通した検索用文字列を `body_search_text` に保存し、Postgres の `simple` dictionary で `tsvector` 化する。

運用上の選択肢:

- 初期: Postgres `tsvector` + app-side tokenizer。ID取得、時系列取得、監査が DB 内で完結する。
- 精度強化: `message_embeddings` または外部 vector store を追加し、full-text と semantic の hybrid search にする。
- 大規模化: OpenSearch、Meilisearch、Vertex AI Vector Search などに projection する。ただし正は常に `messages` table。

Mastra tool は検索結果として必ず `messageId` を返し、要約や返信案には `evidence_message_ids` を残す。これにより、AI が何を根拠にしたかを後から検証できる。

### 12.4 DB 制約と整合性

追加する application-level validation:

- `internal_tasks.source_id` は raw provider id ではなく内部 UUID だけを保存する。`line_conversation` は `conversations.id`、`slack_message` は正規化済み `messages.id`、`agent` は `agent_runs.id` を指す。polymorphic field なので DB FK ではなく、usecase/repository で `tenant_id + source_type + source_id` の存在検証を必須にする。
- Slack user の handler 解決は `tenant_id + provider='slack' + provider_workspace_id(slack_team_id) + provider_account_id(slack_user_id)` で行う。同一 tenant に複数 Slack workspace がある場合でも user ID が衝突しないようにする。
- `internal_tasks.status` の transition は `open -> in_progress -> waiting/done` など許可表で制御する。
- `task_assignments` は active assignment の重複を禁止する。
- `reply_drafts` は `approved` 以降、body の更新を禁止する。
- `outbound_message_queue` は `approved_by_actor_id` がない LINE 送信を禁止する。
- `slack_task_list_links.sync_status=conflict` の item は自動上書きしない。
- open な `slack_task_list_conflicts` がある task は、Slack projection worker が自動上書きしない。
- `resolveSlackListConflict` は `DB_WINS`, `SLACK_WINS`, `MANUAL_MERGE`, `LINK_HANDLER_AND_RETRY`, `DISCARD_SLACK_CHANGE`, `IGNORE` のいずれかを必須にする。
- `SLACK_WINS` と `MANUAL_MERGE` は通常の task mutation と同じ permission / transition validation を通す。
- `agent_runs` は tenant の `enabled_workflows` に含まれない workflow を起動しない。
- `messagesByIds` と message retrieval tool は tenant/role guard に失敗した ID を返さない。
- `conversation_memory_cards` は raw message 全文ではなく、短く検証可能な fact だけを保存する。

## 13. Security / Audit

### 13.1 認可

| 操作                   | 必要 role                      |
| ---------------------- | ------------------------------ |
| dashboard 閲覧         | viewer                         |
| task 作成/更新         | operator                       |
| task 一括更新          | manager                        |
| handler 作成/role 変更 | admin                          |
| 会話ログ検索           | operator                       |
| LINE 送信承認          | operator。high risk は manager |
| Slack/LINE 連携変更    | admin                          |
| Mastra 設定変更        | admin                          |
| audit log 閲覧         | admin                          |

### 13.2 監査ログ対象

- task create/update/assign/status transition
- reply draft generate/update/approve/reject/enqueue/send
- LINE outbound success/failure
- Slack Lists sync conflict/failure/overwrite
- Slack Lists conflict resolve / ignore
- handler role/account update
- tenant integration update
- Mastra workflow run/tool call failure
- message search / timeline / ID retrieval by agent

### 13.3 AI safety

- Mastra tool は tenant scope を runtime 側で固定する。
- prompt には tenant 固有 policy と送信禁止条件を含める。
- tool input/output の audit 保存は redaction 後にする。
- 返信案は根拠、risk level、policy warning を持つ。
- 外部送信は outbound queue + human approval を通す。

## 14. 実装補足観点

production 実装で追加で設計しておくべき観点をまとめる。ここは機能仕様というより、壊れにくく運用しやすい実装にするためのチェックリスト。

### 14.1 Idempotency / 重複実行対策

LINE webhook、Slack Interactivity、Slack Lists polling、worker retry、Mastra job は同じ payload が複数回届く前提で実装する。

| 対象                | idempotency key                                                      | 方針                                                         |
| ------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------ |
| LINE webhook        | `tenant_id + provider_message_id`                                    | `messages` の unique 制約で重複保存を防ぐ                    |
| Slack Interactivity | `tenant_id + slack_team_id + action_ts + action_id`                  | `slack_interaction_results` に保存し、二重 mutation を防ぐ   |
| Slack Lists polling | `tenant_id + slack_list_id + slack_list_item_id + item_hash`         | 前回 hash と比較し、同一差分は再処理しない                   |
| LINE outbound       | `tenant_id + reply_draft_id`                                         | 承認済み draft からの送信を一度だけ enqueue                  |
| Agent job           | `tenant_id + workflow_type + trigger_type + trigger_id + input_hash` | 同一入力の再実行は既存 `agent_run` を返すか明示 retry にする |
| Projection job      | `tenant_id + projection_type + target_ref + payload_hash`            | Slack/App Home の重複更新を避ける                            |

実装ルール:

- mutation input には必要に応じて `clientMutationId` または `idempotencyKey` を追加できるようにする。
- worker は at-least-once delivery 前提で、処理済み判定を DB に持つ。
- queue retry で副作用が二重発火しないよう、外部送信前に必ず DB 状態を確認する。

### 14.2 Retry / DLQ / Backoff

外部 API と agent 実行は失敗する前提で設計する。

| 対象                | Retry                                           | DLQ 条件                                            | 備考                                         |
| ------------------- | ----------------------------------------------- | --------------------------------------------------- | -------------------------------------------- |
| Slack API           | exponential backoff + jitter                    | rate limit 超過が継続、permission error             | `Retry-After` を尊重                         |
| LINE API            | exponential backoff + jitter                    | 4xx、送信先無効、token invalid                      | 送信失敗は operator に見せる                 |
| Mastra workflow     | retry max 2-3                                   | policy violation、tool auth error、model error 継続 | agent output は部分保存しない                |
| DB transaction      | deadlock / serialization failure のみ短期 retry | validation error                                    | domain error は retry しない                 |
| Slack Lists polling | interval backoff                                | workspace/list not found                            | tenant integration status を degraded にする |

DLQ に入った job は管理画面 `/admin/:tenantId/sync` から再実行できるようにする。

### 14.3 Rate limit / Quota

tenant ごとの公平性を守るため、tenant 単位の quota を持つ。

- Slack API 呼び出し数
- LINE outbound 件数
- Mastra workflow 実行数
- message search 件数
- full-text / semantic search の上限
- App Home publish 件数

実装:

- `tenant_usage_counters` または metrics backend に tenant 別 usage を集計する。
- hard limit と soft limit を分ける。
- soft limit 到達時は admin に通知し、hard limit 到達時は新規 agent run や projection を抑制する。
- high priority な LINE 返信承認や P0 通知は quota の例外扱いを検討する。

### 14.4 Slack Lists API capability / fallback

Slack Lists API は workspace plan、権限、API availability、rate limit の影響を受けるため、起動時と OAuth 後に capability check を行う。

確認する項目:

- bot token scope が Lists 操作に必要な権限を持つ。
- 対象 workspace で Slack Lists API が利用できる。
- 接続済み `slack_list_id` を読み取れる。
- `slackLists.items.list` / `create` / `update` の最小操作が成功する。

fallback:

- capability check に失敗した tenant は `slackListsProjectionEnabled=false` にする。
- Slack Lists が使えない場合も、App Home、Modal、Button、管理画面のタスク一覧で運用できるようにする。
- Slack Lists polling sync は feature flag で段階有効化し、初期 production では read-only projection から始める。
- API error が継続する場合は tenant integration を `degraded` にし、projection worker の自動再試行を抑制する。

### 14.5 Error handling

GraphQL/API/usecase/domain error は分類して扱う。

| 種別             | 例                                           | User 表示              | Retry    |
| ---------------- | -------------------------------------------- | ---------------------- | -------- |
| ValidationError  | status transition 不正                       | 修正方法を表示         | なし     |
| PermissionError  | tenant 外 access、role 不足                  | 権限不足を表示         | なし     |
| ConflictError    | Slack Lists conflict、expectedVersion 不一致 | 解決 UI へ誘導         | 手動     |
| ExternalApiError | Slack/LINE API error                         | 連携状態に表示         | 条件付き |
| AgentPolicyError | AI safety violation                          | draft 差し戻し         | 条件付き |
| SystemError      | DB/network 一時障害                          | 汎用エラー + requestId | あり     |

GraphQL response には `extensions.code`, `extensions.requestId`, `extensions.targetId` を入れる。PII や token は error message に含めない。

### 14.6 Observability / SLO

すべてのログ・trace・metrics に以下を付ける。

- `tenant_id`
- `request_id`
- `actor_id`
- `agent_run_id`
- `workflow_type`
- `slack_team_id`
- `line_channel_id`
- `job_id`

主要 metrics:

- GraphQL latency / error rate
- webhook verify failure
- queue depth / oldest job age
- LINE outbound success rate
- Slack projection success rate
- Slack Lists conflict count
- agent run success rate / latency / cost
- message search latency
- DB connection usage

Alert:

- LINE outbound failure rate 上昇
- Slack token invalid
- queue oldest job age が閾値超過
- agent run failure rate 上昇
- tenant isolation guard violation
- DB migration failure

### 14.7 Migration / Backfill

DB migration は zero-downtime を前提にする。

ルール:

- 破壊的変更は `expand -> backfill -> switch -> contract` の順で行う。
- nullable column 追加、backfill、not null 制約追加を分ける。
- 大量 backfill は tenant 単位に分割する。
- migration は CI で dry-run する。
- Prisma migration と raw SQL migration の責務を分ける。index、generated column、full-text search は raw SQL を許可する。
- migration には rollback 方針を添える。ただし data destructive rollback は原則禁止。

### 14.8 Test strategy

| テスト                 | 対象                                                    | 必須観点                  |
| ---------------------- | ------------------------------------------------------- | ------------------------- |
| Domain unit            | status transition、approval policy、conflict resolution | 外部依存なし              |
| Usecase unit           | task assign、reply approve、Slack conflict resolve      | tenantId 必須、permission |
| Repository integration | Prisma tenant guard、DB 制約、FTS                       | Testcontainers Postgres   |
| GraphQL resolver       | auth、input mapping、error extensions                   | tenantId 照合             |
| Webhook test           | LINE/Slack signature                                    | invalid signature reject  |
| Worker test            | retry、idempotency、DLQ                                 | 二重送信防止              |
| Agent tool test        | message search、timeline、DB tool permission            | tenant 横断不可           |
| E2E                    | 管理画面、Slack action、LINE outbound mock              | 主要業務フロー            |

最低限の regression:

- tenant A の actor が tenant B の task/message を取得できない。
- admin usecase 以外で tenantId なしの usecase が呼べない。
- Slack Lists conflict が open の task は projection worker が自動上書きしない。
- approved でない reply draft は LINE outbound に入らない。

### 14.9 Local development

local で必要なもの:

- Docker Compose: Postgres, Redis/PubSub emulator 相当, local GraphQL API
- `.env.local`: provider token は dummy、secret ref は local mock
- webhook tunnel: cloudflared / ngrok
- Slack/LINE mock server
- seed data: tenant、handler、customer、conversation、task、Slack integration
- local Mastra runtime: model provider を mock できる mode

開発者が最初に動かすコマンドは `README` にまとめる。local seed には実在顧客データを入れない。

### 14.10 Feature flag / Rollout

tenant ごとに機能を段階有効化する。

候補 flag:

- `slackAppHomeEnabled`
- `slackListsProjectionEnabled`
- `slackListsPollingEnabled`
- `replyDraftAgentEnabled`
- `autoTaskCandidateEnabled`
- `messageSearchSemanticEnabled`
- `lineOutboundEnabled`
- `dedicatedAgentRuntimeEnabled`

rollout 方針:

- まず read-only projection から始める。
- Slack Lists の直接編集取り込みは最初は disabled にする。
- LINE outbound は approval + manual send から始め、問題がなければ queue send を有効化する。
- tenant ごとの kill switch を必ず持つ。

### 14.11 Data lifecycle / Privacy

会話ログと AI 生成物は保持期間と削除方針を決める。

- message body retention
- raw provider payload retention
- audit log retention
- agent tool call redacted payload retention
- conversation summary retention
- embedding/vector index retention
- tenant delete/export

PII 対応:

- raw payload は必要最小限にする。
- agent tool call log は redaction 済みだけを保存する。
- embeddings を使う場合、tenant delete 時に vector も削除する。
- 管理画面の export は role と audit を必須にする。

### 14.12 Runbook

運用手順を事前に用意する。

- Slack token rotation
- LINE token rotation
- Slack Lists conflict が大量発生した時の停止手順
- agent workflow を tenant 単位で止める手順
- outbound queue の一時停止/再開
- DLQ replay
- DB migration failure recovery
- tenant data export/delete
- incident 時の audit log 抽出

### 14.13 Performance

- GraphQL は DataLoader で N+1 を避ける。
- 一覧系は cursor pagination を必須にする。
- `messages`, `internal_tasks`, `audit_logs`, `agent_runs` は tenant_id + time/status の index を優先する。
- full-text search は `limit` と期間 filter を必須に近い扱いにする。
- App Home は毎回全量生成せず、snapshot hash で差分 publish する。
- Slack Lists projection は batch 化し、rate limit を見て worker concurrency を tenant 単位で調整する。

## 15. Deployment

| 領域                 | 推奨                              | 備考                                                 |
| -------------------- | --------------------------------- | ---------------------------------------------------- |
| Static site          | Cloudflare Pages                  | LP、Markdown docs の配信                             |
| Admin Web            | Cloudflare Pages または Cloud Run | GraphQL client。認証方式次第で選択                   |
| GraphQL API          | GCP Cloud Run                     | tenant guard、DB transaction、provider service layer |
| Provider Webhook API | GCP Cloud Run                     | LINE/Slack 署名検証                                  |
| DB                   | Cloud SQL for PostgreSQL          | tenant_id 制約、backup、PITR、監査                   |
| Queue                | Cloud Tasks / Pub/Sub             | Mastra jobs、Slack projection、LINE outbound         |
| Secrets              | Secret Manager + KMS              | tenant scoped token                                  |
| Agent runtime        | Cloud Run services/jobs           | tenant ごとの Mastra profile/container               |
| Observability        | Cloud Logging + Error Reporting   | tenant_id、request_id、agent_run_id を相関 ID にする |

Cloudflare は入口と配信に強い。GCP は状態を持つ業務基盤に強い。DB、queue、secret、worker が必要な production は GCP を主軸にする。

### 15.1 Pulumi 方針

インフラは Pulumi TypeScript で管理する。stack は `dev` と `prd` を分け、GCP project も必ず分離する。Pulumi config は `dev -> line-memory-pro-dev`, `prd -> line-memory-pro-prd` の組み合わせだけを許可する。

| Stack | GCP project           | 方針                                                                              |
| ----- | --------------------- | --------------------------------------------------------------------------------- |
| `dev` | `line-memory-pro-dev` | 検証環境。Cloud Run min instances 0、Cloud SQL 小さめ、deletion protection 無効   |
| `prd` | `line-memory-pro-prd` | 本番環境。Cloud Run min instances 1、Cloud SQL regional、deletion protection 有効 |

Cloud Run から Cloud SQL への接続は Direct VPC egress を使う。Serverless VPC Access Connector は使わない。

- Cloud Run v2 の `vpcAccess.networkInterfaces` に VPC/subnet を指定する。
- `egress = PRIVATE_RANGES_ONLY` とする。
- Cloud SQL は private IP のみ有効にする。
- `DATABASE_URL` と `DATABASE_HOST` は `db.<env>.line-memory-pro.internal` を向ける。
- Cloud Run service agent に subnet の `roles/compute.networkUser` を付与する。
- Cloud DNS private zone で DB と内部 service hostname を管理する。
- Cloud Run は VPC に紐づいた Cloud DNS private zone で内部 DNS を解決する。

Cloud Run の ingress と Direct VPC egress は別の制御として扱う。Direct VPC egress は Cloud Run から Cloud SQL/private IP へ出るための outbound 経路であり、Cloud Run service 自体を private にする設定ではない。

- GraphQL API と provider webhook は外部入口なので `INGRESS_TRAFFIC_ALL` + 必要な認証/署名検証を使う。
- Agent runtime、Slack projection worker、LINE outbound worker は内部 service とし、`INGRESS_TRAFFIC_INTERNAL_ONLY` + IAM invoker にする。
- 内部 service を Cloud Tasks / scheduler から呼ぶ場合も OIDC token と `roles/run.invoker` を必須にする。

Cloud DNS private zone は service discovery と DB 接続先管理のために使う。`db.<env>.line-memory-pro.internal.` は Cloud SQL private IP の A record とする。一方で Cloud Run service の CNAME は内部向け alias であり、通信経路や認可を private 化するセキュリティ境界ではない。Cloud Run の公開制御は ingress と IAM で担保する。

Cloud Run container には `INTERNAL_DNS_ZONE`, `SERVICE_INTERNAL_HOSTNAME`, `DATABASE_HOST` を環境変数として渡す。アプリケーションは DB 接続や内部 service-to-service 通信で private DNS 名を使い、private IP を直接設定値として持たない。

Pulumi で作る主なリソース:

- GCP API enablement
- Artifact Registry
- VPC / subnet
- Private Service Connection
- Cloud SQL for PostgreSQL
- Cloud DNS private zone / records
- Secret Manager secrets
- Cloud Tasks queues
- runtime service account
- IAM bindings
- Cloud Run services
  - GraphQL API
  - Provider Webhooks
  - Agent Runtime
  - Slack Projection Worker
  - LINE Outbound Worker

### 15.2 Pulumi ディレクトリ構造

実装ディレクトリ:

```text
infra/pulumi/
  Pulumi.yaml
  Pulumi.dev.yaml
  Pulumi.prd.yaml
  package.json
  package-lock.json
  tsconfig.json
  README.md
  src/
    index.ts              -- composition root / stack outputs
    config.ts             -- stack config, env, image names, sizing
    apis.ts               -- required GCP API enablement
    artifactRegistry.ts   -- Docker image repository
    iam.ts                -- runtime service account and IAM
    network.ts            -- VPC, subnet, private service connection
    dns.ts                -- Cloud DNS private zone and records
    secrets.ts            -- Secret Manager secret containers
    sql.ts                -- Cloud SQL, database, user, DATABASE_URL secret
    queues.ts             -- Cloud Tasks queues
    cloudRun.ts           -- Cloud Run service factory
```

環境別 config:

- `Pulumi.dev.yaml`
  - `gcp:project = line-memory-pro-dev`
  - `line-memory-pro-infra:environment = dev`
  - `internalDnsName = dev.line-memory-pro.internal.`
  - `deletionProtection = false`
  - `cloudRunMinInstances = 0`
  - `sqlTier = db-f1-micro`
- `Pulumi.prd.yaml`
  - `gcp:project = line-memory-pro-prd`
  - `line-memory-pro-infra:environment = prd`
  - `internalDnsName = prd.line-memory-pro.internal.`
  - `deletionProtection = true`
  - `cloudRunMinInstances = 1`
  - `sqlTier = db-custom-1-3840`

### 15.3 Pulumi 運用コマンド

```bash
cd infra/pulumi
npm install

npm run preview:dev
npm run up:dev

npm run preview:prd
npm run up:prd
```

container image は環境別 config で渡す。

```bash
pulumi config set line-memory-pro-infra:graphqlApiImage asia-northeast1-docker.pkg.dev/PROJECT/line-memory-pro-apps/graphql-api:TAG
```

Secret Manager の secret container は Pulumi で作る。LINE token、Slack token、OpenAI key などの secret value は CI または管理者が secret version として投入する。DB password と `DATABASE_URL` は Pulumi が生成して Secret Manager に保存する。

## 16. Roadmap

| Phase    | 内容                                                                  | 完了条件                                                                       |
| -------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| Phase 1  | 技術基盤、Clean Architecture、tenant scoped ORM guard、Pulumi dev/prd | usecase tenantId 契約、tenantDbFactory、adminDb 分離、dev/prd IaC が実装される |
| Phase 2  | DB canonical 化、tenant/handler/task/reply draft/audit schema         | GraphQL API でタスク・担当・履歴が完結                                         |
| Phase 3  | GraphQL API MVP                                                       | dashboard/tasks/conversations/replyDrafts の query/mutation が使える           |
| Phase 4  | 管理画面 MVP                                                          | dashboard、task list/detail、conversation detail、reply review が使える        |
| Phase 5  | Slack App Home / Modal / Button                                       | Slack から重要操作を GraphQL service layer 経由で実行                          |
| Phase 6  | Slack Lists projection                                                | DB -> Lists 同期、item mapping、retry、rate limit 対応                         |
| Phase 7  | 会話ログ検索 tools                                                    | full-text search、timeline、message ID retrieval が Mastra/GraphQL で使える    |
| Phase 8  | Mastra workflows                                                      | 会話理解、返信案、要約、タスク候補、要望候補、朝サマリー                       |
| Phase 9  | Slack Lists polling sync                                              | Lists での軽微編集を DB に取り込み、競合を可視化                               |
| Phase 10 | 高分離 tenant 対応                                                    | dedicated runtime、volume、secret、queue partition                             |

## 17. References

- [Mastra Documentation](https://mastra.ai/en/docs)
- [Mastra Workflows](https://mastra.ai/en/docs/workflows/overview)
- [Mastra Agents](https://mastra.ai/en/docs/agents/overview)
- [Mastra Memory API](https://mastra.ai/reference/client-js/memory)
- [Mastra: RAG for agent memory](https://mastra.ai/blog/use-rag-for-agent-memory)
- [Hermes Agent Memory System](https://hermes-agent.ai/blog/hermes-agent-memory-system)
- [Hermes Persistent Memory](https://hermesagent.org.cn/zh-Hant/docs/user-guide/features/memory)
- [Google Cloud Run Direct VPC egress](https://cloud.google.com/run/docs/configuring/vpc-direct-vpc)
- [Pulumi GCP Cloud Run v2 Service](https://www.pulumi.com/registry/packages/gcp/api-docs/cloudrunv2/service/)
- [Pulumi GCP Cloud SQL DatabaseInstance](https://www.pulumi.com/registry/packages/gcp/api-docs/sql/databaseinstance/)
- [Pulumi GCP Cloud DNS ManagedZone](https://www.pulumi.com/registry/packages/gcp/api-docs/dns/managedzone/)
- [Pulumi GCP Cloud Tasks Queue](https://www.pulumi.com/registry/packages/gcp/api-docs/cloudtasks/queue/)
- [Pulumi Configuration](https://www.pulumi.com/docs/intro/concepts/config/)
- [Pulumi Secrets Handling](https://www.pulumi.com/docs/iac/concepts/secrets/)
- [Slack App Home](https://docs.slack.dev/surfaces/app-home)
- [Slack Modals](https://docs.slack.dev/surfaces/modals)
- [Slack Interactivity](https://docs.slack.dev/interactivity/handling-user-interaction/)
- [Slack Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts)
- [Slack Lists](https://docs.slack.dev/surfaces/lists/)
- [Slack Lists API: create](https://docs.slack.dev/reference/methods/slackLists.create/)
- [Slack Lists API: items.list](https://docs.slack.dev/reference/methods/slackLists.items.list/)
- [Slack Lists API: items.update](https://docs.slack.dev/reference/methods/slackLists.items.update/)
- [LINE Messaging API](https://developers.line.biz/en/docs/messaging-api/)
