# Google Drive 證據同步 — Design Spec

| 項目 | 內容 |
|------|------|
| Spec 版本 | v0.4 — Phase 3 端對端測試後續修正 + 性能 + auto-recovery UX |
| 撰寫日期 | 2026-04-21（v0.1 草稿）；2026-04-24 v0.2 修訂；2026-04-24 v0.3 修訂；2026-04-24 v0.4 修訂 |
| 撰寫人 | Claude / raymond |
| 狀態 | Draft — 待 review |
| 影響專案 | compliance-manager-be, compliance-manager-fe |

> **v0.4 變更（Phase 3 端對端測試後）**：
> 1. **Auto-recovery on Drive (re)connect**：OAuth callback 結尾自動掃描「tenant 內未同步 project」並 enqueue `INIT_PROJECT_FOLDERS`。User 不需手動點任何按鈕，連線完即補建（解決 Phase 3 之前 user 必須在 UI 一個個點 sync 的痛點）。
> 2. **Project 列表加 `drive_synced` flag + 同步按鈕**：drive_synced 在 route 層 batch 查（避免 grc ↔ cloud_integration container 雙向 override 觸發 dependency-injector 無限遞迴）；UI 顯示「已連動 / 未與 Google Drive 同步」tag，未同步者多一個 Google icon 按鈕直接觸發 init。
> 3. **FE 整段 Drive UI 由 tenant 整合狀態 gate**：mount 時打 `GET /api/1.0/integrations/google-drive`；status != 'CONNECTED' 不顯示任何 Drive 相關 tag / button（避免沒接 Drive 的 tenant 看到莫名其妙的 UI）。
> 4. **Init handler ROOT 健康檢查**：每次 init 在 reuse existing ROOT mapping 前 `drive.get_folder` 驗證；404 / trashed → wipe tenant 全部 mapping + reset `root_folder_id` → 落到 fresh create。避免 user 手動刪 GuidantAI 後 init job 卡 404 retry 死循環。
> 5. **Webhook self-event skip — 只對 folder change**：INIT / RENAME / ARCHIVE 是我們對 folder 動的，user 只對 file 動。早期版本連 file event 一起 skip 害 user 上傳檔案完全 import 不進來（OAuth 帳號 = user 個人帳號的 dev 場景特別常見）。**file events 永不 self-skip**。
> 6. **Init handler 同層 parallel**：`ThreadPoolExecutor(5)` 並發 sibling folder。每 thread 自己 `@transaction` (session_scope ContextVar per-thread)。實測 145 folders 從 236s → 67s (3.5x)；Drive API quota 5 concurrent × 2 calls = 10 req/s 在限制內。
> 7. **Webhook URL prefix bugfix**：`webhook_channel_manager.register_for_tenant` 組 callback URL 由 `/api/webhooks/...` 改為 `/api/1.0/webhooks/...`（Blueprint 有 `url_prefix=/api/1.0`）；同時加「register 前先 `stop_channel` 既有 channel」避免 Drive 兩 channel 並存。
> 8. **Admin endpoint 整合**：`POST /webhook/register` 同時做 webhook register + project 補建。Response 多回 `backfilled_projects`。
> 9. **DriveFolderMappingDomainService 補 `get_by_uid`**（漏 wrap，ImportDriveFileHandler crash bug）。
> 10. **文案**：v0.3 寫的 `Drive 同步中`/`未同步` 改為 `已連動 Google Drive`/`未與 Google Drive 同步`（避免「同步中」被誤讀為動作進行中）；evidence row 的 `Drive` badge 改為 `Google Drive`；新增 `tooltip_open_in_drive` / `ref_open_in_drive` i18n key。
>
> 詳見 changelog：[`docs/changelog/2026-04-24-google-drive-phase3-followup-and-perf.md`](../../changelog/2026-04-24-google-drive-phase3-followup-and-perf.md)

> **v0.3 重大變更**：
> 1. 移除 v0.1 / v0.2「禁止系統內刪 DRIVE_SYNC evidence」規則。系統刪 DRIVE_SYNC evidence 時改為 **把 Drive 上的檔案 move 到 `_Archive/` 資料夾**，並 DB 軟刪除。Sync pipeline 偵測到 ancestor 為 `_Archive/` 就 SKIP，避免「系統刪 → Drive 還在 → webhook 重新 import」的迴圈。
> 2. 系統刪整個 task 也改為 **把 task folder move 到 `_Archive/`**（取代 v0.2「mapping 標 unlinked + Drive folder 留原處」設計），統一 SYSTEM_UPLOAD / DRIVE_SYNC 兩種 evidence 的刪除 UX。
> 3. 每個 AP 下自動建立一個 `_Archive/` folder（per-AP，flat 結構，無子目錄），登錄為 `drive_folder_mappings.scope_type='ARCHIVE'` (`scope_uid` = AP uid)。
> 4. 移除 v0.2 §13.3.1「已刪除 task 的 evidence UI 顯示」段落（依使用者意見：UI 上看不到 task 就不需特別處理）。
> 5. `GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE` error code 標 deprecated，但保留定義（避免 break 既有引用）；v0.3+ 不再 raise。
>
> User 還可手動把 `_Archive/` 內檔案搬回 active task folder → 視為新 evidence import（不做複雜的 restore 偵測，原軟刪 evidence row 留作歷史紀錄）。

> **v0.2 重大變更**：folder structure 從 5 層 (Project/AP/CG/Control/AO) 改為 6 層，新增 **Task 層**作為 evidence 檔案實際存放層。原因：1 個 AO 可能對應多個 `job_execution`（task），evidence FK (`job_evidences.job_execution_id`) 是綁 task 不是 AO。原設計強迫 evidence 對到「AO 的 active job_execution」在多 task 場景會選錯 task → 稽核資料錯置。新增 Task 層後，每個 task folder 與 `job_execution` 1:1 對應，sync 路徑明確無歧義。代價：單一 task 的 AO（最常見情境）多一層資料夾點擊，但換來 multi-task 場景的正確性。

---

## 1. Goals & Non-goals

### Goals

1. 讓 user 透過 Google Drive 上傳 task 的 evidence 檔案，無須打開系統 UI 即可完成上傳，檔案會自動同步進系統，效果與系統 UI 上傳完全一致（存入 Minio + DB 寫 evidence 紀錄）。Evidence 一律綁特定 task（job_execution），AO 本身不直接持有檔案。
2. Drive 上的刪除動作會同步回系統（軟刪除）。系統上的刪除**不**同步回 Drive。
3. 系統 UI 上能清楚區分 evidence 來源（Drive 同步 vs 系統上傳），並提供 Drive 資料夾 / 檔案的快捷連結。
4. PM 啟動專案時，自動依 Project → AP → Control Group → Control → AO → **Task** 的 6 層在共用 Drive 帳號下建立完整資料夾結構，user 不需要手動建。AO 層為「容器」資料夾（顯示同 AO 的所有 task），Task 層才是 evidence 檔案實際存放層。

### Non-goals (v1)

- 支援 Google Drive 以外的雲端空間（OneDrive / Dropbox 等）— 介面預留但不實作。
- Drive 與系統「雙向同步」— 系統內任何變更都不寫回 Drive，只有資料夾改名是 best-effort。
- 多人協同編輯 Drive 檔案的 conflict resolution — Drive 自己處理。
- 提供 Drive 端的細粒度 ACL — 統一用 `anyoneWithLink + writer`。
- Drive Push notification 給其他系統 participant — 不發。
- Tenant 內多組 Drive 帳號 — 一個 tenant 只允許一組。

---

## 2. Glossary

| 名詞 | 說明 |
|------|------|
| **Tenant Drive Account** | 每個 tenant 設定一個共用的 Google 帳號（建議用公司 dedicated 帳號，如 `compliance@company.com`），系統以 OAuth refresh token 代理該帳號操作 Drive。 |
| **GuidantAI 根目錄** | 系統在共用 Drive 帳號下建立的固定根目錄，所有專案資料夾都在這層下。 |
| **drive_folder_id** | Google Drive 為每個資料夾分配的全域唯一 ID，永遠不變（即使 user 改名 / 移動）。我們用它識別資料夾，**不靠名稱**。 |
| **drive_change_cursor** | Google Drive Changes API 的 `pageToken`，指向該 tenant Drive 上「上次處理過」的位置，下次拉變更從此往後拉。 |
| **DRIVE_SYNC evidence** | source 為 Drive 同步進來的 evidence 紀錄。 |
| **SYSTEM_UPLOAD evidence** | source 為 user 從系統 UI 直接上傳的 evidence 紀錄。 |
| **Webhook channel** | Google Drive `changes.watch` 註冊的推播通道，最長 7 天到期，需週期性續訂。 |
| **AO 資料夾（容器層）** | 6 層結構中的第 5 層；本身**不**存放 evidence 檔案，只是把同 AO 下所有 task 資料夾包在一起的容器。改名 / 連結對應的是 AO entity 本身。 |
| **Task 資料夾（檔案層）** | 6 層結構中的第 6 層（最底層）；evidence 檔案實際上傳到這層。每個 task folder 與系統內某筆 `compliance.job_executions` 1:1 對應，由 `drive_folder_mappings.scope_type='TASK' + scope_uid=job_execution.uid` 標示。 |
| **Task = job_execution** | 系統內「task」即是 `compliance.job_executions` 一筆 record。`compliance.job_evidences.job_execution_id` FK 直接指向這筆 task。一個 AO 在一次 AP 週期可能有多個 task（例如指派給多人或拆段執行），因此原 v0.1 假設「1 AO + 1 AP = 1 job_execution」**不成立**，必須以 task 為單位掛載 evidence。 |
| **`_Archive` 資料夾（v0.3）** | 每個 AP 下自動建立一個 `_Archive/`（底線開頭使其在 Drive 排序置頂），用來「丟棄」由系統發起刪除的 evidence 檔案 / 整個 task folder。檔案放這裡 = Drive 上仍可被人工救援，但 sync pipeline 不再 import / re-import；DB 對應 evidence 為軟刪除狀態。Flat 結構（無子目錄），重名由 Drive 自動加 ` (1)`、` (2)` 後綴處理。 |
| **ARCHIVE scope（v0.3）** | `drive_folder_mappings.scope_type='ARCHIVE'`，`scope_uid` 為該 AP 的 uid（一個 AP 一筆）。Sync 處理 file/folder 變更時，walk up parents 找到任一 ancestor 為 ARCHIVE → SKIP；避免歸檔檔案被當成新 evidence 重新 import。 |

