visionA/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.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

46 KiB
Raw Blame History

ADR-015visionA → converter 採 pre-shared API key 認證(取代 OAuth client_credentials— 範圍縮限至 visionA → converter

狀態

Accepted — 2026-05-11 / 範圍縮限 — 2026-05-16 (v2.0) / §2 visionA → FAA 整段再次撤回 — 2026-05-16 (v2.1)

v2.1 撤回摘要2026-05-16 下午)v2.0 §2「visionA → FAA 回到 ADR-014 §2MC service token + delegated download token整段再次撤回。原因:對 MC source 完整驗證後發現 MC 從未有 issue / validate delegated download token endpoint—— ADR-014 §2 從 2026-05-02 起即為 broken design、v2.0 沿用該設計也是 fictional。

v2.1 新設計visionA download 改走 ADR-016converter 新增 GET /api/v1/jobs/{id}/result + visionA stream 中轉。visionA 端不再有任何 visionA → MC / visionA → FAA 的 server-to-server 路徑。visionA → converter API key 路線§1維持完全不變

v2.0 範圍縮限摘要(保留歷史)v1.x 原本決策「visionA → converter / FAA 兩條 server-to-server 線都改 API key」。2026-05-16 使用者拍板撤回 visionA → FAA 改 API key 部分FAA 線回到 ADR-014 §2 原設計。v2.0 後 v2.1 再次撤回 — FAA 線完全交由 ADR-016 取代converter 中轉,不再有 visionA → FAA / visionA → MC 鏈)。

上位 / 同層 ADR

  • 部分 supersedesADR-014 §5「Service token cache — 仿 converter scheduler 模式」中 converter 部分visionA → converter 的 service token / scope converter:job.write/read 取消、§7「失敗模式 retry 矩陣」中 converter MC token rowconverter 線不再經 MC
    • v2.0 修正(保留歷史)v2.0 一度把 FAA 部分維持 ADR-014 §2 原設計,但 v2.1 整段撤回——FAA 部分的 supersede 改由 ADR-016 接手visionA download 改走 converter result endpoint視 ADR-014 §2 / 本 ADR v2.0 §2 為 broken design
    • v2.1 後:本 ADR 對 ADR-014 的 supersede 範圍維持「§5 中 converter 部分 / §7 中 converter MC token row」ADR-014 §2 / §5 中 FAA 部分 / §6 / §7 中 FAA + MC delegated token row 由 ADR-016 統一 supersede。
    • ADR-014 的其他段落upload streaming proxy、半自動 promote 原則、不擴 model schema、模組劃分仍有效。
  • 被 supersedev2.1 新增):本 ADR §2「visionA → FAA」整段被 ADR-016 supersede。visionA download 不再有 visionA → FAA / visionA → MC 任何鏈路。
  • 不影響ADR-013 — user login 的 OIDC public PKCE client 仍照舊。本 ADR v2.1 後只動「server-to-server visionA → converter」這條線「user login」維持不變「server-to-server visionA → FAA」這條線直接不存在(由 ADR-016 撤回)。
  • 沿用:ADR-006in-memory stateADR-010user login 的 OIDC BFF PatternADR-011(取代 StaticAuth

背景 (Context)

Phase 0.8 OAuth client_credentials 鏈路失敗事件2026-05-09 ~ 11

ADR-014 原本設計 visionA backend → converter / FAA 的 server-to-server 認證走 Member CenterMC的 OAuth client_credentials grant

  1. visionA backend 啟動時讀 VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET
  2. 第一次需要時打 MC POST /oauth/token 換 service tokenscope converter:job.write/readfiles:download.read/delegate
  3. Authorization: Bearer <service-token> 打 converter / FAA
  4. converter / FAA 端 middleware 驗 JWKS 簽章 + 驗 scope + 驗 tenant

Phase 0.8 stage 部署實際跑到才發現整條鏈路有 4 個串行 blocker

# Blocker 影響
1 MC stage 沒註冊 converter:job.read/write 兩個 scopeprogress.md 5/2 assume 都有 證實是錯的) POST /oauth/token 直接 400 invalid_scope
2 stage 上 converter image 是 5 週前舊版,沒 OAuth middleware、沒 /api/v1/jobs endpoint 即使 MC 補 scopeconverter 仍無法接受 service token
3 converter 缺 MEMBER_CENTER_* env 設定 converter 無法 init OIDC middleware
4 FAA stage 也可能要 OAuth 整合warrenchen 維護) 不確定狀態,需跨人協調

要修齊這 4 個 blocker 必須跨 3 個 repo 改 + 同步 MC team + redeploy converter + warrenchen 配合 FAA — 對「2 條 1:1 trust 關係的 server-to-server 整合」明顯過度設計。

v1.x 過度設計的訊號converter 線適用)

OAuth client_credentials + JWKS + scope 機制適合的場景:

  • 多個 client 對同一個 resource server(如 SaaS 平台對外開放 API、第三方 dev 串接),需要區分不同 client 權限、需要短 TTL token 限制 blast radius、需要 scope 細粒度
  • 不同團隊 / 不同信任邊界client 端的 secret 不能由 server 端管理

visionA → converter 的場景完全不符合上面任一個

  • 1:1 trust 關係visionA 是 converter 唯一的 server-to-server caller沒有第三方
  • 使用者同時維護 visionA + converterjimchen可單方拍板改 middleware
  • 全部 internal trust(不是給外部 dev 用,沒有 untrusted client
  • 無需 scope 細分converter 只關心「是否為 visionA」單一布林

而成本:

  • 複雜度MC 端要 onboard scope、issuer JWKS 要可達、token cache 要寫對、TTL 邊界要處理、4 個 5xx / 4xx 失敗模式要 retry & graceful degrade
  • 鏈路長度visionA → MC → cache → converter任一節點掛掉都不能轉檔
  • 可觀測性負擔:要追的 metrics 包含 token cache hit rate、MC 失敗率、scope 對齊狀態

為什麼 v2.0 撤回 FAA 改 API keyFAA 線適用相反邏輯)

