visionA/docs/autoflow/04-architecture/adr/adr-016-download-via-converter.md
jim800121chen dab13ed984 docs(autoflow): ADR-016 — visionA download 改走 converter GetResult,撤回 FAA delegated token 鏈
致命發現(grep MC + FAA source 確認):
- MC source 沒有 issue delegated download token endpoint
- MC source 沒有 validate delegated download token endpoint
- FAA MemberCenterDelegatedDownloadTokenValidator.cs 假設的 MC introspection endpoint 不存在
- ADR-014 §2 從 5/2 寫完到現在這條鏈一直是斷的、只是因為從未實際 e2e 跑通過所以沒被發現

使用者拍板硬約束:不動 MC + 不動 FAA

新增 ADR-016:
- visionA download 改用 converter GET /api/v1/jobs/{id}/result(新 endpoint)
- visionA backend 用既有 ConverterAPIKey 認證(不需新增 secret)
- 維持 T4 已實作的 stream proxy 結構(io.CopyN + Content-Disposition + size cap)
- promote 仍 PUT FAA(converter 內部用自己的 OAuth、與 visionA 無關)
- 不需動 MC + FAA + warrenchen
- 6 個替代方案逐一說明排除理由

修訂既有文件:
- ADR-014 v1.1 → v1.2:§2 download flow 標註被 ADR-016 部分 supersede
- ADR-015 v2.0 → v2.1:§2 visionA → FAA delegated token 設計(v2.0 從 v1.x 撤回的設計)再次撤回;§9 env 表撤回 v2.0 加回的 OIDC ServiceClient* / TenantID / FAABaseURL;visionA 端 server-to-server 只剩 ConverterAPIKey 一把
- conversion.md v0.5 → v0.6:§1 sequence diagram 重畫(移除 MC node)、§2 模組設計(mc_token_client.go 整檔刪除確認、faa_client.go 改名 converter_result_client.go)、§3.2 visionA → FAA 整段標撤回、§4.1 download handler 改 converter.GetResult、§6 錯誤碼撤回 mc/faa 三個 code 加 result_not_found / result_expired
- api-conversion.md v0.5 → v0.6:檔頭 Auth 段落改寫、§4 download endpoint 改述、error code 表撤回 mc_token_unavailable / download_token_failed
- oidc-tdd.md v0.3 → v0.4:§13.1 環境變數表 OIDC ServiceClient* / TenantID / FAABaseURL 從「重新啟用」改回「再次廢棄」、§13.1.1 stage env 範例移除 service client / tenant_id / FAA URL、§13.1.3 改寫為「v0.4 單線設計」說明

整體影響:
- 不需復活 mc_token_client.go(commit 86b7175 砍除狀態維持)
- 不需復活 OIDCConfig.ServiceClientID/Secret/TenantID(commit 86b7175 移除狀態維持)
- visionA backend faa_client.go 要改名為 converter_result_client.go、改呼叫 converter.GetResult
- visionA backend flow.go DownloadStream / PromoteToModels 改用 converter.GetResult
- jimchen 跨 repo 任務:converter scheduler 加 GET /api/v1/jobs/{id}/result endpoint

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:30:46 +08:00

