# ADR-016:visionA download NEF 改走 converter `GET /api/v1/jobs/{id}/result` 中轉(撤回 FAA 鏈) ## 狀態 Accepted — 2026-05-16 ## 上位 / 同層 ADR - **部分 supersedes**: - [ADR-014](./adr-014-conversion-integration.md) §2「Download — FAA delegated token」整段(visionA → FAA delegated token 鏈撤回;visionA download 改走 converter `GET /api/v1/jobs/{id}/result`)。ADR-014 §2 中關於「server-side proxy / 不退回 302 / `` frontend pattern」的決策**沿用、由本 ADR 接手**。 - [ADR-014](./adr-014-conversion-integration.md) §3「半自動 — 加到模型庫」中「visionA backend 跟 FAA pull NEF(scope `files:download.read`)」的部分:本 Phase 0.8 範圍內 visionA 不主動 pull NEF;如 Phase 1+ 需要 server-side pull,改走 converter `GET /api/v1/jobs/{id}/result` 同一條路徑。 - [ADR-014](./adr-014-conversion-integration.md) §5「Service token cache — 仿 converter scheduler 模式」中 **FAA 部分**(scope `files:download.read / delegate` 不再由 visionA 取;對應 service client / tenant_id env 撤回)。 - [ADR-014](./adr-014-conversion-integration.md) §7「失敗模式 retry 矩陣」中「FAA pull(加到模型庫)」、「MC token endpoint」、「MC delegated token」三 row(visionA 端不再觸發;converter 端有自己的 retry 矩陣,不在本 ADR 範圍)。 - [ADR-015 v2.0](./adr-015-server-to-server-api-key.md) §2「visionA → FAA(MC service token + delegated download token)」整段(v2.0 於 §2 維持 ADR-014 §2 設計,本 ADR-016 於 §2 撤回;visionA 端 mc_token_client 整檔再次砍除,OIDC ServiceClient* / TenantID env 廢棄)。 - **沿用**(不受本 ADR 影響): - [ADR-013](./adr-013-public-client.md):user login 的 OIDC public PKCE client(與 server-to-server 完全解耦) - [ADR-014](./adr-014-conversion-integration.md) §1(upload streaming proxy)、§3「加到模型庫」走 visionA `/api/models/init+finalize`、§4 模組劃分(不擴 model schema、`internal/conversion/` 包裝)、§6 user_id trust boundary - [ADR-015 v2.0](./adr-015-server-to-server-api-key.md) §1「visionA → converter 採 pre-shared API key」(本 ADR 在 download 路徑沿用同一條認證機制) - **同層**:本 ADR 與 ADR-014 / ADR-015 v2.0 三者並存後的最終態為: - visionA → converter(init / poll / promote / **result download**):API key(ADR-015 §1) - converter → FAA(promote 內部 PUT NEF):converter 自己用 OAuth client_credentials + scope `files:upload.write`(converter 既有實作,與 visionA 無關) - visionA ↔ FAA:**Phase 0.8b 範圍內不存在這條鏈**(本 ADR 撤回) - visionA ↔ MC(server-to-server):**Phase 0.8b 範圍內不存在這條鏈**(本 ADR 撤回;只有 user login 的 OIDC 公開 client 仍接 MC) --- ## 背景 (Context) ### 起因:致命發現(2026-05-16) 2026-05-16 在 backend agent 準備依 ADR-015 v2.0 §6「mc_token_client 部分復活」實作之前,對 Innovedus Member Center(MC)與 File Access Agent(FAA)的 source code 做全面驗證,發現 **ADR-014 §2 設計的 delegated download token 鏈路從 2026-05-02 寫文件至今一直是斷的、只是因為 visionA 從未實際 e2e 跑通 download 而沒人發現**。 #### 致命發現 1:MC 沒有「issue delegated download token」endpoint 對 `/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/` 完整 grep(8 個 controller): ``` AdminNewsletterListsController.cs AdminOAuthClientsController.cs AdminTenantsController.cs AuthController.cs NewsletterController.cs OAuthController.cs SendEngineIntegrationController.cs SubscriptionsController.cs TokenController.cs UserController.cs ``` 全部 endpoint 清單: - `POST /oauth/token`, `POST /oauth/authorize`(TokenController.cs / OAuthController.cs,OpenIddict OAuth/OIDC 標準) - `POST /auth/login`, `/auth/refresh`, `/auth/logout`, `/auth/register`, `/auth/password/{forgot,reset}`, `/auth/email/verify`(AuthController.cs) - `GET/PUT /user/profile`(UserController.cs) - Admin endpoints(tenants / oauth-clients / newsletter-lists) - `POST /integrations/send-engine/webhook-clients/upsert`(SendEngineIntegrationController.cs) - `POST/GET /subscriptions`(SubscriptionsController.cs) - `POST/GET /newsletter`(NewsletterController.cs) **MC source 完全沒有 `/file-access/download-tokens` endpoint,也沒有任何 file / download / token issuance 相關的 controller**。ADR-014 §2 line 60-68 描述的「MC POST /file-access/download-tokens」流程從未存在。 #### 致命發現 2:MC 沒有「validate delegated download token」endpoint FAA 端 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Infrastructure/Services/MemberCenterDelegatedDownloadTokenValidator.cs` 線 27-72: ```csharp public async Task ValidateAsync( DelegatedDownloadTokenValidationRequest request, CancellationToken cancellationToken) { var accessToken = await GetServiceAccessTokenAsync(cancellationToken); // ... using var response = await http.PostAsJsonAsync( _options.DownloadTokenValidationPath, // ← 對 MC 打的 validation endpoint payload, cancellationToken); // ... } ``` FAA assume MC 有 `_options.DownloadTokenValidationPath`(一個 token introspection endpoint,例如 `POST /file-access/validate-file-download-token`),但對 MC source 全 grep `download-token` / `file-access` / `validate` 都找不到對應 controller。 → 即使 visionA 哪天真的拿到一個 delegated download token、帶去打 FAA `GET /files/{key}`,FAA 也會在 `IDelegatedDownloadTokenValidator.ValidateAsync()` 階段因為 MC 不回 200 而把 token 視為 invalid,回 403。 #### 致命發現 3:ADR-014 從一開始就 assume 而沒驗 source 對 ADR-014 line 17-18 寫的: > **Innovedus Member Center (MC)** 是 OAuth/OIDC IdP,**同時負責簽 service-to-service token 與 delegated download token** line 75: > server-to-server 跟 MC 換 delegated token(scope `files:download.delegate`) → 2026-05-02 寫 ADR-014 時沒實際讀 MC source、直接 assume「MC 有 delegated token endpoint」。整條 delegated token 鏈路是 fictional。 ADR-015 v2.0 §2 line 109-153(2026-05-16 上午寫)也沿用這個 assumption(雖然 v2.0 描述的設計「正確的」是 ADR-014 §2 原意圖,但 ADR-014 §2 原意圖本身就建立在錯誤的 MC 能力假設上)。 #### 致命發現 4:FAA download endpoint 對 service token 也不接受 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` line 184-254:`GET /files/{**objectKey}` **沒掛 `.RequireAuthorization()`**,改用 `IDelegatedDownloadTokenValidator.ValidateAsync(...)` 驗 delegated download token。 → FAA download endpoint 強制只接 delegated token、不接 service token。 → 即使 visionA 從 MC 拿到 valid service token(這條鏈是真的 work 的,已有實證——converter promote 走 client_credentials + `files:upload.write` scope 正常 work),帶去打 FAA `GET /files/{key}` 也會 401 / 403。 → visionA 端**唯一**能 download FAA 上的 NEF 的方法是先取得 delegated token;但 MC 沒有 issue delegated token 的 endpoint(致命發現 1)。 #### 為什麼這個缺口從 2026-05-02 一直沒被發現 時間軸: | 日期 | 事件 | 影響 | |------|------|------| | 2026-05-02 | ADR-014 v1.0 寫定 | assume MC 有 delegated token endpoint | | 2026-05-09 ~ 11 | Phase 0.8 stage 部署遇 4 個 OAuth client_credentials blocker(converter `:read/write` scope 沒 onboard / converter image 舊版 / converter MC env / FAA OAuth 整合不明)| 發現的是 converter 線、不是 FAA delegated token 線 | | 2026-05-11 | ADR-015 v1.0:兩條都改 API key | FAA 線改 API key、跳過了 delegated token 鏈、致命缺口被掩蓋 | | 2026-05-15 | ADR-015 v1.1:補 reference middleware | 仍是 API key 路徑 | | 2026-05-16(上午) | ADR-015 v2.0:撤回 FAA API key、回到 ADR-014 §2 delegated token 設計 | **重新踩到 2026-05-02 的缺口**(但寫 v2.0 時還沒驗 MC source、再次 assume)| | 2026-05-16(下午 / 本 ADR) | 對 MC source 全 grep 確認 → 缺口從未修復 | 撤回 v2.0 §2、改走 converter 中轉 | **Phase 0.8 從來沒有真的把 download 跑到 e2e**:T4 frontend `` 觸發 download 的測試都是對 stub backend,從未真的觸發 visionA → MC issue delegated token → FAA validate 整條鏈,所以 4 處致命缺口疊起來成為一個從未被執行的 dead path。 ### 使用者拍板的硬約束 2026-05-16 在發現上述缺口後,使用者明確拍板: | 約束 | 理由 | |------|------| | **不動 MC** | MC 是公司共用 IdP;新加 endpoint 需 MC team 設計 + onboard scope + redeploy + 配 tenant,跨人協調週期長;過去 5/9 撞 scope 沒註冊已驗證此風險 | | **不動 FAA** | FAA 是 warrenchen 維護的公司共用 repo;既有 dual-auth 設計(JWT for write / delegated for download)改一行都要走跨人協調 | | 但要解決 download | PRD Phase 0.8 範圍內 user 要能下載 NEF(半自動「下載」按鈕 + Wireframe success card 都有) | → 解決方案必須在 jimchen 自己維護的 repo(visionA + converter)內完成、不觸碰 MC / FAA。 ### 既有可用基礎建設 | 元件 | 狀態 | 可用性 | |------|------|--------| | converter promote → FAA PUT NEF(OAuth + `files:upload.write`) | Phase 1 已上線 | ✅ work(已在 prod 跑 / `apps/task-scheduler/src/fileAccessAgent/client.js`)| | converter MinIO 存 NEF | converter scheduler 既有 | ✅ promote 後 NEF 同時保留在 converter MinIO + FAA(converter expires_at = 7 天)| | visionA → converter API key(ADR-015 §1)| commit `86b7175` 已實作 | ✅ work(init / poll / promote 都已驗證)| | converter scheduler 是 jimchen 自己 repo | — | ✅ 加新 endpoint 對 coordination cost 低 | | visionA backend stream proxy 結構(T4 已實作 `internal/conversion/faa_client.go` `DownloadStream` + `io.CopyN` size cap + `Content-Disposition` + context cancellation)| commit `e02059e` 已實作 | ✅ 結構保留、只是 stream 來源從 FAA 改 converter | --- ## 決策 (Decision) ### 採 **converter 加 `GET /api/v1/jobs/{id}/result` endpoint + visionA stream 中轉** 解決 download;promote 路徑不變。 ### 1. converter 新增 endpoint:`GET /api/v1/jobs/{id}/result` #### 1.1 完整 API spec ``` GET /api/v1/jobs/{id}/result HTTP/1.1 Host: 192.168.0.130:9501 Authorization: Bearer Accept: application/octet-stream ``` | 元素 | 規格 | |------|------| | Method | `GET` | | Path | `/api/v1/jobs/:id/result` | | Path param `id` | converter job id(UUID v4) | | Auth | `Authorization: Bearer `(同 ADR-015 §1 既有 middleware;converter middleware `subtle.ConstantTimeCompare` 比對 env) | | Query params | 無 | | Request body | 無 | #### 1.2 Response — 成功(200) ``` HTTP/1.1 200 OK Content-Type: application/octet-stream Content-Length: Content-Disposition: attachment; filename="_.nef" Cache-Control: no-store, no-cache, must-revalidate, max-age=0 ``` | Header | 來源 / 規格 | |--------|-----------| | `Content-Type` | 固定 `application/octet-stream` | | `Content-Length` | converter MinIO 物件實際大小(必填,給 visionA backend 設 Content-Length 透傳到 browser)| | `Content-Disposition` | converter 自行構造 filename:`_.nef`(例 `yolov5s_kl720.nef`,對齊 wireframe success card 顯示範例)。**visionA backend 收到後可選擇透傳或覆寫**(visionA 端有自己的 `defaultDownloadFilename(cj)` helper)| | `Cache-Control` | 固定 `no-store, no-cache, must-revalidate, max-age=0` | | Body | NEF binary stream(converter 從 MinIO get object 後直接 stream 出來) | #### 1.3 Response — 錯誤 | HTTP | code | 條件 | converter 回 body(JSON) | |------|------|------|--------------------------| | 401 | `unauthorized` | API key 不對 / 缺 / 格式錯 | `{"error":"unauthorized"}`(同 ADR-015 §3.5.1 既有 middleware)| | 404 | `job_not_found` | job_id 不存在 / 已被 GC(converter 7 天 expires_at)| `{"error":"job_not_found"}` | | 409 | `job_not_completed` | job 尚未 completed(status ≠ completed)| `{"error":"job_not_completed","status":""}` | | 410 | `result_expired` | job completed 但 MinIO 內 NEF 已被 GC(超 expires_at) | `{"error":"result_expired"}` | | 500 | `internal_error` | converter / MinIO 故障 | `{"error":"internal_error"}` | | 502 | `storage_unavailable` | MinIO 5xx / 不可達 | `{"error":"storage_unavailable"}` | | 503 | `service_busy` | converter 過載 | `{"error":"service_busy"}` | #### 1.4 Size cap converter 端不設 size cap(converter MinIO 容量為準)。visionA backend 端用 `io.CopyN(w, stream, 1 GiB)` 設 1 GiB 上限保護(同 conversion.md §4.1 既有設計)。 #### 1.5 與既有 endpoint 的關係 converter 既有 `POST /api/v1/jobs/:id/download-tokens` 是 Phase 2 預留 501(見 `apps/task-scheduler/src/routes/v1/jobs.js` line 1059-1066)。本 ADR 新增的 `GET /api/v1/jobs/:id/result` 是 Phase 0.8b 的「直接 stream 中轉」實作,**不取代** Phase 2 的 download-tokens endpoint(後者是「給 browser 直連 converter」設計,Phase 2 才評估)。 #### 1.6 converter 端實作要點(給 jimchen 跨 repo 任務) - Route 加在 `apps/task-scheduler/src/routes/v1/jobs.js`(或新建 `result.js` 並在 `v1/index.js` `router.use(...)`) - 套用既有 `requireReadAuth` middleware(與 `GET /api/v1/jobs/:id` 同一 scope;API key 比對由 middleware 統一) - handler 流程: 1. 從 jobService 查 job(status 必須是 `completed`、未過 expires_at) 2. 從 MinIO get object(`target_object_key` 或 promote 後同步保留在 MinIO 的物件 key) 3. set Content-Type / Content-Length / Content-Disposition / Cache-Control headers 4. `pipeline(minioStream, res)` 直接 stream(不暫存記憶體) - Test 加 `apps/task-scheduler/src/routes/v1/__tests__/result.integration.test.js`(mock MinIO + jobService) ### 2. visionA download flow(撤回 FAA / MC 鏈) #### 2.1 流程 ``` Browser ──GET /api/conversion/{job_id}/download (Cookie: visiona_session)──► visionA backend ↓ AuthMiddleware (OIDC cookie) ↓ ownership 檢查 (user_id ↔ job_id) ↓ ensurePromoted (對 converter 冪等 promote) ↓ converter_client.GetResult(ctx, job_id) GET {CONVERTER_BASE_URL}/api/v1/jobs/{id}/result Authorization: Bearer ↓ (converter middleware: ConstantTimeCompare) ↓ 200 NEF stream + Content-Length + Content-Disposition ↓ visionA backend handler: - Content-Type: application/octet-stream - Content-Length: - Content-Disposition: attachment; filename="..." (visionA 用自己的 defaultDownloadFilename(cj) 覆寫、保留與 wireframe 對齊) - Cache-Control: no-store, no-cache, must-revalidate - io.CopyN(c.Writer, stream, 1 GiB) ↓ Browser 收到 200 + attachment + 自動觸發下載 ``` #### 2.2 visionA backend 既有 stream proxy 結構保留 T4 已實作的 `internal/conversion/faa_client.go` 的 stream proxy 結構(io.CopyN + size cap + Content-Disposition + context cancellation)**全部沿用**,只是 stream 來源從 FAA 改 converter。 對應的程式碼變更(給 backend agent 下次任務): - `internal/conversion/faa_client.go` → **改名** `internal/conversion/converter_result_client.go`(或合併進 `converter_client.go`);移除 `DownloadWithDelegated` 簽名;新增 `GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)` - `internal/conversion/flow.go` DownloadStream 內部:移除「IssueDelegatedDownload」步驟、直接呼叫 `converter.GetResult` - `internal/conversion/mc_token_client.go` **整檔刪除**(ADR-015 v2.0 §6 部分復活 → 本 ADR 再次砍除;Phase 0.8b visionA 端不再有任何 visionA → MC server-to-server 路徑) - `internal/conversion/conversion.go` Service interface:`DownloadStream` 簽名不變(仍回 `(io.ReadCloser, *DownloadMetadata, error)`) - handler `internal/api/conversion.go`:`conversionDownloadHandler` 完全不動 ### 3. Promote 路徑完全不變 promote 仍走 visionA → converter `POST /api/v1/jobs/{id}/promote`: ``` Browser ──POST /api/conversion/{job_id}/promote-to-models──► visionA backend ↓ converter POST /api/v1/jobs/{id}/promote Authorization: Bearer ↓ converter 內部:用自己的 OAuth client + scope files:upload.write PUT FAA /files/{target_object_key} ↓ promote 成功;NEF 同時在 FAA 與 converter MinIO ↓ 回 {target_object_key} 給 visionA ↓ visionA backend 用既有 /api/models/init + /api/models/finalize 流程把 NEF 寫進 visionA model store ↓ (但注意:visionA 自己不 pull FAA;走的是 converter.GetResult 同一條路徑) ``` 「加到模型庫」流程中 visionA 需要拿到 NEF binary 寫進 model store——本 ADR 後此路徑也走 `converter.GetResult`(與 download 共用同一條),visionA 完全不直接打 FAA。 **converter → FAA 這條鏈仍是 OAuth client_credentials + `files:upload.write` scope**(converter 自己管,與 visionA 無關;本 ADR 不影響)。 ### 4. 認證統一:visionA 端只需 `VISIONA_CONVERTER_API_KEY` | 認證 secret | 用途 | Phase 0.8b v0.5(ADR-015 v2.0)| **Phase 0.8b v0.6(本 ADR)** | |------------|------|------------------------------|-----| | `VISIONA_CONVERTER_API_KEY` | visionA → converter(init / poll / promote / **download**)| ✅ 使用中 | ✅ 使用中(**新增 download 走同一把**)| | `VISIONA_OIDC_SERVICE_CLIENT_ID` | visionA → MC 換 service token(給 FAA 線用)| ✅ 重新啟用 | ❌ **再次廢棄**(撤回 v2.0 復活) | | `VISIONA_OIDC_SERVICE_CLIENT_SECRET` | 同上 | ✅ 重新啟用 | ❌ **再次廢棄** | | `VISIONA_OIDC_TENANT_ID` | FAA tenant_id claim 驗證 | ✅ 重新啟用 | ❌ **再次廢棄**(visionA 端不再需要)| | `VISIONA_FAA_BASE_URL` | visionA → FAA 的 base URL | ✅ 使用中 | ❌ **再次廢棄**(visionA 端不再直接打 FAA)| | `VISIONA_FAA_API_KEY` | (v1.0 加的)| ❌ 撤回 | ❌ 維持撤回(v2.0 已撤回) | → visionA 端 server-to-server 認證 secret 只剩 `VISIONA_CONVERTER_API_KEY` 一把(user login 的 OIDC public PKCE 仍維持 ADR-013 設計、不變)。 ### 5. visionA backend mc_token_client 整檔刪除(撤回 v2.0 部分復活) ADR-015 v2.0 §6 規劃「mc_token_client.go 部分復活」(保留 ServiceToken cache + IssueDelegatedDownload 邏輯)—— 本 ADR 撤回此規劃。 - mc_token_client.go 整檔刪除(commit `86b7175` 已經砍掉、不需重建) - mc_token_client_test.go 整檔刪除 - `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩欄位從 struct 刪除 - `ConversionConfig.TenantID` 從 struct 刪除 - main.go 不再 wire `MCTokenClient` --- ## 考慮過的替代方案 (Alternatives Considered) ### 方案 A(採用):converter 新增 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉 | 評估 | 內容 | |------|------| | 優點 | (1) 不動 MC、不動 FAA、不動 warrenchen、不動公司 MC team;(2) 認證統一 API key(jimchen 自己管 rotate);(3) visionA backend 既有 stream proxy 結構保留、改動範圍小;(4) 失敗模式少(只剩 converter 401 / 4xx / 5xx);(5) 修復 ADR-014 §2 從 2026-05-02 起的設計缺口(delegated token 鏈本來就 broken);(6) converter 與 visionA 都是 jimchen 維護、coordination 成本最低;(7) 與 Phase 1 follow-up「converter 加 download-tokens」路徑相容(Phase 2 升級時這個 GET result endpoint 可保留為「server-side pull」用途)| | 缺點 | (1) converter 變 download bottleneck(visionA 跨 internet 從 converter stream、再中轉給 user,雙倍流量);(2) converter MinIO 的 retention 變成 download 可用性 SLA(converter 7 天 expires_at = 7 天後 user 不能 download);(3) converter API 變 download 的單點故障(converter down → user 不能 download,即使 FAA 上有 NEF);(4) 跨 repo 任務(converter scheduler 加 endpoint)— 但因 jimchen 維護兩端、cost 可控 | | 排除原因 | **未排除 — 採用** | ### 方案 B:metadata-only 模型庫(NEF 永遠在 converter MinIO、不 promote 到 FAA) | 評估 | 內容 | |------|------| | 優點 | (1) 不必動 FAA / MC;(2) promote 流程簡化(converter 自己保留 NEF、不 PUT FAA) | | 缺點 | (1) Phase 1 已上線的 converter → FAA promote 流程要拆掉(converter 端改造範圍大);(2) **retention 議題嚴重**:converter MinIO 7 天 expires_at = NEF 7 天後消失、但 visionA model store 設計是「model 是持久資產」、矛盾;(3) 若要延長 converter MinIO retention 必須改 converter scheduler GC 邏輯、影響其他 converter 用途;(4) visionA 「加到模型庫」流程從「pull NEF 進 visionA 自己 storage」變成「永遠依賴 converter MinIO」,違反 ADR-014 §3「加到模型庫 = 進 visionA storage 給後續 device load 用」原意 | | 排除原因 | **retention 模型不可接受**——「加到模型庫」是 user 預期能「永久持有」的動作 | ### 方案 C:converter promote 改成「直接 stream NEF 回 visionA」 | 評估 | 內容 | |------|------| | 優點 | promote 同時完成「NEF 已給 visionA」,少一次 round-trip | | 缺點 | (1) promote 是 POST + idempotent 設計、回 stream 違反 REST semantic;(2) promote 同時要做兩件事(推 FAA + 回 stream),失敗模式爆炸(推 FAA 成功但回 stream 失敗時怎麼辦?);(3) frontend wireframe 已設計「先 promote、後 user 選下載 / 加到模型庫」的兩步流程、要改 UX;(4) 「下載」按鈕本來就是 demand-trigger(user 可能 promote 完很久才下載)、把 stream 綁在 promote 不符合使用場景 | | 排除原因 | **promote 設計大改、UX 倒退** | ### 方案 D:visionA 自己簽 HMAC token、要求 FAA 加新 auth path | 評估 | 內容 | |------|------| | 優點 | visionA 不依賴 MC、token 機制比 API key 細粒度(短 TTL + 綁 object_key + 綁 method) | | 缺點 | (1) **動 FAA**:warrenchen 要在 FAA dual-auth(JWT + IDelegatedDownloadTokenValidator)外再加第三條「visionA HMAC」路徑、改公司共用 repo;(2) HMAC secret 需 visionA + FAA 同步管理(與 API key 一樣 long-lived,但多一層複雜度);(3) ADR-015 v2.0 §7 「選項 B」已記為 Phase 1+ follow-up(量大需回 302 redirect 時才考慮)、Phase 0.8b 範圍內動 FAA cost 過高 | | 排除原因 | **使用者硬約束「不動 FAA」**;保留為 Phase 1+ 選項(量大時回 302) | ### 方案 E:協調 MC team 加 「issue + validate delegated download token」endpoint | 評估 | 內容 | |------|------| | 優點 | 修復 ADR-014 §2 原設計(讓那條鏈真的可走通)、長期看是 OAuth 框架的正確補完 | | 缺點 | (1) **動 MC**:MC team 設計 → onboard scope → 註冊 token endpoint → 配 tenant → redeploy;2026-05-09 撞 MC scope 沒註冊已驗證跨人協調週期長;(2) 即使 MC 補了 endpoint、FAA 端 `MemberCenterDelegatedDownloadTokenValidator._options.DownloadTokenValidationPath` 還要對齊配置;(3) 為了 Phase 0.8b 一個 download path 動兩個公司共用 repo 不划算;(4) Phase 1+ 後若決定走 OAuth 框架可重新評估 | | 排除原因 | **使用者硬約束「不動 MC、不動 FAA」**;長期可重啟此方案(Phase 1+) | ### 方案 F:放棄 download 功能(只保留「加到模型庫」) | 評估 | 內容 | |------|------| | 排除原因 | **PRD Phase 0.8 明確含 download**(wireframe success card「下載」按鈕 + flow-conversion.md 半自動分流);不能放棄 | --- ## 後果 (Consequences) ### 正面影響 - **修復 ADR-014 §2 從 2026-05-02 起的設計缺口**:delegated download token 鏈從未存在,本 ADR 撤回對其的依賴後 visionA download 不再有 dead path - **不動 MC、不動 FAA、不動公司 MC team / warrenchen**:跨人協調成本歸零,落地速度最快 - **認證統一**:visionA 端 server-to-server secret 只剩 `VISIONA_CONVERTER_API_KEY` 一把;MC service client / tenant_id 配置全部撤回,env 表簡化 - **失敗模式收斂**:原本「visionA → MC 4xx/5xx + MC → service token + MC → delegated token + FAA → service token validate + FAA → delegated token validate」5 條 fail path 收斂為「converter 401 / 4xx / 5xx」3 條 - **可觀測性減負**:visionA 端不需追 MC token cache hit rate、MC 失敗率、delegated token issue 失敗率 - **mc_token_client.go 整檔可刪**:commit `86b7175` 已砍掉、本 ADR 確認不需復活(撤回 ADR-015 v2.0 §6 部分復活規劃),visionA backend 程式碼庫進一步簡化 - **與 frontend 對 user 完全透明**:response shape / call pattern / 錯誤文字一律不變;只是 visionA backend 內部 stream 來源演進 ### 負面影響(接受的取捨) - **converter 變 download bottleneck**:原 ADR-014 §2 設計是「browser 直連 FAA」、流量 1×;ADR-015 v2.0 改 server-side stream proxy 後變 2×(FAA → visionA → browser);本 ADR 後仍是 2× 但路徑改 `converter MinIO → visionA → browser`(流量 cost 與 v2.0 相同、但 hop 改變) - **converter MinIO retention 變 download SLA**:converter 7 天 expires_at = 7 天後 user 不能 download。對應的 frontend UX: - `expires_at` 仍由 visionA 透傳給 frontend(`conversion.md` §2.6.2 既有設計) - frontend 在 success card 顯示倒數(「6 天 21 小時後自動清除」) - 過期後按「下載」會收 410 `result_expired`(visionA backend 把 converter 410 透傳) - **converter 是 download 單點故障**:converter scheduler 掛 → user 不能 download,即使 FAA 上有 NEF(沒辦法繞 converter 直接拉 FAA,因為 visionA 沒有 delegated token 路徑) - **「加到模型庫」流程也依賴 converter**:visionA 端 server-side pull NEF 從「直接 FAA」改成「converter result endpoint」、多一 hop(converter 從 MinIO get object 後 stream 給 visionA) - **與 Phase 1+「browser 直連 converter」路徑稍有不一致**:converter Phase 2 預留的 `POST /api/v1/jobs/:id/download-tokens` endpoint 是給 browser 直連 converter 用、與本 ADR 新增的 `GET /api/v1/jobs/:id/result` 是兩條設計不同的 path。Phase 2 升級時需評估兩條 path 是否共存或合併 ### 風險 | 風險 | 緩解 | |------|------| | converter MinIO 容量規劃失準(大量 user promote 後 NEF 累積)| converter 既有 GC(7 天 expires_at)即會自動清理;Phase 1+ 可加 retention 監控 | | converter scheduler 在 Phase 0.8b 量小可承受 download bottleneck、但 Phase 1+ 量大時不行 | Phase 1+ 升級到方案 D(visionA HMAC token + FAA 加第三條 auth path + 回 302 redirect);本 ADR 不阻擋此升級路徑 | | converter 加新 endpoint 期間(jimchen 跨 repo)若部署不同步 → visionA download 404 | (1) 加 endpoint 是純增量改動、不影響既有 endpoint;(2) visionA backend 在 converter 端 endpoint 上線前部署也安全(既有 download path 會直接 404、不會破壞其他功能);(3) 部署順序:先 converter ship → 再 visionA ship | | converter MinIO 物件不在(job 過期、或 promote 沒同步寫到 MinIO)| converter 端 handler 處理(410 `result_expired`);visionA backend 透傳給 frontend | | converter 端 result endpoint 實作 bug(stream pipeline 寫錯導致記憶體爆)| 加 integration test(mock MinIO 大檔,驗 stream 不暫存)| --- ## 配套產出(給後續 Phase) ### Phase 0.8b 範圍內(本 ADR 觸發的 follow-up) #### A. converter 跨 repo(jimchen) - 新增 `apps/task-scheduler/src/routes/v1/result.js`(或加進 `jobs.js`):`GET /api/v1/jobs/:id/result` handler - 套用既有 `requireReadAuth` middleware(API key 比對) - handler 內:job status / expires_at 檢查 → MinIO stream pipeline → 設 headers - 整合測試:`apps/task-scheduler/src/routes/v1/__tests__/result.integration.test.js` - 更新 `apps/task-scheduler/openapi.yaml`(加 `GET /api/v1/jobs/{id}/result` path) - 更新 `apps/task-scheduler/README.md`(API 清單加新 endpoint) #### B. visionA backend(backend agent 下次任務) - 刪 `internal/conversion/mc_token_client.go`(commit `86b7175` 已砍、確認狀態) - 刪 `internal/conversion/mc_token_client_test.go`(確認狀態) - 改 `internal/conversion/faa_client.go`(**改名** `converter_result_client.go` 或併入 `converter_client.go`): - 移除 `DownloadWithDelegated(ctx, delegatedToken, objectKey)` - 新增 `GetResult(ctx, jobID) (io.ReadCloser, *DownloadMetadata, error)`,內部打 `GET {ConverterBaseURL}/api/v1/jobs/{jobID}/result` + `Authorization: Bearer ` - 改 `internal/conversion/flow.go`: - 移除 `tokens *MCTokenClient` 欄位(撤回 v2.0 §6 加回) - `DownloadStream` 內部:移除 `IssueDelegatedDownload` 步驟、改呼叫 `converter.GetResult` - `PromoteToModels` 內部(如需要從 FAA pull NEF):同樣改呼叫 `converter.GetResult` - 改 `internal/config/config.go`: - `ConversionConfig.TenantID` 欄位刪除(撤回 v2.0 復活) - `ConversionConfig.FAABaseURL` 欄位刪除(visionA 不再直接打 FAA) - `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 欄位刪除 - `Enabled()` 簡化:只判 `ConverterBaseURL != "" && ConverterAPIKey != ""` - 改 `cmd/api-server/main.go`:移除 wire `MCTokenClient`、移除傳 FAA 相關 config - 改 `.env.stage.example` / `.env.dev.example`: - 移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID` / `VISIONA_FAA_BASE_URL` - 保留 `VISIONA_CONVERTER_BASE_URL` / `VISIONA_CONVERTER_API_KEY` - 改對應 unit / integration test:移除 mc_token / faa_client_test 中對 service token / delegated token 的測試;補 `converter_result_client_test.go` ### Phase 0.9 / Phase 1+ follow-up - [ ] converter API key rotate runbook(範圍縮限至 converter,沿用 ADR-015 v2.0 規劃) - [ ] 觀察 converter result endpoint 在 Phase 1 量大時的效能 / 頻寬 cost - [ ] 若量大需回 302 redirect 模式 → 重新評估方案 D(visionA HMAC token + FAA 加第三條 auth path) - [ ] 若 Phase 2+ 要回 OAuth 框架 → 重新評估方案 E(協調 MC / FAA team 補完 delegated token 鏈) --- ## 合規性 - [x] 與使用者確認:本 ADR 採方案 A(converter 加 GET result endpoint)+ 撤回 visionA → FAA / MC 所有 server-to-server 路徑(2026-05-16) - [x] 與 jimchen 確認(同時為 visionA + converter 維護者):converter scheduler 加新 endpoint 由 jimchen 處理 - [x] 對 MC source 完整 grep 驗證:MC 沒有 issue / validate delegated download token 的 endpoint(致命發現 1 + 2 + 4) - [x] 對 FAA source 驗證:`MemberCenterDelegatedDownloadTokenValidator.cs` 確實 assume MC 有 validation endpoint、且 FAA download endpoint 強制只接 delegated token(致命發現 4) - [x] 對 ADR-014 / ADR-015 v2.0 對齊:本 ADR 部分 supersede 兩者(ADR-014 §2 整段 + ADR-015 v2.0 §2 整段) - [x] 對 ADR-013 對齊:user login 的 OIDC public PKCE client 不受影響 - [x] 與 converter Phase 1 對齊:promote 流程不變、converter → FAA 路徑(OAuth + `files:upload.write`)不變 - [ ] ~~與 MC team 協調補 delegated token endpoint~~(撤回,本 ADR 不動 MC) - [ ] ~~與 warrenchen 協調 FAA dual-auth 改造~~(撤回,本 ADR 不動 FAA) - [ ] DevOps:確認 converter 新 endpoint 上線時序(先 converter ship → 再 visionA ship);rotate runbook follow-up --- ## 相關文件 - 部分 supersedes: - [`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md)(§2 download 整段 + §3 加到模型庫的 FAA pull 部分 + §5 FAA service token cache 部分 + §7 retry 矩陣 FAA/MC row) - [`adr-015-server-to-server-api-key.md`](./adr-015-server-to-server-api-key.md) v2.0(§2 visionA → FAA 整段) - 不影響: - [`adr-013-public-client.md`](./adr-013-public-client.md)(user login 部分) - [`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md) §1(upload streaming proxy)、§4(模組劃分)、§6(user_id trust boundary) - [`adr-015-server-to-server-api-key.md`](./adr-015-server-to-server-api-key.md) v2.0 §1(visionA → converter API key) - 詳細實作(本 ADR 同步更新): - `conversion.md` v0.6(§1 整體 flow、§2 模組設計、§3 服務間認證、§4.1 download handler、§6 錯誤碼、§9 retry 矩陣、§10 安全考量、變更影響清單) - `api/api-conversion.md` v0.6(檔頭 Auth metadata、§3 promote 與 §4 download 錯誤碼、錯誤碼總覽、版本記錄) - `oidc-tdd.md` v0.4(Metadata 區範圍說明、§13.1 環境變數表、§13.1.1 stage env 範例、§13.1.3 server-to-server 雙線設計改寫) - Source code 證據: - MC controller 列表:`/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/*.cs`(8 個檔、無 file/download/token issuance endpoint) - FAA delegated token validator:`/Users/jimchen/file_access_agent/src/FileAccessAgent.Infrastructure/Services/MemberCenterDelegatedDownloadTokenValidator.cs` line 27-72 - FAA download endpoint:`/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs` line 184-254(無 `RequireAuthorization()`) - converter promote → FAA PUT 已實作:`/Users/jimchen/kneron_model_converter/apps/task-scheduler/src/fileAccessAgent/client.js` - converter v1 routes:`/Users/jimchen/kneron_model_converter/apps/task-scheduler/src/routes/v1/jobs.js`(既有 endpoint:POST jobs / GET jobs / GET jobs/:id / POST jobs/:id/promote;POST jobs/:id/download-tokens 預留 501;DELETE jobs/:id 預留 501) - 觸發背景:`.autoflow/progress.md` 2026-05-16「致命發現 → 拍板選 A → ADR-016」段落 --- ## 版本記錄 | 日期 | 版本 | 變更 | |------|------|------| | 2026-05-16 | 1.0 | 初版 — 撤回 visionA → FAA delegated download token 鏈(從 ADR-014 §2 起即為 broken design),改走 converter 新增 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉。視 ADR-014 §2 / ADR-015 v2.0 §2 為部分 supersede;user login(ADR-013)/ upload streaming(ADR-014 §1)/ converter API key(ADR-015 §1)全部不變。涉及 visionA + converter 兩個 repo 改造;不動 MC、不動 FAA、不動 warrenchen。 |