v1.0 把同一套「過度設計」邏輯外推到 FAA但 FAA 線的實際情況不同:

  1. FAA 是 warrenchen 維護的公司共用 repo協調成本不對稱converter 是 jimchen 自己的、可單方改FAA 改一行都要走跨人協調)
  2. MC 端針對 FAA 的 scope 早已備妥:使用者於 2026-05-16 提供的 stage service client4242ba63099d4f318dd3f143d27ef4c5)含 files:upload.write files:metadata.read files:delete files:download.delegate 4 個 scope完整 cover FAA 4 個 endpointPUT / GET metadata / HEAD / DELETE / GET file不需要 MC team 額外 onboard
  3. FAA 已內建 dual-auth 設計/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs line 184-254 顯示 GET /files/{key} 下載 endpoint 沒掛 RequireAuthorization(),改用 IDelegatedDownloadTokenValidator.ValidateAsync(...) 驗 delegated download token其他 endpointPUT / metadata / HEAD / DELETE)才走 JWT Bearer + EnsureJwtScopeAndTenant。 → FAA 設計上 download 就是要 delegated token、不接 service token;如果硬要把 FAA 全 endpoint 改成 API key middleware等於要 warrenchen 重寫整套 dual-auth、把既有 delegated token validator 拔掉,遠超「補 middleware」的成本
  4. 5/9 撞 MC scope 沒註冊的痛主因在 converter 線converter:job.read/write 兩個 scope 不存在FAA 線 4 個 scope 已備妥的事實在當時尚未驗證
  5. converter 線 v1.0 改 API key 已能解決「最痛的」blocker(不必動 MC、不必動 FAA、不必跨人FAA 線本就 1:NFAA 之後可能服務多個 visionA-like 產品線),維持 OAuth 框架對 FAA 端架構演進更友善

→ v2.0 縮限至「只動 converter 線FAA 線回到 ADR-014 §2 原設計」是更精確的責任邊界劃分。

已洩漏的 stage service client secretv1.x 觀察事實 — v2.0 部分仍適用)

RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E= 已在對話中外洩progress.md 2026-05-11 紀錄)。

  • v1.x 的處理:改用 API key 後此 secret 直接作廢、不需 rotate
  • v2.0 的處理FAA 線改回 service token 路線後,使用者於 2026-05-16 提供的 stage service client 4242ba63099d4f318dd3f143d27ef4c5 取代舊 client舊的洩漏值仍作廢、不重用新 secret 僅放 stage host .env.stage 與部署 secret store、絕不進 git / 文件(本 ADR、TDD、env example 一律不寫真實 secret 值)

決策 (Decision)

v2.0 範圍:採 pre-shared API key + Authorization: Bearer <api-key> header 取代 OAuth client_credentials僅適用於 visionA → converter。visionA → FAA 維持 ADR-014 §2 原設計MC service token + delegated download token

1. visionA → converterv1.0 採用 / v2.0 維持)

visionA backend 啟動時
    ↓
讀 env VISIONA_CONVERTER_API_KEY
    ↓
[轉檔請求進來]
    ↓
打 converter
   Authorization: Bearer <VISIONA_CONVERTER_API_KEY>

converter 端 middleware

讀 env CONVERTER_API_KEY
    ↓
[收到請求]
    ↓
parse Authorization header → 取 token
    ↓
subtle.ConstantTimeCompare(token, CONVERTER_API_KEY)
    ↓
match → 放行mismatch → 401

2. ⚠️ visionA → FAAv2.1:整段撤回,改走 ADR-016 converter 中轉)

v2.1 撤回2026-05-16 下午)v2.0 在本節「FAA 線回到 ADR-014 §2 原設計MC service token + delegated download token整段撤回

理由(致命發現 2026-05-16

  1. MC source 沒有 POST /file-access/download-tokens endpointvisionA 無法跟 MC 換 delegated token
  2. MC source 沒有 FAA IDelegatedDownloadTokenValidator assume 的 introspection endpoint即使有 token 也無法 validate
  3. FAA GET /files/{key} 強制只接 delegated token、不接 service token

→ ADR-014 §2 與本 ADR v2.0 §2 描述的「visionA → MC → FAA delegated token 鏈」完全是 fictional(從 2026-05-02 起未曾 e2e 跑通過)。

v2.1 採用的設計visionA download 改走 ADR-016converter 新增 GET /api/v1/jobs/{id}/result endpoint + visionA stream 中轉。visionA 端不再有任何 visionA → MC server-to-server 路徑、不再有任何 visionA → FAA 直接呼叫。

本節以下內容僅作歷史保留、實作以 ADR-016 為準v2.1 後對應的 visionA 端 code 應撤回mc_token_client.go 不需復活,已於 commit 86b7175 砍除 → 維持砍除OIDCConfig.ServiceClientID/Secret + ConversionConfig.TenantID 維持 v1.x 廢棄狀態)。

v2.0 原採用的設計(即 ADR-014 §2 原設計v2.1 整段撤回,僅作歷史保留)

visionA backend 啟動時
    ↓
讀 OIDCConfig.ServiceClientID / ServiceClientSecret + ConversionConfig.TenantID
    ↓
[需要打 FAA例如「加到模型庫」server-to-server pull、或「下載」proxy]
    ↓
visionA → MC POST {issuer}/oauth/token
   grant_type=client_credentials
   client_id=<ServiceClientID>
   client_secret=<ServiceClientSecret>
   scope=files:upload.write files:metadata.read files:delete files:download.delegate
    ↓
MC 回 service access_tokencache 至 exp - 15s
    ↓
A 路線FAA 寫 / metadata / delete / s2s download
   visionA 帶 Authorization: Bearer <service-token> 打 FAA
   FAA 端 .RequireAuthorization() + EnsureJwtScopeAndTenant 驗
     ├─ JWT 簽章FAA `AddJwtBearer` Authority = MC issuer自動 JWKS
     ├─ AudienceFAA `Auth:Audience`
     ├─ scope claimPUT 要 files:upload.writeGET metadata / HEAD 要 files:metadata.readDELETE 要 files:delete
     └─ tenant_id claim 必須等於 instanceOptions.TenantId
   → 通過則放行