---

## 3. High-level Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│  Frontend (compliance-manager-fe)                               │
│  - 「雲端空間整合設定」頁面（admin）                                │
│  - AO Evidence 區塊（badge / Drive 連結 / 在 Drive 開啟）           │
└─────────┬───────────────────────────────────┬───────────────────┘
          │ REST                              │
          ▼                                   │
┌─────────────────────────────────────────────┴───────────────────┐
│  Backend (compliance-manager-be / Flask)                         │
│  ┌──────────────────┐  ┌─────────────────┐  ┌────────────────┐ │
│  │ integration api  │  │ webhook receiver│  │ project_start  │ │
│  │ (OAuth/connect)  │  │ /webhooks/gdrive│  │   integration  │ │
│  └────────┬─────────┘  └────────┬────────┘  └───────┬────────┘ │
│           │                      │                    │         │
│           ▼                      ▼                    ▼         │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │   GoogleDriveService (drive client wrapper)              │  │
│  │   GoogleDriveTokenManager (token refresh)                │  │
│  │   TokenCryptoService (Fernet | KMS)                      │  │
│  │   DriveSyncJobService (enqueue)                          │  │
│  └──────────────────────────────────────────────────────────┘  │
│           │                      │                              │
│           │                      ▼                              │
│           │           ┌──────────────────┐                      │
│           │           │  drive_sync_jobs │ (DB-backed queue)    │
│           │           └────────┬─────────┘                      │
│           ▼                    ▼                                │
│  ┌─────────────────────────────────────────────┐                │
│  │  APScheduler                                │                │
│  │  - DriveSyncWorker (poll pending jobs)      │                │
│  │  - WebhookChannelRenewer (renew before TTL) │                │
│  └─────────────────────────────────────────────┘                │
└─────────┬─────────────────────────────────────┬─────────────────┘
          │                                     │
          ▼ Drive API                           ▼ Minio
┌──────────────────────┐              ┌──────────────────────────┐
│  Google Drive        │  webhook ──▶ │  jedi-file-upload (Minio)│
│  (Tenant 共用帳號)    │  POST        │  JOB_EVIDENCES/{uid}/... │
└──────────────────────┘              └──────────────────────────┘
```

**核心元件**：

- **GoogleDriveService**：Drive API 的 wrapper（folders.create、files.list、files.export、changes.list、changes.watch、permissions.create）。
- **GoogleDriveTokenManager**：取 access token（cache hit → 直接回；過期 → refresh → 更新 cache）。所有 Drive API 呼叫前都過這層。
- **TokenCryptoService**：抽象介面，第一版 `FernetCrypto`，未來可換 `AwsKmsCrypto`。
- **DriveSyncJobService**：enqueue / 查詢 sync job 的 app service。
- **DriveSyncWorker**（APScheduler 內定時執行）：每 N 秒拉一批 pending job 處理（一個 worker 內順序處理同 tenant 的 job 避免 race；不同 tenant 可平行）。
- **WebhookChannelRenewer**（APScheduler 內定時執行）：每天檢查所有 tenant 的 channel，距到期 < 1 天則 renew。

---

## 4. Data Model

### 4.1 新增 table

#### `compliance.tenant_drive_integrations`

每個 tenant 一筆。記錄 OAuth 連線狀態、token、webhook channel、根目錄。

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | SERIAL PK | |
| `uid` | UUID UNIQUE | API 對外 ID |
| `tenant_id` | INT UNIQUE | 一個 tenant 一筆 |
| `provider` | VARCHAR(20) | 預留，目前固定 `'google_drive'` |
| `google_account_email` | VARCHAR(255) | 連線帳號 email（顯示用） |
| `refresh_token_encrypted` | TEXT | 加密後的 refresh token |
| `access_token_cache` | TEXT | 加密後的 access token cache |
| `access_token_expires_at` | TIMESTAMPTZ | |
| `root_folder_id` | VARCHAR(100) | GuidantAI 根目錄的 Drive folder ID |
| `drive_change_cursor` | VARCHAR(255) | changes.list 的 pageToken |
| `webhook_channel_id` | VARCHAR(100) | UUID we send to Drive |
| `webhook_resource_id` | VARCHAR(100) | Drive 回傳的 resource ID |
| `webhook_token` | VARCHAR(100) | 隨機 token，驗證 webhook 來源用 |
| `webhook_expires_at` | TIMESTAMPTZ | Channel TTL |
| `status` | VARCHAR(20) | `CONNECTED` / `EXPIRED` / `REVOKED` / `DISCONNECTED` |
| `connected_by_user_id` | INT | 哪個 admin 連的 |
| `connected_at` | TIMESTAMPTZ | |
| `last_sync_at` | TIMESTAMPTZ | |
| `last_sync_error` | TEXT | NULL = 無錯誤 |
| `created_at`, `updated_at`, `created_user`, `updated_user` | 標準審計欄位 | |

#### `compliance.drive_folder_mappings`

記錄系統實體 ↔ Drive folder ID 的對應。

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | SERIAL PK | |
| `uid` | UUID UNIQUE | |
| `tenant_id` | INT | RLS 用 |
| `scope_type` | VARCHAR(20) | `ROOT` / `PROJECT` / `AP` / `CONTROL_GROUP` / `CONTROL` / `AO` / `TASK` / `ARCHIVE`（v0.3） |
| `scope_uid` | UUID NULLABLE | 對應的 entity uid（ROOT 時為 NULL）。`TASK` 時為 `compliance.job_executions.uid`。`ARCHIVE` 時為該 AP 的 uid（一個 AP 一筆 ARCHIVE mapping）。 |
| `parent_drive_folder_id` | VARCHAR(100) NULLABLE | 用於驗證搬移；ROOT 為 NULL |
| `drive_folder_id` | VARCHAR(100) UNIQUE | Drive 給的 ID |
| `display_name_snapshot` | VARCHAR(255) | 上次寫入 Drive 的名稱 |
| `is_unlinked` | BOOL DEFAULT FALSE | True 代表 Drive 上已不存在，待重建 |
| `created_at`, `updated_at` | | |

UNIQUE INDEX `(tenant_id, scope_type, scope_uid)` — 確保每個 entity 只有一個 mapping。

> **CHECK constraint 變更（v0.2）**：原 migration `2026-04-24-google-drive-folder-mappings.sql` 的 `chk_drive_folder_mappings_scope` 不含 `'TASK'`，需新增 migration `2026-04-24-add-task-scope-to-drive-folder-mappings.sql` 把 constraint ALTER 成 `CHECK (scope_type IN ('ROOT','PROJECT','AP','CONTROL_GROUP','CONTROL','AO','TASK'))`。
>
> **CHECK constraint 變更（v0.3）**：再次 ALTER 加入 `'ARCHIVE'`，最終為 `CHECK (scope_type IN ('ROOT','PROJECT','AP','CONTROL_GROUP','CONTROL','AO','TASK','ARCHIVE'))`。獨立 migration `2026-04-24-add-archive-scope-to-drive-folder-mappings.sql`（接於 task scope migration 之後）。

#### `compliance.drive_sync_jobs`

DB-backed job queue。

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | SERIAL PK | |
| `uid` | UUID UNIQUE | |
| `tenant_id` | INT | RLS / worker 分組用 |
| `job_type` | VARCHAR(40) | 見下方 enum |
| `payload` | JSONB | job 參數 |
| `status` | VARCHAR(20) | `PENDING` / `IN_PROGRESS` / `SUCCESS` / `FAILED` / `SKIPPED` |
| `priority` | INT DEFAULT 100 | 越小越優先 |
| `retry_count` | INT DEFAULT 0 | |
| `max_retries` | INT DEFAULT 5 | |
| `next_run_at` | TIMESTAMPTZ | exponential backoff 用 |
| `last_error` | TEXT | |
| `started_at`, `finished_at` | | |
| `created_at` | | |

`job_type` enum：
- `INIT_PROJECT_FOLDERS` — 專案啟動時建整套 6 層資料夾結構（含 task folders）
- `CREATE_FOLDER` — 個別建一個資料夾（INIT 內部會 split 出多筆；task 補建 / 上層重建後也用）
- `RENAME_FOLDER` — 系統改名同步（含 task rename）
- `PROCESS_DRIVE_CHANGES` — webhook 觸發，拉 changes.list
- `IMPORT_DRIVE_FILE` — 下載某個 Drive file 進 Minio + 寫 evidence（payload 帶 task_mapping_uid）
- `SOFT_DELETE_EVIDENCE` — Drive 刪除偵測到時
- `RECONCILE_TASK_FOLDER` — job revert / 資料夾重建後，補抓某 task folder 內檔案並對帳（v0.2 由 `RECONCILE_AO_FOLDER` 改名）

### 4.2 既有 table 修改

#### `compliance.job_evidences` 新增欄位

| 欄位 | 型別 | 說明 |
|------|------|------|
| `source` | VARCHAR(20) DEFAULT `'SYSTEM_UPLOAD'` | `SYSTEM_UPLOAD` / `DRIVE_SYNC` |
| `drive_file_id` | VARCHAR(100) NULLABLE | DRIVE_SYNC 時為 Drive file ID |
| `drive_file_modified_at` | TIMESTAMPTZ NULLABLE | Drive 上次修改時間（用於變更偵測） |
| `drive_last_modifying_user_email` | VARCHAR(255) NULLABLE | 預留欄位，目前 UI 不顯示真人（依 Q9.3） |
| `is_deleted` | BOOL DEFAULT FALSE | 軟刪除 flag |
| `deleted_at` | TIMESTAMPTZ NULLABLE | |
| `deleted_reason` | VARCHAR(40) NULLABLE | `USER_DELETED` / `DRIVE_DELETED` / `DRIVE_MOVED_OUT` |

UNIQUE INDEX `(drive_file_id)` WHERE `drive_file_id IS NOT NULL` — 防止重複 import。

> Migration 時所有既有 evidence 預設 `source='SYSTEM_UPLOAD'`、`is_deleted=false`。

---

## 5. Folder Structure & Naming

### 5.1 階層（6 層 — v0.2，加 Archive sibling — v0.3）

```
{Tenant 共用 Drive 帳號}/
└── GuidantAI/                                              ← scope_type=ROOT
    └── {Project Name}/                                     ← scope_type=PROJECT
        └── 第N輪 - {AP Name}/                              ← scope_type=AP（含輪次前綴）
            ├── _Archive/                                   ← scope_type=ARCHIVE（v0.3，每 AP 一個）
            │   ├── [T-xxxxxxxx] 已刪除任務名稱/             ← 系統刪整個 task → 整個 task folder 搬進來
            │   └── 已刪除的單檔.pdf                         ← 系統刪單一 evidence → 個別檔案搬進來
            ├── [{cg_code}] {cg_name}/                      ← scope_type=CONTROL_GROUP
            │   └── [{ctrl_code}] {ctrl_name}/              ← scope_type=CONTROL
            │       └── {AO title}/                         ← scope_type=AO（容器層，不放檔案）
            │           └── [T-{task_id}] {task_name}/      ← scope_type=TASK（檔案實際放這層）
            └── ...其他 CG / Control / AO / Task ...