483 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-016visionA 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 / `<a href download>` frontend pattern」的決策**沿用、由本 ADR 接手**。
- [ADR-014](./adr-014-conversion-integration.md) §3「半自動 — 加到模型庫」中「visionA backend 跟 FAA pull NEFscope `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」三 rowvisionA 端不再觸發converter 端有自己的 retry 矩陣,不在本 ADR 範圍)。
- [ADR-015 v2.0](./adr-015-server-to-server-api-key.md) §2「visionA → FAAMC 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) §1upload 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 → converterinit / poll / promote / **result download**API keyADR-015 §1
- converter → FAApromote 內部 PUT NEFconverter 自己用 OAuth client_credentials + scope `files:upload.write`converter 既有實作,與 visionA 無關)
- visionA ↔ FAA**Phase 0.8b 範圍內不存在這條鏈**(本 ADR 撤回)
- visionA ↔ MCserver-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 CenterMC與 File Access AgentFAA的 source code 做全面驗證,發現 **ADR-014 §2 設計的 delegated download token 鏈路從 2026-05-02 寫文件至今一直是斷的、只是因為 visionA 從未實際 e2e 跑通 download 而沒人發現**
#### 致命發現 1MC 沒有「issue delegated download token」endpoint
`/Users/jimchen/member_center/src/MemberCenter.Api/Controllers/` 完整 grep8 個 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.csOpenIddict 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 endpointstenants / 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」流程從未存在。
#### 致命發現 2MC 沒有「validate delegated download token」endpoint
FAA 端 `/Users/jimchen/file_access_agent/src/FileAccessAgent.Infrastructure/Services/MemberCenterDelegatedDownloadTokenValidator.cs` 線 27-72
```csharp
public async Task<DelegatedDownloadTokenValidationResult> 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。
#### 致命發現 3ADR-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 tokenscope `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-1532026-05-16 上午寫)也沿用這個 assumption雖然 v2.0 描述的設計「正確的」是 ADR-014 §2 原意圖,但 ADR-014 §2 原意圖本身就建立在錯誤的 MC 能力假設上)。
#### 致命發現 4FAA 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 blockerconverter `: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 `<a href>` 觸發 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 自己維護的 repovisionA + converter內完成、不觸碰 MC / FAA。
### 既有可用基礎建設
| 元件 | 狀態 | 可用性 |
|------|------|--------|
| converter promote → FAA PUT NEFOAuth + `files:upload.write` | Phase 1 已上線 | ✅ work已在 prod 跑 / `apps/task-scheduler/src/fileAccessAgent/client.js`|
| converter MinIO 存 NEF | converter scheduler 既有 | ✅ promote 後 NEF 同時保留在 converter MinIO + FAAconverter expires_at = 7 天)|
| visionA → converter API keyADR-015 §1| commit `86b7175` 已實作 | ✅ workinit / 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 中轉** 解決 downloadpromote 路徑不變。
### 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 <CONVERTER_API_KEY>
Accept: application/octet-stream
```
| 元素 | 規格 |
|------|------|
| Method | `GET` |
| Path | `/api/v1/jobs/:id/result` |
| Path param `id` | converter job idUUID v4 |
| Auth | `Authorization: Bearer <CONVERTER_API_KEY>`(同 ADR-015 §1 既有 middlewareconverter middleware `subtle.ConstantTimeCompare` 比對 env |
| Query params | 無 |
| Request body | 無 |
#### 1.2 Response — 成功200
```
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: <NEF binary 真實大小>
Content-Disposition: attachment; filename="<source_filename_stem>_<target_chip_lower>.nef"
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
<NEF binary stream>
```
| Header | 來源 / 規格 |
|--------|-----------|
| `Content-Type` | 固定 `application/octet-stream` |
| `Content-Length` | converter MinIO 物件實際大小(必填,給 visionA backend 設 Content-Length 透傳到 browser|
| `Content-Disposition` | converter 自行構造 filename`<source_filename_stem>_<target_chip_lower>.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 streamconverter 從 MinIO get object 後直接 stream 出來) |
#### 1.3 Response — 錯誤
| HTTP | code | 條件 | converter 回 bodyJSON |
|------|------|------|--------------------------|
| 401 | `unauthorized` | API key 不對 / 缺 / 格式錯 | `{"error":"unauthorized"}`(同 ADR-015 §3.5.1 既有 middleware|
| 404 | `job_not_found` | job_id 不存在 / 已被 GCconverter 7 天 expires_at| `{"error":"job_not_found"}` |
| 409 | `job_not_completed` | job 尚未 completedstatus ≠ completed| `{"error":"job_not_completed","status":"<current>"}` |
| 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 capconverter 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` 同一 scopeAPI key 比對由 middleware 統一)
- handler 流程:
1. 從 jobService 查 jobstatus 必須是 `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 <VISIONA_CONVERTER_API_KEY>
(converter middleware: ConstantTimeCompare)
200 NEF stream + Content-Length + Content-Disposition
visionA backend handler
- Content-Type: application/octet-stream
- Content-Length: <size>
- 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 <VISIONA_CONVERTER_API_KEY>
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.5ADR-015 v2.0| **Phase 0.8b v0.6(本 ADR** |
|------------|------|------------------------------|-----|
| `VISIONA_CONVERTER_API_KEY` | visionA → converterinit / 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 keyjimchen 自己管 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 bottleneckvisionA 跨 internet 從 converter stream、再中轉給 user雙倍流量(2) converter MinIO 的 retention 變成 download 可用性 SLAconverter 7 天 expires_at = 7 天後 user 不能 download(3) converter API 變 download 的單點故障converter down → user 不能 download即使 FAA 上有 NEF(4) 跨 repo 任務converter scheduler 加 endpoint— 但因 jimchen 維護兩端、cost 可控 |
| 排除原因 | **未排除 — 採用** |
### 方案 Bmetadata-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 預期能「永久持有」的動作 |
### 方案 Cconverter 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-triggeruser 可能 promote 完很久才下載)、把 stream 綁在 promote 不符合使用場景 |
| 排除原因 | **promote 設計大改、UX 倒退** |
### 方案 DvisionA 自己簽 HMAC token、要求 FAA 加新 auth path
| 評估 | 內容 |
|------|------|
| 優點 | visionA 不依賴 MC、token 機制比 API key 細粒度(短 TTL + 綁 object_key + 綁 method |
| 缺點 | (1) **動 FAA**warrenchen 要在 FAA dual-authJWT + 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 → redeploy2026-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」、多一 hopconverter 從 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 既有 GC7 天 expires_at即會自動清理Phase 1+ 可加 retention 監控 |
| converter scheduler 在 Phase 0.8b 量小可承受 download bottleneck、但 Phase 1+ 量大時不行 | Phase 1+ 升級到方案 DvisionA 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 實作 bugstream pipeline 寫錯導致記憶體爆)| 加 integration testmock MinIO 大檔,驗 stream 不暫存)|
---
## 配套產出(給後續 Phase
### Phase 0.8b 範圍內(本 ADR 觸發的 follow-up
#### A. converter 跨 repojimchen
- 新增 `apps/task-scheduler/src/routes/v1/result.js`(或加進 `jobs.js``GET /api/v1/jobs/:id/result` handler
- 套用既有 `requireReadAuth` middlewareAPI 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 backendbackend 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 <ConverterAPIKey>`
-`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 模式 → 重新評估方案 DvisionA HMAC token + FAA 加第三條 auth path
- [ ] 若 Phase 2+ 要回 OAuth 框架 → 重新評估方案 E協調 MC / FAA team 補完 delegated token 鏈)
---
## 合規性
- [x] 與使用者確認:本 ADR 採方案 Aconverter 加 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 shiprotate 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) §1upload streaming proxy、§4模組劃分、§6user_id trust boundary
- [`adr-015-server-to-server-api-key.md`](./adr-015-server-to-server-api-key.md) v2.0 §1visionA → 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.4Metadata 區範圍說明、§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`(既有 endpointPOST jobs / GET jobs / GET jobs/:id / POST jobs/:id/promotePOST jobs/:id/download-tokens 預留 501DELETE 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 為部分 supersedeuser loginADR-013/ upload streamingADR-014 §1/ converter API keyADR-015 §1全部不變。涉及 visionA + converter 兩個 repo 改造;不動 MC、不動 FAA、不動 warrenchen。 |