B 路線download stream proxy — visionA backend 中轉到 browser
   1. visionA 帶 service-token 打 MC POST /file-access/download-tokens
      scope: files:download.delegate針對特定 object_key + GET method + 5 分鐘 TTL
   2. MC 回 delegated download token (opaque)
   3. visionA 帶 Authorization: Bearer <delegated-token> 打 FAA GET /files/{key}
   4. FAA 端 GET /files/{key} 沒掛 .RequireAuthorization()
      改走 IDelegatedDownloadTokenValidator.ValidateAsync(...)
        ├─ token active
        ├─ tenant_id match
        ├─ object_key match
        └─ method == "GET"
      → 通過則 stream NEF binary
   5. visionA backend io.CopyN(...) 中轉回 browser

為什麼 v2.0 設計「download 線必須是 delegated token」而非「service token」

FAA 端的 dual-auth 設計(已實作於 /Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs)強制如此:

FAA endpoint line range auth 機制 適用 scope / token type
GET /files/metadata/{**objectKey} 80-111 .RequireAuthorization() + EnsureJwtScopeAndTenant files:metadata.read (service token)
HEAD /files/{**objectKey} 113-148 .RequireAuthorization() + EnsureJwtScopeAndTenant files:metadata.read (service token)
PUT /files/{**objectKey} 150-182 .RequireAuthorization() + EnsureJwtScopeAndTenant files:upload.write (service token)
GET /files/{**objectKey} 184-254 .RequireAuthorization();用 IDelegatedDownloadTokenValidator.ValidateAsync(...) files:download.delegate 換出來的 delegated token
DELETE /files/{**objectKey} 256-287 .RequireAuthorization() + EnsureJwtScopeAndTenant files:delete (service token)

→ FAA GET /files/{key} 不接 service token,必須用 MC 簽的 delegated download token。 → visionA download flow 必須做「換 delegated token」這一步、不能省。 → visionA「加到模型庫」server-to-server pull 流程因為走的也是 GET /files/{key} 下載端點,也要走 delegated download token 路徑v2.0 修正v1.x 與 ADR-014 §3 「scope files:download.read」描述不精確FAA 端 source 真相是 download endpoint 一律用 delegated tokenscope files:download.delegate 是 service client 用來「向 MC 換 delegated token」的能力不是 FAA 端 endpoint 接收的 scope

stage 端證據(使用者 2026-05-16 提供)

  • FAA stage URLhttps://stage-9527.innovedus.com:5081
  • TenantId732270c0-449c-489c-bfad-321e9bf89b3d
  • ServiceClientId4242ba63099d4f318dd3f143d27ef4c5(取代 v1.x 提到的舊 client 23605e14...
  • ServiceScopesfiles:upload.write files:metadata.read files:delete files:download.delegate
  • ServiceClientSecret放 stage host .env.stage,不進 git / 文件

待 verify合規性段落追蹤MC stage 端是否確實註冊上述 4 個 scope 並對該 service client 生效,需 stage redeploy 前實測 POST /oauth/token 拿到含 4 scope 的 access_token。

3. 單一下游converterv1.x「每個下游各自獨立的 key」表格 v2.0 縮限)

v1.x 的 key 表格 縮限至 converter 一條:

Key 持有者 用途
VISIONA_CONVERTER_API_KEYvisionA 端) / CONVERTER_API_KEYconverter 端) jimchen visionA → converter

v1.x 中的 FAA key rowVISIONA_FAA_API_KEY / FAA_API_KEY)撤回——FAA 改回 MC service token 路徑、不需要 pre-shared API key。

理由converter 維持):每條 trust boundary 各自獨立。converter 線 1:1 trust雙方都由 jimchen 維護rotate 對齊成本可控。

3.5 Reference Middleware Implementationv2.0 縮限至 converter 端)

本節提供 converter 端 middleware 的可直接照抄 reference snippet。

3.5.1 converter 端Go — net/http 標準 middleware pattern

net/http 標準 middleware pattern可直接套用 chi / gorilla/mux / 原生 http.ServeMuxsubtle.ConstantTimeCompare 是 Go 標準庫提供的 constant-time 比較函式(避免 timing attack

// internal/middleware/apikey.go
package middleware

import (
	"crypto/subtle"
	"errors"
	"log/slog"
	"net/http"
	"strings"
)

// ErrAPIKeyNotConfigured 啟動時 server 端 API key 未設定 — 應在 main() init 時 fail-fast、
// 不要等到第一個 request 才發現
var ErrAPIKeyNotConfigured = errors.New("CONVERTER_API_KEY env not set")

// NewAPIKeyAuth 回傳一個驗證 Authorization: Bearer <api-key> 的 middleware。
// 若 expectedKey 為空字串,會直接 panic啟動 fail-fast— 避免「未設定 = 全部放行」的災難。
func NewAPIKeyAuth(expectedKey string, logger *slog.Logger) func(http.Handler) http.Handler {
	if expectedKey == "" {
		// 啟動時偵測 — 不允許 server 在沒有 key 的狀態下啟動
		panic(ErrAPIKeyNotConfigured)
	}
	expectedBytes := []byte(expectedKey)

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			authHeader := r.Header.Get("Authorization")

			// missing Authorization header
			if authHeader == "" {
				logger.Warn("api key auth failed",
					"reason", "missing_authorization_header",
					"path", r.URL.Path,
					"remote", r.RemoteAddr)
				writeUnauthorized(w)
				return
			}

			// 不是 Bearer prefix必須有 "Bearer " 前綴 + 空格)
			const prefix = "Bearer "
			if !strings.HasPrefix(authHeader, prefix) {
				logger.Warn("api key auth failed",
					"reason", "missing_bearer_prefix",
					"path", r.URL.Path,
					"remote", r.RemoteAddr)
				writeUnauthorized(w)
				return
			}

			token := strings.TrimSpace(authHeader[len(prefix):])

			// token 為空("Bearer " 後面什麼都沒有)
			if token == "" {
				logger.Warn("api key auth failed",
					"reason", "empty_token",
					"path", r.URL.Path,
					"remote", r.RemoteAddr)
				writeUnauthorized(w)
				return
			}

			// constant-time compare — 即使長度不同 ConstantTimeCompare 也會回 0但為了
			// 提早 short-circuit、先檢查長度可避免 hash 不必要的工作(長度本身不是 secret
			if subtle.ConstantTimeCompare([]byte(token), expectedBytes) != 1 {
				logger.Warn("api key auth failed",
					"reason", "token_mismatch",
					"path", r.URL.Path,
					"remote", r.RemoteAddr)
				// 注意log 絕對不印 token 本身
				writeUnauthorized(w)
				return
			}

			// 通過 — 放行到 next handler
			next.ServeHTTP(w, r)
		})
	}
}