```

> **為何要加 Task 層**：`compliance.job_evidences.job_execution_id` 直接 FK 到 task；同一 AO 在同一 AP 週期可能有多個 task（指派多人 / 拆段執行）。原 v0.1 把檔案直接放 AO 層，遇到多 task 時無法正確判斷檔案應綁哪一個 task → 稽核資料錯置。新 v0.2 在 AO 下多一層 task folder，每個 task folder 與 `job_executions` 一對一映射，sync 路徑明確。

> **為何加 `_Archive/` （v0.3）**：原 v0.1/v0.2 為了避免「系統刪 → Drive 還在 → webhook 重新 import」的迴圈，禁止系統刪 DRIVE_SYNC evidence，UX 過於嚴苛（user 必須切到 Drive 操作）。v0.3 改為「系統刪時把檔案 move 到 `_Archive/`」，sync 偵測 ancestor 為 ARCHIVE 直接 SKIP，迴圈問題消失，UX 也與 SYSTEM_UPLOAD 統一（系統內可刪、Drive 上仍可救援）。
>
> **Archive folder 設計**：
> - **位置**：每 AP 一個（不是 per project / per tenant）— 與 task folder 同層級。原因：保留 AP 維度的稽核軌跡，不同 AP 的歸檔不混在一起。
> - **命名**：`_Archive`（英文 + 底線開頭）— 底線在 Drive 預設排序中會置頂，user 一眼看到；英文短，避免中文與資料夾命名規則衝突。
> - **結構**：flat（v1 不分子目錄）— 簡單；同名檔案靠 Drive 自動加 ` (1)` 後綴；上層 task folder 直接整個 move 進來保留任務脈絡。
> - **建立時機**：`INIT_PROJECT_FOLDERS` 內每建一個 AP folder，緊接著建一個 `_Archive/` 子資料夾並寫 mapping。

### 5.2 命名規則

- **GuidantAI** — 固定字串，不含日期 / tenant 標記。
- **Project** — 純 display name（沒有 OSCAL code）。系統內改名 → best-effort 同步。
- **AP** — `第{round_no}輪 - {AP display name}`，例如 `第1輪 - 2026Q2 內稽`。系統內改名 / 輪次調整 → best-effort 同步。
- **Control Group / Control** — `[{code}] {display name}`。code 是 OSCAL ID（如 `AC-2`、`AC-2.a`）幾乎不變；display name 改了 → best-effort 同步。
- **AO** — 直接用 `{AO title}`，**不**加 `[{ao_code}]` prefix。原因：AO title 本身已含子項標記（如 `[a] 確認...`、`[b] 驗證...`），再加 OSCAL 碼會冗長。
- **Task** — `[T-{task_id}] {task_name}`，例如 `[T-9f3c2a1b] HR 密碼政策實作`。
  - `task_id` = `job_execution.uid` 的前 8 碼（UUID prefix），與系統其它部分顯示風格一致；不用 DB int id（避免外洩 / 不可變保證較弱）
  - `task_name` 取自 `job_execution` 的人類可讀欄位。**TODO（待 implementation 階段確認）**：實際欄位名 — 候選 `name` / `display_name` / `description` / `title`，Phase 2 實作 Task layer 時用 `grep` 確認 entity；若該 entity 沒有合適 name 欄位，先 fallback 為 `[T-{task_id}]`（無 trailing name）並回頭跟產品討論補欄位。
- 名稱過長 / 含 Drive 不允許字元（`/`、`\`）→ 替換為 `_`，最長 100 字元。

### 5.2.1 各層 Mutability Matrix（v0.2 補充）

| Scope | 改名? | 新增/刪除? | 觸發點（系統內 API） |
|-------|------|----------|-------|
| ROOT (GuidantAI) | 否 | 否 | （固定，OAuth connect 時建立） |
| PROJECT | 是 | 否（project archive 才會「消失」） | `PUT /grc/project/<uid>` |
| AP | 是 | 多輪稽核時新增 | `PUT /grc/project/<pid>/ap/<ap_uid>`（rename）；新輪 AP 啟動時新增 |
| CONTROL_GROUP | 否（OSCAL profile） | 否 | — |
| CONTROL | 否（OSCAL profile） | 否 | — |
| AO | 否（OSCAL profile） | 否 | — |
| TASK | 是 | 是（BPMN editor / planning page） | `PUT /grc/project/<pid>/job/<job_uid>` (rename) / `POST /grc/project/<pid>/assessment-object/<ao_uid>/jobs` (create) / `DELETE /grc/project/<pid>/job/<job_uid>` (delete) |
| ARCHIVE (v0.3) | 否 | AP 啟動時自動建立；user 不可由 UI 刪 | `INIT_PROJECT_FOLDERS` (per AP)；user 若手動刪 Drive 上的 `_Archive/` → 下次 archive 操作時偵測 mapping `is_unlinked=TRUE` → 重新建立 |

> **設計確認**：CONTROL_GROUP / CONTROL / AO 的名稱來源於 OSCAL profile import，**對 user 而言為 immutable**（無 rename API），因此 Drive 同步**不需要也不應該**為這三層註冊 rename hook。Spec / plan 明確不為它們補 rename API；如果未來改變這項設計，再回來補。
>
> Task 是唯一在 evidence 檔案實際存放層之上仍可動的 entity（rename / 增 / 刪）— 因為 task 是「執行單位」，user 在 BPMN editor / planning 頁面有充分的編輯需求。
>
> ARCHIVE 是系統管理的「歸檔桶」，名稱固定 `_Archive`，user 不應改名 / 刪除（即便 user 改了，下次 archive 仍會 SKIP — 因為靠 mapping `drive_folder_id` 識別 folder，不靠名稱；user 刪掉 folder 後系統會偵測 `is_unlinked` 並重建）。

### 5.3 Drive 權限

每個資料夾建立後，立刻呼叫 `permissions.create`：
```
{ "type": "anyone", "role": "writer", "allowFileDiscovery": false }
```
（任何拿到連結的人可編輯，但不會被 Drive 搜尋發現）

> ⚠️ 此設計表示「拿到連結 = 拿到編輯權」，請在 UI 與設定文件提醒 admin 妥善保管連結。未來若需細緻 ACL，再評估改用 `domain` permission 限制 Workspace 範圍。

### 5.4 改名同步策略（系統 → Drive）

- 系統內 entity 改名 → app service 呼叫 `DriveSyncJobService.enqueue('RENAME_FOLDER', {scope, uid, new_name})`。
- Worker 取 job → 找 mapping → call Drive API 改名。
- **Force overwrite**：即使 user 在 Drive 上手動改過，也覆寫。
- 失敗：retry 5 次 exponential backoff，最終失敗 → status=FAILED + alert admin（不阻擋系統內改名成功）。

#### 5.4.1 各層 rename hook 對照（v0.2 補充）

| Scope | 系統 API | App Service Method | Hook 行為 |
|-------|---------|-------------------|----------|
| PROJECT | `PUT /grc/project/<uid>` | `ProjectService.update_project` | service 層偵測 name 變動 → return `(dto, name_changed)` → route 呼叫 `try_enqueue_rename_folder` |
| AP | `PUT /grc/project/<pid>/ap/<ap_uid>` | `AssessmentPlanService.update` | 同上；name 組合改用 `f"第{round_no}輪 - {new_name}"` |
| CONTROL_GROUP | — | — | **無 rename API**（OSCAL immutable），不需 hook |
| CONTROL | — | — | **無 rename API**（OSCAL immutable），不需 hook |
| AO | — | — | **無 rename API**（OSCAL immutable），不需 hook |
| TASK | `PUT /grc/project/<pid>/job/<job_uid>` | `JobService.update_job` | 比照 PROJECT / AP pattern：service 層偵測 name 變動 → return `(dto, name_changed)` → route 呼叫 `try_enqueue_rename_folder('TASK', task_uid, f"[T-{task_uid[:8]}] {new_name}")` |

> 為何 CG / Control / AO 沒有 rename hook：這三層的名稱完全由 OSCAL profile import 決定，user 在系統 UI 上沒有改名入口；Drive 上 user 若改了名，下次系統 sync 也不會 force overwrite（因為沒有觸發 rename event）。如果未來補 rename API（例如允許 user override OSCAL 名稱顯示），再回頭補對應的 hook。

### 5.5 Drive → 系統「不」同步改名

User 在 Drive 上改資料夾名稱 → 偵測到後**忽略**（系統名稱才是 source of truth）。下次系統再改名時會被覆寫回去。

---

## 6. OAuth Flow

### 6.1 OAuth Client 設定（一次性）

由 product team 在 Google Cloud Console 完成：
1. 建立 GCP project（如 `guidant-ai-prod` / `guidant-ai-staging`）。
2. 啟用 Google Drive API。
3. 建立 **OAuth 2.0 Client ID**（Web application）。
4. 設定 Authorized redirect URIs（每環境一條）。
5. 設定 Authorized JavaScript origins（前端 domain）。
6. OAuth consent screen：設定 scopes、test users（unverified 階段最多 100 人）。
7. **Restricted scope verification**（上 production 前 4-12 週啟動，本 spec 不涵蓋）。

### 6.2 Connect 流程

```
[Admin 在「雲端空間整合設定」頁]
        │
        │ 點「連線 Google Drive」
        ▼
GET  /api/integrations/google-drive/auth-url
  → 後端產生 state token (CSRF), 存入 Redis (TTL 10 min)
  → 組 OAuth URL: scope=drive, access_type=offline, prompt=consent, state=xxx
  ← { auth_url }
        │
        ▼
前端 window.open(auth_url, '_blank')
        │
        ▼
[Google OAuth 同意畫面]
  user 用公司共用帳號登入並同意
        │
        ▼
Google redirect → /api/integrations/google-drive/callback?code=xxx&state=yyy
  → 驗證 state（CSRF）
  → 用 code 換 tokens (access_token, refresh_token, expires_in)
  → 用 access_token 查 userinfo 拿 email
  → 加密 refresh_token 寫入 tenant_drive_integrations
  → 建立 GuidantAI 根目錄，寫入 root_folder_id 與 drive_folder_mappings (scope=ROOT)
  → 註冊 webhook channel (changes.watch)，寫入 channel 資訊
  → 取 startPageToken 寫入 drive_change_cursor
  → 渲染 HTML：postMessage 通知 opener「連線成功」+ window.close()
        │
        ▼
[整合設定頁] 接到 postMessage → 重新讀狀態 → 顯示「已連線：xxx@company.com」
```

### 6.3 Disconnect 流程

```
[Admin 點「中斷連線」]
        │
        ▼
DELETE /api/integrations/google-drive
  → call Drive channels.stop 關掉 webhook channel
  → call Google revoke API 撤銷 refresh_token（best-effort）
  → tenant_drive_integrations.status = 'DISCONNECTED'
  → refresh_token_encrypted = NULL（clear）
  → 不刪 drive_folder_mappings（資料夾在 Drive 上仍存在）
  → 不刪 DRIVE_SYNC evidence（Minio 快取仍可下載）
  ← { status: 'disconnected' }
```

### 6.4 Re-auth 流程

同 Connect，但 callback 額外處理：
- 若 tenant 已有 integration record → 更新 token，不重建根目錄、不重新註冊 webhook（除非已過期）。

### 6.5 Token Refresh

`GoogleDriveTokenManager.get_valid_access_token(tenant_id)`：

```python
1. 讀 tenant_drive_integrations.access_token_cache + expires_at
2. 若 expires_at > now() + 60s → decrypt cache 直接回
3. 否則：
   a. 解密 refresh_token
   b. POST https://oauth2.googleapis.com/token (grant_type=refresh_token)
   c. 收新 access_token + expires_in
   d. 加密寫回 access_token_cache + expires_at
   e. 回新 token
4. 若 refresh 失敗 invalid_grant：
   - status = REVOKED
   - 寫 last_sync_error
   - raise DriveTokenRevokedError
```

### 6.6 Token Revoke 偵測

任何 Drive API 呼叫遇到 `401 invalid_credentials` 或 refresh 拿到 `invalid_grant`：
- 立即標 status=REVOKED
- enqueue 一個 `NOTIFY_ADMIN_REAUTH` job（用既有 notification 機制）
- UI 顯示紅色 banner 提示重連
- 既有 DRIVE_SYNC evidence 不刪（Minio 快取仍可用）

---

## 7. Webhook Setup & Channel Renewal

### 7.1 註冊 webhook (changes.watch)

OAuth Connect 完成後立即註冊：

```python
channel_id = uuid4()
webhook_token = secrets.token_urlsafe(32)

drive.changes().watch(
    pageToken=startPageToken,
    body={
        "id": channel_id,
        "type": "web_hook",
        "address": f"{PUBLIC_BASE_URL}/api/webhooks/google-drive/{tenant_id}",
        "token": webhook_token,
        "expiration": int((now() + timedelta(days=7)).timestamp() * 1000),
    }
).execute()
```

寫入 `tenant_drive_integrations.webhook_channel_id`、`webhook_resource_id`、`webhook_token`、`webhook_expires_at`。

### 7.2 Webhook receiver

```
POST /api/webhooks/google-drive/<tenant_id>
Headers:
  X-Goog-Channel-ID
  X-Goog-Channel-Token        ← 必須等於 DB 存的 webhook_token，否則 401
  X-Goog-Resource-ID
  X-Goog-Resource-State        ← 'sync' (initial) | 'change'
  X-Goog-Channel-Expiration

處理：
  1. 驗證 channel_id + token 對得上 tenant_drive_integrations
  2. 若 resource_state == 'sync' → 忽略（initial handshake）
  3. 否則 enqueue PROCESS_DRIVE_CHANGES job
  4. 立刻回 200（< 1s）
```

### 7.3 Channel renewal

APScheduler 每天執行一次 `WebhookChannelRenewer`：

```
For each tenant where status=CONNECTED and webhook_expires_at < now() + 1 day:
  1. call drive.channels.stop on old channel
  2. register new channel (changes.watch with current cursor)
  3. update DB
  4. on failure → retry; 連續失敗 3 天 → alert admin + status=EXPIRED
```

### 7.4 DDNS / Public endpoint 注意事項

- Dev / demo 環境若用 DDNS：建議用「自有 domain CNAME 指向 DDNS」而非直接用 DDNS provider 子域名（後者通常無法做 DNS TXT 驗證）。
- 替代：[ngrok](https://ngrok.com) 或 [cloudflared tunnel](https://www.cloudflare.com/products/tunnel/) 起 HTTPS tunnel。
- Production 環境：需有正式 domain + 公開 HTTPS endpoint。
- Domain 必須在 GCP Console / Search Console 完成驗證（DNS TXT record 或 HTML 檔案）。

---

## 8. Sync Pipeline

### 8.1 Webhook → 變更處理 main flow

```
Webhook 觸發
    │
    ▼
enqueue PROCESS_DRIVE_CHANGES { tenant_id }
    │
    ▼
DriveSyncWorker 取 job
    │
    ▼
讀取 tenant_drive_integrations.drive_change_cursor
    │
    ▼
loop:
  resp = drive.changes().list(pageToken=cursor, fields=...)
  for change in resp.changes:
    if change.removed:           → handle_drive_delete(change.fileId)
    elif change.file.trashed:    → handle_drive_delete(change.fileId)
    else:
      file = change.file
      if file is folder:         → handle_folder_change(file)
      else:                      → handle_file_change(file)
  cursor = resp.nextPageToken or resp.newStartPageToken
  if not resp.nextPageToken: break
寫回 cursor
```

### 8.2 `handle_file_change(file)` 處理新檔 / 更新

```
0. (v0.3) 走 file.parents 往上 walk ancestors —— 若任一 ancestor 為 ARCHIVE mapping → SKIP
   - 實作優化：可預先 cache per-tenant 的 archive folder ID set，
     直接 file.parents ∩ archive_set 判斷（O(1)），不用每次 recursive walk
   - 目的：避免「系統把檔案 move 到 _Archive/ → webhook 觸發 → 重新 import」的迴圈

1. 確認 file.parents 中至少有一個 parent ∈ drive_folder_mappings 且 scope_type='TASK'
   - 若 parent scope_type='AO' / 其他層級（含 ARCHIVE 已在 step 0 排除）→ 忽略並回 admin alert
     （v0.2 起 evidence 必須丟到 task folder，不是 AO 容器層）
   - 若 parent 不在我們管的資料夾下 → 忽略（user 在 GuidantAI 之外的檔案不關我們事）
2. 從 TASK mapping 直接取得 job_execution_id（mapping.scope_uid = job_execution.uid）
   — 不再需要「AO → 找 active job_execution」的反查邏輯
3. 檢查 job_execution.status：
   - == 'COMPLETED' → 跳過（依 Q9.4 B），write log "skipped due to completed job"
   - else → 繼續