// writeUnauthorized 統一回 401 — 不洩漏「key 對 / 不對」/ 「missing / mismatch」的差異
func writeUnauthorized(w http.ResponseWriter) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusUnauthorized)
	_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}

main 端使用範例:

// cmd/converter/main.go節錄
expectedKey := os.Getenv("CONVERTER_API_KEY")
authMiddleware := middleware.NewAPIKeyAuth(expectedKey, logger)
// 套用到所有 /api/v1/* routes
mux.Handle("/api/v1/", authMiddleware(apiHandler))

3.5.2 FAA 端C# ASP.NET Core middlewarev2.0 撤回 — 整段刪除)

v2.0 撤回v1.1 在此處提供的 FAA 端 ASP.NET Core middleware snippet含 Classic Middleware Class 寫法 A + Minimal API Inline Middleware 寫法 B整段刪除

理由FAA 線回到 ADR-014 §2 原設計MC service token + delegated download tokenFAA 端不需要新增任何 API key middleware

  • 既有 AddJwtBearer + RequireAuthorization() + EnsureJwtScopeAndTenant 已涵蓋 PUT / metadata / HEAD / DELETE 4 個 endpoint
  • 既有 IDelegatedDownloadTokenValidator 已涵蓋 GET download endpoint
  • FAA 端零變更——這是 v2.0 撤回的核心收益(不必動公司共用 FAA repo、不必跟 warrenchen 協調)

3.5.3 部署檢查清單v2.0 縮限至 converter 端)

不分 client 端 / server 端,部署前 converter 兩側必須逐項確認:

# 檢查項 為什麼
1 env 已設定且非空(啟動 fail-fast 避免「未設定 = 全部放行」災難server 應在啟動時 panic / throw、不要等到第一個 request 才發現
2 constant-time compareGo subtle.ConstantTimeCompare 避免 timing attack 反推 key
3 401 response body 統一不洩漏「key 對 / 不對」/ 「missing / mismatch」差異 對外只回 {"error":"unauthorized"},差異只記在 server 端 log
4 log 絕對不印 token 本身 即使是失敗的 token 也不印(攻擊者可能用半正確的 token 試探);只印 reason / path / remote
5 Bearer prefix 嚴格驗證(缺 prefix 也 401 不允許 Authorization: <token> 這種格式(即使內容對也 reject、強制 client 用標準 Bearer scheme
6 每環境dev / stage / prod獨立 key 嚴格分環境產 keyopenssl rand -hex 32 各 64 字元 hex不重用
7 key 不進 git .gitignore 嚴格 ignore .env*CI / CD secret 從 Secrets Manager / Vault 注入
8 健康檢查 endpoint 是否要 bypass auth 通常 /healthz / /readyz 應 bypass讓 LB / k8s 可探測),但業務 endpoint 全部要 auth

FAA 端不需要本清單——v2.0 起 FAA 端使用既有 OAuth + delegated token 機制,無新增 API key middleware。FAA 端的 OAuth / delegated token 部署檢查請參考 ADR-014 §2 與 conversion.md §3.2。

4. 不再有 scope 概念converter 線適用FAA 線 v2.0 撤回)

converter 線OAuth client_credentials 設計中兩個 converter scopeconverter:job.write/read)取消。

  • 單一 API key 就是「visionA 有權打 converter」的完整證明
  • 不再有「同一個 client 但拿不同 scope」的細粒度區分在 1:1 trust 中本來就沒意義)
  • converter 端 middleware 也不需要驗 scope

FAA 線v1.x 取消 files:upload.write / files:metadata.read / files:delete / files:download.delegate 4 個 scope 的決策撤回。FAA 4 個 scope 全部恢復、由 MC service client 4242ba63... 持有,並由 FAA EnsureJwtScopeAndTenant 驗。

5. tenant 概念

converter 線visionA → converter 不再帶 tenant_id。converter 端的 user_id 從 multipart body user_id field 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變。converter 端不需要 tenant 概念。

FAA 線v2.0 修正 — 從 v1.x「不再有 tenant」改為「FAA 線需要 tenant」

  • FAA 端 EnsureJwtScopeAndTenant 函式會驗 service token 內的 tenant_id claim 等於 instanceOptions.TenantId(見 FAA Program.cs line 303-312
  • delegated download token 路徑也驗 validationResult.TenantId.Value != instanceOptions.TenantIdline 218-221
  • → visionA 的 service token / delegated download token 必須含正確的 tenant_id claim
  • 來源MC service client 4242ba63... 註冊時對應的 tenantstage 為 732270c0-449c-489c-bfad-321e9bf89b3d
  • VISIONA_OIDC_TENANT_ID env 重新啟用v1.x 標廢棄v2.0 撤回廢棄)

6. visionA backend 移除的程式碼v1.x 移除清單v2.0 部分復活)

項目 v1.x 處理 v2.0 處理
internal/conversion/mc_token_client.go(整個 package 整個檔案刪除(~440 行) 部分復活 — 保留 service token cache + delegated download token issue 邏輯(給 FAA 用),但不再被 converter_client 引用
internal/conversion/converter_client.go 內呼叫 MCTokenClient.ServiceToken() 改成讀 cfg.Conversion.ConverterAPIKey 直接 set header 同 v1.x(不變)—— converter 線維持 API key
internal/conversion/faa_client.go 內呼叫 MCTokenClient.ServiceToken() 改成讀 cfg.Conversion.FAAAPIKey 直接 set header 撤回——回到 v1.x 之前:呼叫 MCTokenClient.ServiceToken() 拿 service token、帶 Authorization: Bearer <service-token>download 路徑額外呼叫 MCTokenClient.IssueDelegatedDownload(...)
internal/conversion/flow.go 內呼叫 mc.IssueDelegatedDownload() 「delegated download token 路徑取消」 撤回——download stream proxy 路徑要呼叫 IssueDelegatedDownload(...) 拿 token、再呼叫 faa.DownloadWithDelegated(token, objectKey)
internal/config/config.goOIDCConfig.ServiceClientID / ServiceClientSecret 兩個欄位 廢棄(保留 struct field 為 backward compat、不再使用env VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET.env*.example 移除 重新啟用——FAA 線需要env 加回 .env.stage.example
internal/config/config.goConversionConfig.TenantID 欄位 + env VISIONA_OIDC_TENANT_ID conversion 模組不再依賴;如其他模組未使用即可移除 重新啟用——FAA 端 EnsureJwtScopeAndTenant 驗 tenant_id claim、token 必須帶

config 欄位 v2.0 樣貌:

// internal/config/config.go
type ConversionConfig struct {
    ConverterBaseURL string // 既有
    FAABaseURL       string // 既有
    ConverterAPIKey  string // v1.0 新增 — env VISIONA_CONVERTER_API_KEYv2.0 維持)
    // FAAAPIKey     string // v1.0 加的v2.0 撤回(不再需要)
    TenantID         string // v1.x 廢棄v2.0 重新啟用 — env VISIONA_OIDC_TENANT_ID
}

// OIDCConfig 維持有 ServiceClientID / ServiceClientSecret 兩欄位v2.0 重新啟用)
type OIDCConfig struct {
    // ... user login 相關欄位(不變)...
    ServiceClientID     string // v2.0 重新啟用 — env VISIONA_OIDC_SERVICE_CLIENT_ID
    ServiceClientSecret string // v2.0 重新啟用 — env VISIONA_OIDC_SERVICE_CLIENT_SECRET
}

// Enabled 改判定:
func (c ConversionConfig) Enabled() bool {
    return c.ConverterBaseURL != "" &&
           c.FAABaseURL != "" &&
           c.ConverterAPIKey != "" &&
           // FAA 線判定 OIDCConfig 有 ServiceClientID / Secret + ConversionConfig.TenantID
           // — 由 main.go 啟動時組合判斷,這裡只判 conversion 自己的欄位
           c.TenantID != ""
}

7. Delegated download token 路徑的處理v2.0 撤回 v1.x 的撤回,回到 ADR-014 §2 設計)

ADR-014 §2 原設計 download 流程:

browser → visionA /download → MC issue delegated token → 302 → browser → FAA?access_token=...

v1.x 的選項 A短期 server-side proxy with API key撤回v1.0 / v1.1 在本節原本提案「visionA backend 直接用 Authorization: Bearer <FAA_API_KEY> 拉 FAA、stream 回 browser」整段全部撤回。

v2.0 採用的設計(保留 server-side stream proxy 不退回 302、但 token 來源改回 delegated download token

browser → visionA /download → visionA backend
                                   ↓
                              ownership 檢查
                                   ↓
                              ensurePromoted拿 target_object_key
                                   ↓
                              MCTokenClient.ServiceToken() → MC service access_token
                                   ↓
                              MCTokenClient.IssueDelegatedDownload(token, object_key, "GET", 5min)
                                   → MC POST /file-access/download-tokens
                                   ↓
                              FAA GET /files/{key}
                                   Authorization: Bearer <delegated-token>
                                   ↓
                              FAA IDelegatedDownloadTokenValidator.ValidateAsync(...)
                                   ↓
                              FAA stream NEF binary
                                   ↓
                              visionA backend io.CopyN → browser

為什麼保留 server-side stream proxy不退回 ADR-014 §2 的 302 redirect

  • T4 已經在 Phase 0.8 把 download 改成 server-side stream proxyconversion.md v0.4 / api/api-conversion.md v0.4),實測 frontend <a href download> 流程已驗證
  • 退回 302 redirect 等於 frontend 行為改變、要重做 e2e 驗證、無收益
  • delegated token 在 server-side不洩漏給 frontend JS / browser URL bar反而比 302 模式更安全
  • 流量成本(每次下載繞 visionA backend N×Phase 0.8 MVP 量小可接受Phase 1 量大時再評估升級

為什麼 token 來源改回 delegated download token不繼續用 v1.x 的 visionA API key

  • FAA GET /files/{key} endpoint 強制使用 delegated tokenline 184-254 沒掛 RequireAuthorization()、改用 IDelegatedDownloadTokenValidatorFAA 端不接受其他 token type
  • v1.x 若要用 visionA API key需要 warrenchen 在 FAA 端為 download endpoint 額外實作「API key middleware」並改寫 dual-auth 路由——成本遠超 v2.0 撤回的收益

選項 BPhase 1+ HMAC token 升級路徑)保留為 follow-up

如果 Phase 1 流量壓力大要回 302 redirect 模式visionA 可以自己簽 short-TTL HMAC token不需要 MC 介入FAA 端 middleware 多加一條「驗 visionA HMAC token」的路徑

browser → visionA /download → visionA 用 HMAC_KEY 簽 short-TTL token
                                   ↓
                          302 → browser
                                   ↓
                          browser → FAA?access_token=<visionA-signed-hmac>
                                          ↓
                          FAA middlewareJWT (s2s) OR delegated (current) OR HMAC (browser direct) 三選一

此升級路徑與本 ADR v2.0 決策無衝突,記入 Phase 1 follow-up同 v1.x 規劃)。