4. 檢查檔案大小：
   - file.size > 20MB → reject，寫一筆 "oversized" record,通知 admin (Q9.1 A)
5. 檢查檔案 MIME type：
   - 是 Google native (Docs/Sheets/Slides) → 走 LINK evidence 流程（8.3）
   - 是 binary → 走 FILE evidence 流程（8.4）
```

> **v0.2 設計收益**：原 v0.1 在 §8.2 step 2 須做 `AO uid → 找 active job_execution` 反查（牽涉跨表 JOIN 且多 task 時無法決定要哪一個），常會誤綁；改為 TASK mapping 後，task folder 與 job_execution 直接 1:1，`mapping.scope_uid` 就是答案，邏輯簡化且正確。

> **v0.3 ARCHIVE SKIP 細節**：當系統 archive 一個檔案 / 整個 task folder 時，會主動觸發 Drive API `files.update?addParents=&removeParents=` 把目標搬到 AP 的 `_Archive/` 下。Drive 會發出 webhook 觸發 `PROCESS_DRIVE_CHANGES`，每筆 change 進到 `_process_one_change`：
> - 對於 file change：跑 step 0 → ancestor 含 ARCHIVE folder ID → return（不 enqueue IMPORT）
> - 對於 folder move（task folder 整個搬入 `_Archive/`）：在 `handle_folder_change` 也要做同樣 ancestor 檢查，避免重新建 mapping
>
> **手動拖回 active task folder 的處理（restore）**：若 user 在 Drive 上把 archived 檔案手動搬回某個 active task folder → webhook 偵測 → step 0 不命中 ARCHIVE → 走正常 import 路徑 → 寫一筆**新的** evidence row（不嘗試「找回原先軟刪的 row 並 un-delete」，避免狀態追蹤複雜化）。原軟刪 evidence row 留作歷史。UI 上可能同時看到「軟刪歷史 + 新 import」兩列，可接受。

### 8.3 Google 原生檔案 → LINK evidence

```
1. drive_file_id 已存在 evidence → update reference_url 即可
2. 否則 enqueue IMPORT_DRIVE_FILE，type=LINK
   - reference_url = file.webViewLink
   - description = "[Google Drive] {file.name}"
   - source = DRIVE_SYNC, evidence_type = LINK
   - drive_file_id = file.id
```

### 8.4 Binary 檔案 → FILE evidence + Minio 快取

```
1. drive_file_id 已存在且 modifiedTime 沒變 → 跳過（dedup）
2. drive_file_id 已存在且 modifiedTime 變了 → re-import（更新 Minio 副本）
3. 否則新 import：
   a. drive.files.get_media(file.id) 下載 byte stream
      ⚠ 若 file.size metadata 缺失或為 0：邊串流邊計算 bytes，
         超過 DRIVE_FILE_SIZE_LIMIT_MB → abort、刪除暫存、寫 oversized 紀錄
   b. file_upload_service.upload_stream(stream, filename, save_dir=JOB_EVIDENCES/{job_execution_uid})
      → 取得 Minio file_id
   c. 寫 job_evidences:
      source=DRIVE_SYNC
      evidence_type=FILE
      file_id=Minio file_id
      drive_file_id=file.id
      content_hash=md5
      drive_file_modified_at=file.modifiedTime
      created_user='drive-sync@system' (固定，不顯示真人 — Q9.3 B)
      drive_last_modifying_user_email=file.lastModifyingUser.emailAddress (預留欄位)
```

### 8.5 `handle_drive_delete(file_id)` 刪除同步

```
1. 找 evidence WHERE drive_file_id = file_id AND is_deleted = FALSE
   - 找不到 → 忽略（可能是無關檔案，或是 Move 動作的副作用）
2. 找到 → 軟刪除：
   is_deleted = TRUE
   deleted_at = now()
   deleted_reason = 'DRIVE_DELETED'
   （Minio 副本不立刻刪，預留 30 天救援期）
```

### 8.6 `handle_folder_change(folder)` 資料夾變化

```
場景一：user 改了 folder 名稱
  → 我們不同步回系統（依設計，系統是 source of truth），display_name_snapshot 不更新
  → 下次系統內 entity 改名時 force 覆寫回正確名稱
  → 注意：若系統內永遠不改名，Drive 上 user 改的名稱會永久存在（這是刻意行為，
         不額外做 periodic reconcile）

場景二：folder.parents 變了（user 把資料夾搬走）
  → 找 mapping WHERE drive_folder_id = folder.id（適用於所有 scope_type，含 TASK）
  → 若 new parent ≠ mapping.parent_drive_folder_id：
     - 視為「資料夾消失」
     - mapping.is_unlinked = TRUE
     - 若該 mapping scope_type='TASK' → 該 task 下所有 DRIVE_SYNC evidence 軟刪
       (deleted_reason='DRIVE_MOVED_OUT')
     - 若該 mapping 是上層（PROJECT/AP/CG/CONTROL/AO）→ 後代所有 task 的
       DRIVE_SYNC evidence 一併軟刪（避免 dangling）
     - enqueue CREATE_FOLDER job 重建（Q8.3 處理）

場景三：folder 被刪 (change.removed / trashed)
  → 同場景二
```

#### 8.6.1 系統內 Task CRUD 對 Drive 的影響（v0.2 補充）

> 系統內 task 的 create / rename / delete 都是「系統 → Drive」單向同步，由 hook 進 `JobService` 對應方法觸發。

| Task 操作 | 系統 API | Drive 端動作 | 設計理由 |
|----------|---------|------------|---------|
| Task create | `POST /grc/project/<pid>/assessment-object/<ao_uid>/jobs` | enqueue `CREATE_FOLDER` (scope_type=TASK)，新 task folder 出現在 AO 容器下 | BPMN editor 新增 Task 節點 / 切分多人 task 後，evidence 上傳前 Drive 必須先有對應 folder |
| Task rename | `PUT /grc/project/<pid>/job/<job_uid>` | enqueue `RENAME_FOLDER` (scope_type=TASK)；force overwrite | task name 改了 → Drive 上 folder 顯示名跟著變，user 從 Drive 端找得到 |
| Task delete (v0.3 修訂) | `DELETE /grc/project/<pid>/job/<job_uid>` | **把 task folder move 到 AP 的 `_Archive/`**；mapping 標 `is_unlinked=TRUE` | 統一系統刪除 UX；archive 後 Drive 上仍可下載證據；ancestor=ARCHIVE 使 sync 不會 re-import；user 不需切換到 Drive 操作 |

**Task delete 詳細處理（v0.3 設計決策）**：

```
1. 系統內 JobService.delete_job 完成 task 軟刪（DB tx commit）
2. Hook 進 JobService.delete_job 在 commit 後呼叫
   DriveSyncOrchestrationService.try_archive_task_folder(tenant_id, task_uid)
3. orchestration:
   a. 找 task mapping (scope_type='TASK', scope_uid=task_uid) → 拿 task folder ID
   b. 從 task → AO → Control → CG → AP 反推所屬 AP，取對應 ARCHIVE mapping
      - 若 ARCHIVE mapping 不存在 / is_unlinked=TRUE → 重建 `_Archive/`
   c. 呼叫 Drive API：files.update?addParents={archive_id}&removeParents={ao_id}
      （搬整個 folder + 內含檔案到 _Archive/）
   d. update task mapping SET is_unlinked=TRUE
4. Best-effort：Drive API 失敗只 log warning + 寫 sync job 失敗紀錄，不阻擋系統內軟刪
5. 系統內 evidence 紀錄保留（軟刪 task 不刪 evidence；歷史視角可查）
```

**為何 v0.3 改成 archive 而非「保持原位」**：
- v0.2「保持原位 + mapping unlinked」會讓 user 在 Drive 上看到「明明 task 已刪除但 folder 還在原地」造成混淆
- archive 後 Drive 上的檔案統一收到 `_Archive/`，視覺上明確分離 active vs deleted
- 與單一 evidence delete 的行為一致（兩者都 archive）
- 避免誤刪：folder 仍在 Drive，user 隨時可救援
- ancestor=ARCHIVE 自動 SKIP，不會被當成新 evidence 重新 import


### 8.7 重建資料夾 (`CREATE_FOLDER` for unlinked)

```
1. 查 mapping（適用所有 scope_type，含 TASK）
2. 確認 parent mapping 仍存在
3. drive.files.create (folder, parent=parent_drive_folder_id, name=display_name_snapshot)
4. set permission anyoneWithLink+writer
5. 更新 mapping: drive_folder_id=新id, is_unlinked=FALSE
6. 該 scope 是 TASK → 同時 enqueue RECONCILE_TASK_FOLDER 確保資料對齊
   - 若 user 把搬走的舊資料夾再搬回（或往新資料夾上傳檔案），
     RECONCILE 會重新 import；之前因 DRIVE_MOVED_OUT 軟刪的 evidence
     不會自動 un-delete（被視為「新檔案」處理），避免狀態混亂。
7. 該 scope 是 AO（容器層）→ 重建後對其下所有 task mapping 各 enqueue 一次
   RECONCILE_TASK_FOLDER（task folder 的 parent 變了，需要連動重建）。
8. 上層（PROJECT/AP/CG/CONTROL）重建類似：先建自己 → 對所有後代依序重建（CREATE_FOLDER）
   → 對所有最底層 TASK 各跑 RECONCILE_TASK_FOLDER。