8. user_id 注入 trust boundary 不變v1.x / v2.0 一致)

ADR-014 §6「visionA backend 是唯一灌 user_id 的點」邏輯不變:

  • user_id 仍從 OIDC cookie session 拿OIDC sub
  • 仍透過 multipart streaming 注入 converter request 的 user_id fieldconverter 端視 visionA 為 trusted caller
  • API keyconverter/ service tokenFAA證明的是「caller 是 visionA」user_id 的真實性由 visionA 內部的 OIDC 機制保證 — 兩條獨立鏈

9. 部署層的 env 注入v2.1 修訂)

Phase 0.8b v2.1 採用visionA 端 server-to-server secret 只剩 converter API key 一把):

Env Stage Production v2.1 變更
VISIONA_CONVERTER_API_KEYvisionA 端) .env.stagejimchen 持有,不進 git AWS Secrets Manager / Vault 維持v1.0 新增、v2.0 維持、v2.1 維持)
CONVERTER_API_KEYconverter 端) .envjimchen 持有,不進 git 同上 維持
VISIONA_CONVERTER_BASE_URL .env.stage Secrets Manager 維持(既有)
VISIONA_FAA_API_KEYvisionA 端) 撤回v1.0 加的v2.0 移除v2.1 維持移除)
FAA_API_KEYFAA 端) 撤回v1.0 加的v2.0 移除v2.1 維持移除)
VISIONA_OIDC_SERVICE_CLIENT_ID v2.1 再次撤回v1.x 廢棄v2.0 重新啟用v2.1 撤回 v2.0 啟用)
VISIONA_OIDC_SERVICE_CLIENT_SECRET v2.1 再次撤回(同上)
VISIONA_OIDC_TENANT_ID v2.1 再次撤回(同上)
VISIONA_FAA_BASE_URL v2.1 撤回visionA 端不再直接打 FAA、走 converter 中轉)

key 產生方式:

  • converter API key兩端對齊openssl rand -hex 3264 字元 hex
  • service client secretv2.1 不需要)

v2.1 後 visionA 端 server-to-server 鏈路收斂為單條visionA → converterAPI keydownload 也走同一條converter GET /api/v1/jobs/{id}/result,詳見 ADR-016

考慮過的替代方案 (Alternatives Considered)

方案 A維持 OAuth client_credentialsADR-014 原方案 — converter / FAA 兩條都走)

評估 內容
優點 標準化、跨團隊可重用、短 TTL token、scope 細粒度可控
缺點 需要 MC team 配合 onboard converter scope、converter / FAA 都要重寫 middleware、JWKS 取得失敗時 graceful degrade 複雜、stage e2e 鏈路 4 個 blocker 全要修齊
排除原因 對 1:1 internal trust 場景converter過度設計Phase 0.8 stage 部署實際遇到的 4 個 blocker 證明這條路 ROI 不好

方案 BmTLSmutual TLS

評估 內容
優點 不需傳遞 secret in plaintext憑證綁定、cert rotation 機制成熟
缺點 converter / FAA 都要支援 mTLS、需要 CA 管理、ingressnginx / Caddy / ALB也要配合 client cert termination、stage 環境部署成本高
排除原因 對 1:1 trust 過度設計;公司 stage 環境 ingresshost nginx未對外開放 mTLS 配置;維運成本不成比例

方案 CAPI key + IP allowlist 雙層防護

評估 內容
優點 即使 API key 洩漏,攻擊者也需從特定 IP 才能用
缺點 visionA 上 AWS 後 IP 不固定NAT gateway / ALB 對外多 IPconverter / FAA 在公司內網 IP allowlist 維護成本高;對「不是內網」的 prod 場景幾乎沒用
排除原因 Phase 0.8 stage 仍在公司內網visionA stage 在 192.168.0.x加 IP allowlist 在 stage 可行但對 prod 沒有延展性;不採用

方案 D共用一把 API key不分 converter / FAA

評估 內容
優點 env 少一個、部署設定簡單
缺點 一處洩漏兩處連坐converter rotate 必須同步 FAA違反「每條 trust boundary 各自獨立」原則
排除原因 在 v1.x 兩條都 API key 的設計中作為反方案被排除v2.0 因 FAA 撤回 API key、議題自然不存在

方案 Ev2.0 採用visionA → converter API key、visionA → FAA 仍走 MC service token + delegated download token

評估 內容
優點 (1) 不必動 FAA repowarrenchen 維護成本歸零);(2) 不必動 MC 端 scope onboarding4242ba63 service client 已備妥 4 scope(3) FAA 既有 dual-auth 設計JWT for write / metadata / delete + delegated token for download零修改(4) converter 線 v1.0 已得到的「不必動 MC、不必協調」收益對 converter 而言維持(5) FAA 線維持 OAuth 框架對 FAA 多 client 演進更友善FAA 之後可能服務多個 visionA-like 產品線)
缺點 (1) visionA 仍要保留 mc_token_client 的 service token cache + delegated download token issue 邏輯v1.x 砍掉的 ~440 行要部分復活);(2) MC 仍是 FAA 線的依賴MC 掛 → FAA 用不了,但 converter 不受影響);(3) 兩條線的認證機制不對稱converter API key、FAA OAuth心智負擔略高
排除 v1.0 方案(兩條都 API key的原因 使用者 2026-05-16 拍板:(1) 不希望動 FAA共用 repo、warrenchen 維護);(2) 不希望動 MC5/9 撞 scope 沒註冊的痛);(3) 但 5/16 提供的 service client 4242ba63... 證明 MC 端針對 FAA 的 4 個 scope 已備妥 — v1.0 拒絕走 OAuth 的「MC scope 沒備妥」前提在 FAA 線不成立
採用 v2.0 採用

後果 (Consequences)