```

### 8.8 RECONCILE_TASK_FOLDER（v0.2 改名）

> v0.1 名為 `RECONCILE_AO_FOLDER`；v0.2 改成以 task folder 為單位（更精準），enum 與 job_type 同步改名為 `RECONCILE_TASK_FOLDER`。AO-level 對帳由「對該 AO 下每個 task 各跑一次 RECONCILE_TASK_FOLDER」達成。

```
Payload: { tenant_id, task_uid (= job_execution.uid) }

觸發時機：
  - job revert (COMPLETED → PROCESSING) 時補抓 COMPLETED 期間的檔案
    （revert hook 拿 job_execution.uid 直接 enqueue，不用走 AO 反查）
  - Task / 上層資料夾重建後（見 §8.7）

流程：
  1. 查 mapping (scope_type='TASK', scope_uid=task_uid) → 拿 drive_folder_id
  2. drive.files.list (q="'{task_folder_id}' in parents and trashed=false")
  3. for each file:
     若 evidence WHERE drive_file_id=file.id AND is_deleted=FALSE 不存在
       → enqueue IMPORT_DRIVE_FILE { drive_file_id, task_mapping_uid }
  4. for each evidence WHERE job_execution_id=task.id
        AND source=DRIVE_SYNC AND is_deleted=FALSE:
     若 drive_file_id 不在當前 Drive 列表中
       → 軟刪 (DRIVE_DELETED)
```

### 8.9 Worker 並行策略

- 同一個 tenant 的 job **序列化處理**（用 advisory lock 或 `SELECT FOR UPDATE SKIP LOCKED`）。
- 不同 tenant 可並行（pool size 配置）。
- 失敗：exponential backoff（1m, 5m, 15m, 1h, 6h）。
- max_retries 用完 → status=FAILED → admin 在整合設定頁可看到失敗清單，可手動重試。

---

## 9. Project Start 整合

### 9.1 觸發時機

於 `OscalProjectStartRoute.post` 主流程結束後（21 張 table 寫入完成）：

```python
# api/oscal/projects/start_route.py（既有）
result = oscal_project_start_service.start(...)
return success(result)

↓ 改為

result = oscal_project_start_service.start(...)
# 新增：若該 tenant 有 Drive 整合，enqueue 資料夾建立
if tenant_drive_integration_service.is_connected(tenant_id):
    drive_sync_job_service.enqueue('INIT_PROJECT_FOLDERS', {
        'tenant_id': tenant_id,
        'project_uid': result.project_uid,
    })
return success(result)
```

> **Transaction 邊界**：
> - `oscal_project_start_service.start(...)` 內部的 21 張 table 寫入維持原本的 `@transaction`。
> - `enqueue('INIT_PROJECT_FOLDERS', ...)` 在 start transaction commit 之後才呼叫，自帶獨立的 `@transaction`。
> - 若 enqueue 失敗（例如 DB 暫時無法寫 drive_sync_jobs）→ project 已啟動成功，但 Drive 資料夾不會自動建。
>   admin 可在「雲端空間整合設定」頁手動觸發重建（提供一支 `POST /integrations/google-drive/projects/<uid>/init-folders`）。

### 9.2 `INIT_PROJECT_FOLDERS` worker 邏輯（v0.2 — 6 層）

```
1. 確認 tenant_drive_integration.status == CONNECTED, 否則 SKIP
2. 確認根目錄 mapping 存在，否則建
3. 建立 PROJECT folder（parent = root）
4. for each AP in project:
     建立 AP folder（parent = project，名稱 "第N輪 - {ap.name}"）
     for each Control Group:
        建立 CG folder
        for each Control in CG:
           建立 Control folder
           for each AO in Control:
              建立 AO 容器 folder（parent = control, 名稱 "{ao.title}"）
              寫 mapping (scope=AO, scope_uid=ao_uid, drive_folder_id=...)
              for each Task (job_execution) of this AO in this AP cycle:
                 建立 Task folder
                   parent = ao folder
                   名稱 "[T-{task_id}] {task_name}"  (task_id = job_execution.uid[:8])
                 寫 mapping (scope=TASK, scope_uid=job_execution.uid, drive_folder_id=...)
5. 每建一個 folder：set anyoneWithLink+writer permission
6. 為避免 Drive API rate limit (1000 req / 100s / user)，
   batch 處理 + sleep；單一 project 含 task 預估 < 10 分鐘完成
   （比 v0.1 多 N 個 task folder，N = AO 數 × 平均 task 數）
```

> **新增 task 補建**：若 AO init 完成後再加 task（指派多人 / split），由 task creation hook
> 偵測並 enqueue `CREATE_FOLDER (scope=TASK)`，不重跑 INIT。

### 9.3 失敗處理

- 中途失敗（如 token 過期）→ 已建的 mapping 不刪 → retry 時 worker 從未建的 scope 繼續。
- 單個資料夾建立失敗（如名稱衝突）→ 略過該分支，繼續其他，最後一次性 alert admin。

---

## 10. Two-source Evidence Model

> **Evidence 綁定層級（v0.2）**：所有 evidence（不分 SYSTEM_UPLOAD / DRIVE_SYNC）都直接綁 `job_evidences.job_execution_id`，亦即「task」。Drive 同步路徑為 `Drive task folder → drive_folder_mappings(scope_type='TASK') → job_executions → 寫 evidence`，AO 層只是 UI 上的容器概念，不參與 evidence 綁定。

### 10.1 來源區分

| 屬性 | SYSTEM_UPLOAD | DRIVE_SYNC |
|------|---------------|------------|
| Source of truth | 系統 | Google Drive |
| Minio | ✓ 原檔 | ✓ 快取副本 |
| DB (job_evidences) | ✓ | ✓（多 drive_file_id 欄位） |
| Drive | ❌ 不寫入 | ✓ user 自行管理 |
| 系統內可下載 | ✓ | ✓（從 Minio） |
| 系統內可預覽（docx→pdf） | ✓ | ✓（檔案在 Minio，邏輯一致） |
| 系統內可刪除（v0.3） | ✓ | ✓（會把 Drive 檔案 move 到 `_Archive/`） |
| Drive 上刪除影響 | ❌ | ✓ 軟刪除 |

> **v0.3 系統刪除行為統一**（取代 v0.1 / v0.2 的「禁刪 DRIVE_SYNC」規則）：
>
> | 系統操作 | SYSTEM_UPLOAD evidence | DRIVE_SYNC evidence |
> |---------|----------------------|---------------------|
> | 刪除單一 evidence | Minio 檔案保留（30 天救援期） + DB 軟刪 | **Drive 上 move 檔案到 `_Archive/`**（best-effort）+ DB 軟刪 |
> | 刪除整個 task | Mapping 標 unlinked + task 下所有 evidence 軟刪 | **Drive 上 move 整個 task folder 到 `_Archive/`**（best-effort）+ mapping 標 unlinked + evidence 軟刪 |
>
> Archive 動作為 **best-effort**：Drive API 失敗不阻擋系統內軟刪 commit；失敗時 log warning + 寫 sync job 失敗紀錄，admin 可在「失敗紀錄」介面看到並手動處理。

### 10.2 UI 顯示

AO Evidence 區塊（`AuditControlRef.vue`）改造：

```
┌─ Evidence (5) ──────── [🔗 此 AO 的 Google Drive 資料夾] ─┐
│                                                          │
│  [📁 Drive] policy_v2.pdf       2026-04-20 14:30         │
│             ⬇ 下載  👁 預覽  🔗 在 Drive 開啟              │
│                                                          │
│  [💾 系統]  audit_screenshot.png  bob  2026-04-19 10:15  │
│             ⬇ 下載  👁 預覽  🗑 刪除                       │
│                                                          │
│  [📁 Drive] meeting_notes (Google Doc)                   │
│             🔗 在 Drive 開啟（LINK type）                  │
└──────────────────────────────────────────────────────────┘
```

Badge 顏色：
- 📁 Drive: 藍色 outline
- 💾 系統: 灰色

### 10.3 預覽功能影響

User 既有的 docx → PDF → iframe 預覽功能 **無需修改**：
- 兩種來源的 binary 檔案都在 Minio
- 預覽 service 從 Minio file_id 取檔，不需要 source 區分

### 10.4 Drive 資料夾 / 檔案連結

- **AO 區塊頂部顯示「此 AO 的 Drive 資料夾」連結** = AO mapping 對應的 Drive folder URL（自組 `https://drive.google.com/drive/folders/{ao_drive_folder_id}`）。User 進到 AO 容器層後可看到下面所有 task folder。
- **每個 task 區塊（在 evidence 列表分組顯示）顯示「此任務的 Drive 資料夾」連結** = TASK mapping 對應的 Drive folder URL。User 點進去就能直接上傳 evidence 到正確的 task。
- **個別 DRIVE_SYNC evidence 的「在 Drive 開啟」** = `https://drive.google.com/file/d/{drive_file_id}/view`

> Spec 設計上預期 user 主要走「task folder 連結」路徑直接上傳檔案；AO folder 連結只供瀏覽 / 對照用。

---

## 11. Edge Cases & Failure Modes

| 情境 | 處理 |
|------|------|
| Tenant 沒設 Drive 整合 | Project start 跳過資料夾建立；UI 不顯示「Drive 連結」按鈕 |
| Token revoke 期間有 Drive 變更 | webhook 仍會打進來，但 worker call API 失敗 → status=REVOKED → 通知 admin |
| Drive API rate limit (429) | exponential backoff retry |
| Webhook channel 過期未 renew | 偵測到後重新註冊；錯過的變更靠 `changes.list(pageToken=cursor)` 補回 |
| User 刪除 GuidantAI 根目錄 | `is_unlinked=TRUE`，下次 sync 重建根目錄 + 整套結構（用 RECONCILE_TASK_FOLDER 對每個 task folder 補檔案） |
| 同一 tenant 多人 admin 嘗試 connect | DB UNIQUE constraint 阻擋；UI 顯示「已連線：xxx」+ 重新連線需先 disconnect |
| 檔案上傳到 GuidantAI 之外的 Drive 區域 | 忽略（不在我們管的 mapping 內） |
| 檔案 > 20MB | reject + 寫 oversized 紀錄 + alert |
| Google Doc / Sheet / Slide | 走 LINK evidence（reference_url=webViewLink） |
| 檔案被搬到別的 task 資料夾 | 視為原 task 刪除 + 新 task 新增（兩筆變更） |
| 檔案被丟到 AO 容器層（非 task folder） | 忽略 + 寫一筆 admin alert（v0.2 起 evidence 必須在 task folder） |
| 新 task 加到既有 AO（AO 已 init 完） | `JobService.create_job` hook 偵測到 → orchestration `try_enqueue_create_task_folder` → enqueue CREATE_FOLDER (scope_type=TASK)，task folder 出現在 AO 容器下；觸發點：BPMN editor 新增 Task 節點 / planning 頁面新增 task |
| Task 被改名 | `JobService.update_job` hook 偵測 name 變動 → orchestration `try_enqueue_rename_folder('TASK', ...)` → worker 改 Drive folder name；force overwrite |
| Task 被刪 / 軟刪 / 取消（v0.3 修訂） | `JobService.delete_job` hook → orchestration `try_archive_task_folder(task_uid)` → 把該 task folder move 到對應 AP 的 `_Archive/`；同時 mapping 標 `is_unlinked=TRUE`、evidence 軟刪。Drive 上 task folder 仍存在（在 `_Archive/` 下），user 可下載證據；新檔案就算被 user 拖進去也 SKIP（ancestor 為 ARCHIVE） |
| 系統內刪 DRIVE_SYNC 單一 evidence（v0.3） | `JobEvidenceService.delete_job_evidence` 不再 raise `GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE`；改呼叫 orchestration `try_archive_drive_file(evidence)`（best-effort：Drive 上 move 檔案到 `_Archive/`）→ DB 軟刪 evidence。Drive 失敗仍允許 DB 軟刪 commit。 |
| User 手動把檔案拖進 `_Archive/` | webhook 偵測 → `_process_one_change` 看到 file 從 active task folder 消失（ancestor 變了）→ 走「該檔案在原 task 已不見」邏輯 → 軟刪對應 evidence (deleted_reason=DRIVE_MOVED_OUT)。新位置（ancestor 為 ARCHIVE）SKIP，不重新 import。 |
| User 手動把 archived 檔案拖回 active task folder（restore） | webhook 偵測 → ancestor 不再含 ARCHIVE → 走正常 `handle_file_change` → import 為**新** evidence row（不嘗試 un-delete 原 row，避免狀態追蹤複雜化）。原軟刪 row 留歷史。 |
| User 手動刪掉 `_Archive/` folder | webhook → `handle_folder_change` 偵測 ARCHIVE mapping 對應的 folder 消失 → mapping `is_unlinked=TRUE`。下次系統需 archive 時，orchestration 偵測 mapping unlinked → 重建 `_Archive/`（同 §8.7 重建邏輯）後再執行 archive。 |
| 同名檔案被 archive 多次 | Drive 自動加後綴 `file.pdf`、`file (1).pdf`、`file (2).pdf` — 可接受，不額外處理。 |
| Job COMPLETED 後 Drive 仍被加檔 | worker 偵測到 → SKIP，不 import |
| Job revert（COMPLETED → PROCESSING） | enqueue RECONCILE_TASK_FOLDER（payload 帶 task_uid）補抓 COMPLETED 期間的檔案 |
| 重複 webhook（同一變更觸發多次） | drive_file_id UNIQUE + modifiedTime 比對防重複 import |
| Drive 帳號暫時離線 / 503 | retry 機制 |

---

## 12. API Endpoints

### 12.1 整合管理 API

```
GET    /api/integrations/google-drive
       回傳當前 tenant 的整合狀態（不含 token）

POST   /api/integrations/google-drive/auth-url
       產生 OAuth URL（含 state CSRF token）
       Response: { auth_url }

GET    /api/integrations/google-drive/callback?code=&state=
       OAuth callback（HTML response）

DELETE /api/integrations/google-drive
       中斷連線

POST   /api/integrations/google-drive/sync
       手動觸發 PROCESS_DRIVE_CHANGES（user 點「立即同步」）

GET    /api/integrations/google-drive/sync-jobs?status=&page=
       查 sync job 歷史 / 失敗清單（admin only）

POST   /api/integrations/google-drive/sync-jobs/<uid>/retry
       手動 retry 某筆失敗 job

POST   /api/integrations/google-drive/projects/<project_uid>/init-folders
       手動觸發資料夾重建（用於 project start enqueue 失敗的補救）
```

### 12.2 Webhook receiver

```
POST   /api/webhooks/google-drive/<tenant_id>
       Google 推播進來
```

### 12.3 既有 Evidence API 修改

```
GET    /job-evidences?job_execution_uid=
       Response 新增 source / drive_file_id / drive_url 欄位

DELETE /job-evidence/<uid>
       (v0.3) SYSTEM_UPLOAD / DRIVE_SYNC 都允許刪除
       - SYSTEM_UPLOAD：原邏輯（DB 軟刪 + Minio 保留 30 天）
       - DRIVE_SYNC：先呼叫 orchestration `try_archive_drive_file(evidence)`
                     把 Drive 檔案 move 到 AP 的 `_Archive/` (best-effort) → 再 DB 軟刪
       - 不再 raise GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE（v0.3 已 deprecated 該 error code）
```

權限要求：
- 整合管理：tenant admin
- Webhook：無 JWT（由 X-Goog-Channel-Token 驗證）
- 手動同步：tenant admin
- 既有 evidence API：維持原有（manager / participant）

---

## 13. Frontend Changes

### 13.1 新頁面：「雲端空間整合設定」

**Route**: `/settings/cloud-integrations`（admin only）

**Components**:
- `CloudIntegrationsView.vue` — 容器頁
- `GoogleDriveIntegrationCard.vue` — Drive 區塊
- `SyncJobHistoryDialog.vue` — 失敗清單 dialog

**狀態 UI**:

```
未連線:
┌─────────────────────────────────────────────────┐
│  Google Drive                                    │
│  📦 透過 Drive 上傳 evidence，自動同步進系統。      │
│                                                  │
│  ⚠ 將授權系統存取連線帳號的整個 Drive，建議使用    │
│     dedicated 共用公司帳號。                       │
│                                                  │
│              [連線 Google Drive]                 │
└─────────────────────────────────────────────────┘

已連線:
┌─────────────────────────────────────────────────┐
│  Google Drive                                    │
│  ✅ 已連線：compliance@company.com                 │
│  🗂 根目錄：GuidantAI/  [🔗 開啟]                 │
│  ⏱ 上次同步：3 分鐘前                              │
│  🔌 Webhook 到期：2026-04-26                      │
│                                                  │
│  [立即同步] [查看失敗紀錄] [中斷連線] [重新授權]    │
└─────────────────────────────────────────────────┘

異常狀態（REVOKED / EXPIRED）:
紅色 banner + [重新授權]
```

### 13.2 OAuth callback 處理

新增 `src/views/integrations/GoogleDriveCallback.vue`：
- 接收後端 redirect 後的頁面（其實是後端 render 的 HTML，但若用前端 redirect 模式也可走這頁）
- `window.opener.postMessage('drive-connected')` 通知 opener
- `window.close()`

或更簡單：後端 callback 直接 render HTML 含 inline script 做 postMessage + close（不經過 Vue route）。

### 13.3 既有 AO Evidence UI 改造

**`src/components/grc/AuditControlRef.vue`**:
- Evidence 列表加 source badge
- DRIVE_SYNC evidence 顯示「🗑 刪除」按鈕（v0.3：與 SYSTEM_UPLOAD 一致），點擊呼叫 DELETE API → 後端負責把 Drive 檔案 move 到 `_Archive/` + 軟刪 DB；UI toast 提示「已刪除（檔案已移至 Drive Archive）」
- 同時保留「🔗 在 Drive 開啟」按鈕（供刪除前查看 / 下載）
- AO 區塊頂部加「📁 此 AO 的 Google Drive 容器」按鈕（前提：tenant 有連 Drive）— 點擊到 AO folder 看所有 task folder
- **每個 task 區塊頂部**另加「📁 此任務的 Drive 資料夾」按鈕，點擊到該 task 的專屬 folder（user 上傳檔案的入口）

**`src/components/grc/JobExecutionDrawer.vue`**:
- 此 component 是「進到單一 task」的視角，evidence 區塊頂部直接顯示 task folder 連結（不需要再顯示 AO 容器層連結）
- 其他改造同上（badge / DRIVE_SYNC 可刪 / 在 Drive 開啟）

> Backend response 規範：
> - `GET /job-evidences?job_execution_uid=...` response envelope `meta` 加 `task_drive_folder_url`（單一 task 視角）
> - AO 視角的 list endpoint（如有 grouping by task）response 各 task 群組內附 `task_drive_folder_url`，AO 層 response top-level 附 `ao_drive_folder_url`

> **v0.3 移除 §13.3.1**：原 v0.2 §13.3.1「已刪除 Task 的 evidence 顯示」段落已移除。理由：依 user feedback，「UI 上已經看不到任務了的話，就不需理會」— 已刪除的 task 在系統 UI 上不出現，evidence 列表自然也不會看到，毋需特殊樣式 / 旗標處理。

### 13.4 新 Service