正面影響

  • converter 線實作大幅簡化v1.0 收益保留visionA backend 對 converter 的呼叫不查 cache、不打 MC、不重簽
  • converter / FAA stage e2e blocker 收斂converter 線 0 個 blockerFAA 線靠使用者已備妥的 service client 4242ba63... 驗證後即可上線(待 verify
  • 不必動 FAA repo、不必動 warrenchenv2.0 新增收益v1.x 規劃要 warrenchen 配合改 FAA middleware 的工作完全取消
  • 不必動 MC scope onboarding(部分 v2.0 新增收益FAA 線 4 個 scope 已備妥converter 線本來就不依賴 MC
  • converter middleware 極簡v1.0 收益保留):只需「比對單一字串 + constant-time compare」
  • converter 失敗模式收斂原本「MC 5xx / MC 4xx / token cache miss / scope mismatch」四個失敗類型收斂為「API key 對 / 不對」單一布林
  • 可觀測性減負(部分)converter 線不需追 token cache hit rate / MC 失敗率FAA 線仍需追,但範圍縮小一半
  • 已洩漏的 stage service client secret RciRUyi... 直接作廢:使用者於 v2.0 提供新 client 4242ba63... 取代

負面影響(接受的取捨)

  • converter 線 API key 是 long-lived secret:不像 OAuth token 有 TTL通常 1 小時rotate 需要 visionA + converter 同步換 env 並 redeploysecret 管理責任更重
  • converter 線沒有 scope 細粒度:未來如果 visionA 內部要區分「能 init job 但不能 promote」這種需求要回頭加但 1:1 trust 場景幾乎不會有此需求)
  • converter 線沒有 audit trail誰用 token 做什麼)OAuth + JWT 的 sub claim 提供天然 auditAPI key 模式下 converter 只知道「是 visionA」需要靠 visionA 內部 log + request_id 串接才能追到 user_id既有 request_id 機制可滿足)
  • FAA 線維持 OAuth client_credentials 鏈條的部分複雜度v2.0 新增visionA 仍需 mc_token_clientservice token cache + delegated download token issue + retry policyMC 仍是 FAA 線的單點依賴FAA 線的可觀測性負擔token cache hit rate / MC 失敗率)保留
  • 兩條線認證機制不對稱v2.0 新增converter 線 API key、FAA 線 OAuth運維 / 排查時需區分兩種失敗模式converter 401 = key 不同步FAA 401 = service token 取得失敗 / scope mismatch / tenant mismatch / delegated token 過期)

風險

風險 緩解
converter API key 洩漏git log、log shipping、Slack 訊息等) (1) .gitignore 嚴格 ignore .env*(2) log 不印 token(3) stage / prod 用不同 key(4) 一旦發現洩漏 → 換 env → redeploy雙方協調 < 1 小時可完成)
converter API key Rotate 流程缺失 配套必須產出 rotate runbookjimchen 對 converter
FAA 線 service client secret 洩漏v2.0 新增) 同 v1.x 處理MC team 換 client secret → visionA 同步 env → restart運維事件需跨 MC team 協調
MC stage 端 4242ba63 service client 4 個 scope 是否真的有效 / 啟用v2.0 新增) 待 verifystage redeploy 前實測 POST /oauth/token 帶 4 scope 拿到 access_token若部分 scope 缺失需 MC team 補(合規性段落追蹤)
FAA 線 MC 不可達 → 無法 issue delegated token → 下載失敗v2.0 新增) graceful degradation對 frontend 回 502 download_token_failed / mc_token_unavailable(仍維持 ADR-014 §7 原 retry 矩陣)
開發環境 / stage / prod 用同一把 converter API key 嚴格分環境產 keydev / stage / prod 各自 openssl rand -hex 32),不重用

合規性

  • 與使用者確認v2.0 範圍縮限至 visionA → converter API keyvisionA → FAA 回到 ADR-014 §2 原設計2026-05-16
  • 與 jimchen 確認(同時為 visionA + converter 維護者converter middleware 改寫由 jimchen 處理(沿用 v1.0 步驟 4 規劃)
  • 與 warrenchen 確認FAA 端 middleware 改寫由 warrenchen 處理v2.0 撤回此項FAA repo 不需改動
  • 與 MC team 驗證 stage 端 4242ba63 service client 4 個 scope 都可用v2.0 新增step 6 stage redeploy 前必驗):
    • files:upload.write 對應 FAA PUT /files/...
    • files:metadata.read 對應 FAA GET /files/metadata/... + HEAD /files/...
    • files:delete 對應 FAA DELETE /files/...
    • files:download.delegate 用來向 MC POST /file-access/download-tokens 換 delegated download token再打 FAA GET /files/{key}
  • 與 ADR-014 對齊v2.0 修訂):本 ADR v2.0 起對 ADR-014 的 supersede 範圍縮限至「§5 中 converter 部分 service token / converter:job.write/read scope」ADR-014 §2 / §5 中 FAA 部分 / §6 / §7 中 FAA 與 MC delegated token row 全部恢復有效
  • 與 ADR-013 對齊:本 ADR 不影響 user login 的 public PKCE client
  • DevOps rotate runbook 待產出Phase 0.9 follow-up範圍縮限至 converter API key
  • 已洩漏的 stage service client secret RciRUyi... 自動作廢(不需 MC rotatev2.0 由新 client 4242ba63... 取代)

配套產出(給後續 Phase

Phase 0.8b 範圍內v2.0 修訂)

  • visionA backend 程式碼改造backend agent 任務):
    • converter_client.go 維持 v1.0 改造API key
    • faa_client.go 改回呼叫 MCTokenClient.ServiceToken() + 為 download 路徑增 DownloadWithDelegated 變體
    • mc_token_client.go 部分復活service token cache + delegated download token issue 邏輯)
    • flow.go download 路徑改回呼叫 IssueDelegatedDownload + DownloadWithDelegated
    • config.go 重新啟用 OIDCConfig.ServiceClientID/Secret + ConversionConfig.TenantID
  • converter middleware 改造jimchen 跨 repo — 維持 v1.0 規劃
  • FAA middleware 改造warrenchen 跨 repov2.0 撤回
  • .env.stage.example 更新v2.0 修訂):
    • 維持 VISIONA_CONVERTER_API_KEY 新增
    • 撤回 VISIONA_FAA_API_KEY
    • 加回 VISIONA_OIDC_SERVICE_CLIENT_ID / VISIONA_OIDC_SERVICE_CLIENT_SECRET / VISIONA_OIDC_TENANT_ID / VISIONA_FAA_BASE_URL
    • 這部分 .env.example 更新由 backend agent 下次任務(複活 mc_token_client 時)一併處理;本次架構任務範圍只動共享文件*
  • 設計文件更新conversion.md / api-conversion.md / oidc-tdd.md — 本 ADR v2.0 同步產出)