**`src/service/CloudIntegrationService.js`**:
```js
class CloudIntegrationService extends BaseService {
  async getGoogleDriveStatus()
  async getAuthUrl()
  async disconnect()
  async triggerSync()
  async listSyncJobs(params)
  async retrySyncJob(uid)
}
```

**API endpoint constants** (in `src/config/api/api.js`):
```
INTEGRATION_GDRIVE: '/integrations/google-drive'
INTEGRATION_GDRIVE_AUTH_URL: '/integrations/google-drive/auth-url'
INTEGRATION_GDRIVE_SYNC: '/integrations/google-drive/sync'
INTEGRATION_GDRIVE_SYNC_JOBS: '/integrations/google-drive/sync-jobs'
```

### 13.5 路由

```js
// router/index.js
{
  path: '/settings/cloud-integrations',
  name: 'CloudIntegrations',
  component: CloudIntegrationsView,
  meta: { requiresAuth: true, requiresAdmin: true }
}
```

---

## 14. Permissions & Roles

| 操作 | 角色 |
|------|------|
| 連線 / 中斷 / 重新授權 Google Drive | tenant admin |
| 觸發手動同步 | tenant admin |
| 查 sync jobs / 重試失敗 job | tenant admin |
| 看 AO Drive 資料夾連結 | 任何 evidence 可看權限的人 |
| 看 DRIVE_SYNC evidence 的「在 Drive 開啟」 | 同上 |
| 刪除 SYSTEM_UPLOAD evidence | 既有規則（manager 等） |
| 刪除 DRIVE_SYNC evidence (v0.3) | 同 SYSTEM_UPLOAD（manager 等）；後端會自動把 Drive 檔案 move 到 `_Archive/` |
| 觸發 INIT_PROJECT_FOLDERS | 系統自動（PM 啟動專案） |

---

## 15. Error Codes

新增於 `common/code/grc_error_code.py`（接續現有序號）：

| Code | Constant | 訊息 |
|------|----------|------|
| `GRC_400030` | `GRC_DRIVE_OAUTH_STATE_INVALID` | OAuth 狀態驗證失敗 |
| `GRC_400031` | `GRC_DRIVE_FILE_OVERSIZED` | Drive 檔案超過大小限制 |
| `GRC_401010` | `GRC_DRIVE_TOKEN_REVOKED` | Drive 連線已失效，請重新授權 |
| `GRC_403020` | `GRC_FORBIDDEN_DELETE_DRIVE_EVIDENCE` | Drive 同步的證據請至 Google Drive 刪除 — **v0.3 deprecated**（保留定義但不再 raise；既有引用可繼續存在）。v0.3 起 DRIVE_SYNC evidence 改為「系統刪除時把 Drive 檔案 move 到 `_Archive/`」 |
| `GRC_404030` | `GRC_DRIVE_INTEGRATION_NOT_FOUND` | 此 tenant 尚未連線 Google Drive |
| `GRC_404031` | `GRC_DRIVE_FOLDER_MAPPING_NOT_FOUND` | Drive 資料夾對應不存在 |
| `GRC_409020` | `GRC_DRIVE_INTEGRATION_ALREADY_EXISTS` | Google Drive 已連線，請先中斷 |
| `GRC_412020` | `GRC_DRIVE_WEBHOOK_TOKEN_INVALID` | Webhook 驗證失敗 |
| `GRC_500030` | `GRC_DRIVE_API_ERROR` | Google Drive API 錯誤 |

---

## 16. Migration Plan

### 16.1 SQL migrations

依 CLAUDE.md 規範，每段加日期註解，新 table 給 `cm_app` 權限：

`scripts/sql/2026-MM-DD-google-drive-sync.sql`：

```sql
-- Date: 2026-MM-DD

-- 1. 建立 tenant_drive_integrations (2026-MM-DD)
CREATE TABLE compliance.tenant_drive_integrations (...);
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.tenant_drive_integrations TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.tenant_drive_integrations_id_seq TO cm_app;

-- 2. 建立 drive_folder_mappings (2026-MM-DD)
CREATE TABLE compliance.drive_folder_mappings (...);
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.drive_folder_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.drive_folder_mappings_id_seq TO cm_app;

-- 3. 建立 drive_sync_jobs (2026-MM-DD)
CREATE TABLE compliance.drive_sync_jobs (...);
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.drive_sync_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.drive_sync_jobs_id_seq TO cm_app;
CREATE INDEX idx_drive_sync_jobs_pending ON compliance.drive_sync_jobs (tenant_id, status, next_run_at) WHERE status = 'PENDING';

-- 4. job_evidences 新增欄位 (2026-MM-DD)
ALTER TABLE compliance.job_evidences
  ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'SYSTEM_UPLOAD',
  ADD COLUMN drive_file_id VARCHAR(100),
  ADD COLUMN drive_file_modified_at TIMESTAMPTZ,
  ADD COLUMN drive_last_modifying_user_email VARCHAR(255),
  ADD COLUMN is_deleted BOOL NOT NULL DEFAULT FALSE,
  ADD COLUMN deleted_at TIMESTAMPTZ,
  ADD COLUMN deleted_reason VARCHAR(40);
CREATE UNIQUE INDEX idx_job_evidences_drive_file_id ON compliance.job_evidences (drive_file_id) WHERE drive_file_id IS NOT NULL;

-- 5. RLS policies for new tables (2026-MM-DD)
-- 沿用現有 tenant_id RLS pattern
```

### 16.2 環境變數新增

`config/<env>.py`：
```
GOOGLE_DRIVE_OAUTH_CLIENT_ID
GOOGLE_DRIVE_OAUTH_CLIENT_SECRET
GOOGLE_DRIVE_OAUTH_REDIRECT_URI
DRIVE_TOKEN_ENCRYPTION_KEY      # Fernet key (URL-safe base64)
DRIVE_WEBHOOK_PUBLIC_BASE_URL   # https://xxx.example.com
DRIVE_SYNC_WORKER_POOL_SIZE     # default 4
DRIVE_FILE_SIZE_LIMIT_MB        # default 20
```

### 16.3 套件新增

`pyproject.toml`：
- `google-api-python-client`
- `google-auth-oauthlib`
- `apscheduler`
- `cryptography`（Fernet，可能已有）

### 16.4 Rollback

- 新表 drop 即可。
- `job_evidences` 新欄位都有預設值，舊 query 不受影響；rollback 時可保留欄位（資料不刪）。

---

## 17. Pre-implementation Checklist（事前準備）

實作前需要 product team / ops 完成：

### 17.1 Google Cloud 設定

- [ ] 建立 GCP project (per env)
- [ ] 啟用 Google Drive API
- [ ] 建立 OAuth 2.0 Client ID (Web application)
- [ ] 設定 Authorized redirect URIs (per env)
- [ ] 設定 OAuth consent screen
- [ ] 將 dev / demo 環境的 admin 加入 test users（unverified 階段）
- [ ] (Production 前) 啟動 OAuth restricted scope verification 流程

### 17.2 Domain / DDNS

- [ ] Production：確認有正式 domain + HTTPS
- [ ] Dev / demo：選擇方案
  - [ ] 自有 domain CNAME → DDNS IP
  - [ ] 或 Cloudflare Tunnel
  - [ ] 或 ngrok（暫時測試用）
- [ ] 在 GCP Console 完成 domain verification

### 17.3 環境變數 / Secret

- [ ] 產生 Fernet key (`Fernet.generate_key()`) per env，存放 secret manager
- [ ] OAuth client secret 存放 secret manager
- [ ] Public base URL 寫入 config

### 17.4 共用 Drive 帳號

- [ ] 客戶端建立 dedicated Google 帳號（如 `compliance@<customer>.com`）
- [ ] 確認該帳號 Drive 容量充足
- [ ] 客戶 IT 確認允許該帳號做 OAuth 授權

### 17.5 套件 / 工具

- [ ] `poetry add google-api-python-client google-auth-oauthlib apscheduler`
- [ ] 評估 worker 部署方式（與 main_socketio.py 同 process / 獨立 process）

---

## 18. Open Questions（spec 階段未解，後續再議）

1. **OAuth verification 時程**：full `drive` scope 上 production 前需跑 Google verification（4-12 週）。這個流程要由誰啟動、預算為何？
2. **KMS 升級時程**：第一版用 Fernet + env var，何時升級到 AWS KMS？是否要寫到 production 第一個 release 的 must-have？
3. **多 tenant 共用 OAuth client 的 ID 額度**：unverified 100 user 上限是否足夠覆蓋所有 staging tenant？
4. **Drive 帳號儲存空間配額耗盡**：當共用帳號 Drive 滿了該怎麼通知 admin？monitor 機制要做到什麼程度？
5. **稽核匯出格式**：`drive_last_modifying_user_email` 欄位已預留並存入。當客戶稽核要求匯出「實際 Drive 上傳人」時，要在哪裡開放此欄位顯示 / 匯出？是 audit report PDF、CSV 匯出、還是 UI hover tooltip？

---

## 19. Out of Scope（v1）

- OneDrive / Dropbox / Box 等其他雲端整合
- 系統 → Drive 雙向同步（只做 Drive → 系統）
- Drive 端細粒度 ACL（只用 `anyoneWithLink+writer`）
- Drive 檔案版本歷史的同步
- Drive 上多人協同編輯通知
- 一個 tenant 多組 Drive 帳號
- 匯出整個 AO 的 evidence 包到 Drive
- AI 自動分類 / 標籤 Drive 檔案
- 細粒度 quota 管理 / 用量分析
- 整合到 jedi-flow-engine workflow（Drive 上傳是否觸發 workflow 事件 — 未來再評估）