Phase 0.9 / Phase 1 follow-up

  • converter API key rotate runbook含「產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key」步驟範圍縮限至 converter
  • 是否設 converter API key 有效期(例如 1 年到期自動提醒)— 由 SRE 流程決定
  • FAA 上「visionA 自己簽 HMAC token」delegated 機制§7 選項 B用於 download 路徑回 302 redirect同 v1.x 規劃)
  • 觀察 server-side download proxy 在 Phase 1 量大時的效能 / 頻寬 cost同 v1.x 規劃)

相關文件

  • 部分 supersedesadr-014-conversion-integration.mdv2.0 範圍縮限:僅 §5 中 converter 部分 service token / scope§2 / §5 中 FAA 部分 / §6 / §7 中 FAA + MC delegated token row 全部恢復有效)
  • 不影響:adr-013-public-client.mduser login 部分)
  • 詳細實作(本 ADR v2.0 同步更新):conversion.md v0.5、api/api-conversion.md v0.5、oidc-tdd.md v0.3
  • 觸發背景:progress.md 「Phase 0.8b 啟動原因2026-05-11」+ 「Phase 0.8b 步驟 2 — Backend agent 任務範圍盤點」+ 「v2.0 範圍縮限2026-05-16」段落
  • FAA dual-auth 設計參考:/Users/jimchen/file_access_agent/src/FileAccessAgent.Api/Program.cs line 41-57JWT Bearer 設定、line 184-254download endpoint 不掛 RequireAuthorization、用 IDelegatedDownloadTokenValidator、line 291-322EnsureJwtScopeAndTenant + HasScope

版本記錄

日期 版本 變更
2026-05-11 1.0 初版 — Phase 0.8b 將 server-to-server 認證從 OAuth client_credentials 改 pre-shared API keyvisionA → converter / FAA 兩條線都改)
2026-05-15 1.1 補 §3.5 Reference Middleware Implementation — Go (converter) + C# (FAA) snippet + 部署檢查清單,給跨 repo 改 middleware 時照抄
2026-05-16 2.0 範圍縮限 — 撤回 visionA → FAA API key 部分FAA 不動、改回 ADR-014 §2 MC service token + delegated download tokenvisionA → converter API key 路線保留。撤回原因:(1) 使用者明示不希望動 FAA / MC、(2) 5/9 撞 MC scope 沒註冊的痛尚在、但 (3) 使用者今天提供 FAA stage service client (4242ba63099d4f318dd3f143d27ef4c5) 證明 MC 端 4 個 scope 都已備好(含 files:download.delegate、舊路線可走通。涉及修訂§2 整段標撤回 + 加 v2.0 設計FAA dual-auth + delegated token download、§3.5.2 C# FAA snippet 整段刪除、§5 tenant 概念分 converter/FAA 兩段、§6 mc_token_client 改部分復活 + OIDCConfig.ServiceClientID/Secret + ConversionConfig.TenantID 重新啟用、§7 download 路徑改回 delegated token保留 server-side proxy 不退回 302、§9 env 表加回 service client + tenant id + 撤回 FAA API key、新增方案 E 排除 v1.0 兩條都 API key 路線、後果重估、合規性「跟 warrenchen 確認」改為「跟 MC team 驗 4 scope」。對 commit 86b7175 影響visionA backend faa_client.go 要從 API key 改回 service token + delegated token待下次 backend agent 處理mc_token_client.go 要部分復活(保留 ServiceToken cache + IssueDelegatedDownload 邏輯config.go 加回 ServiceClientID/Secret/TenantID 三欄位;.env.stage.example 加回對應 env、撤回 VISIONA_FAA_API_KEY。本次純文件修訂、source code 改造留給 backend agent 下次任務。
2026-05-16 2.1 §2 visionA → FAA 整段再次撤回 — 對 MC source 完整驗證後發現:(1) MC source 沒有 POST /file-access/download-tokens endpointvisionA 無法跟 MC 換 delegated token、(2) MC source 沒有 FAA IDelegatedDownloadTokenValidator assume 的 introspection endpoint即使有 token 也無法 validate、(3) FAA GET /files/{key} 強制只接 delegated token、不接 service token。→ ADR-014 §2 與本 ADR v2.0 §2 描述的「visionA → MC → FAA delegated token 鏈」完全是 fictional(從 2026-05-02 寫定起即為 broken design、未曾 e2e 跑通過)。v2.1 採用 ADR-016visionA download 改走 converter 新增的 GET /api/v1/jobs/{id}/result + visionA stream 中轉)。涉及修訂:狀態行加 v2.1 撤回說明、上位 ADR 區註明 §2 被 ADR-016 supersede、§2 整段標 v2.1 撤回v2.0 內容保留歷史、§9 env 表撤回 v2.0 加回的 VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET / VISIONA_OIDC_TENANT_ID / VISIONA_FAA_BASE_URL、改寫「v2.1 visionA 端 server-to-server 鏈路收斂為單條」。對 commit 86b7175 影響visionA backend mc_token_client.go 維持砍除狀態(撤回 v2.0 「部分復活」規劃、faa_client.go 改名為 converter_result_client.go或併入 converter_client.go、config.go 不需加回 ServiceClient* / TenantID / FAABaseURL、.env.stage.example 維持只有 converter env。本次純文件修訂、source code 改造留給 backend agent 下次任務範圍含「converter 跨 repo 新增 GET result endpoint」§1 visionA → converter API keyv1.0 / v2.0 / v2.1 都維持不變)