# Credential Store 遷移 SDD — KV → D1（目錄）+ CF Workers per-script Secrets（密文）

> 建立：2026-06-29 by arcrun CC｜對應 issue：Arcrun#13（優先序 3）｜決策：leo 2026-06-29 拍板（D19）
> **修訂 2026-07-03（T1.5 spike，證據 Arcrun#2）**：密文的家由「CF Secrets Store 產品」改為「**CF Workers per-script Secrets**」（`wrangler secret put` / API `PUT /accounts/{id}/workers/scripts/{script}/secrets` 那套）。原因：T1 spike 實證 Secrets Store 的 Worker binding 部署時靜態宣告、`.get(ref)` 參數被忽略，不支援「runtime 依 D1 ref 動態查任意 secret」；T1.5 spike 四驗證全過（API 動態加免重部署／`env[ref]` 動態索引可行／14 把未觸上限／重部署後存活），方向定案。§2.3、§2.5、T3、T4 已依此改寫；**雙讀過渡、回填、回滾、治理（D1 只存 metadata+secret_ref 不存密文）設計不動**。D21（2026-07-02 leo 拍板）：當作 SaaS 不存在，按單用戶自架設計，多租戶段落標「future SaaS 再議」。
> 範圍宣告：本檔是既有 SDD `credential-primitives-wasm/` 的補充設計（rule 02 §4.3 例外：現有 SDD 目錄內新增單檔）。**不施工，先 SDD，總管審對齊後放行。**
> 取代關係：本檔的儲存決策**取代** `credential-store-redesign.md` §4.5 的「維持 KV」推薦——leo 看完該推薦後**拍板走 D1+Secrets Store**（D19；密文家其後依 T1.5 spike 修訂為 Workers per-script Secrets，見上方修訂註，D19 原則不變），故 Phase B 翻回「搬」。redesign.md 的 A（telegram 一致性，已做）/ C（友善前門）/ Q1（acr parts）不受影響。
> 詞彙：component=TinyGo WASM｜recipe=http_request+固定設定｜workflow=多步 YAML｜auth-recipe=credential 怎麼注入。

---

## 0. leo 的最終決策（D19，一句話）

**「擁有目錄，不擁有內容物」**：arcrun 持有 credential 的**目錄**（看得到存了哪些、最後何時用），但**不持有、也讀不回密文明文**——連 owner 自己的界面都讀不到值。

動機（leo 親述）：用戶 API key 外泄被盜刷的經濟損失 + 商譽風險（「就算不是我們過失也會怪在我們身上」）。**我們不持有明文、不持有金鑰 = 不背這個鍋。**

```
密文值（token / SA JSON / private key）  →  CF Workers per-script Secrets（CF 託管，API 唯寫，arcrun 拿不到明文）
credential 目錄（name/service/metadata） →  使用者自己的 D1（不含密文，只含指向 Workers Secrets 的 secret_ref＝env var 名）
舊自管 ENCRYPTION_KEY + 密文存 KV         →  廢掉（回填完成後）；KV 降為暫存/熱讀快取，非真相源
界面 acr creds list                      →  讀 D1（顯示清單 + last_used），看得到但讀不回值
```

### n8n credentials UX（D19 定案）
- 界面顯示**很多 credentials**（名字 / 服務 / metadata / last_used）。
- **不能 read 既有值、不能 edit 既有值**（連 owner 都不行）。只能：
  - **整筆 replace**（重貼新值覆蓋舊的）
  - **delete**
- read 不回值「不是限制、是設計」——值在 Workers Secrets（API 唯寫，T1.5 spike 實證讀不回），arcrun 拿不到。

---

## 1. 現狀（已核實 code，2026-06-29）

| 項目 | 現狀 | 檔案 |
|---|---|---|
| 密文儲存 | `CREDENTIALS_KV`，key=`{api_key}:cred:{name}`，值=`{encrypted, iv}`（AES-GCM） | `cypher-executor/src/routes/credentials.ts:48` |
| 加密金鑰 | 自管全域 `ENCRYPTION_KEY`（client 端加密、WASM `crypto_decrypt` 解密） | rule 01 加解密；`wasi-shim.ts` |
| 寫 | `POST /credentials` { name, encrypted, iv } | `credentials.ts:24-53` |
| 讀+解密 | `auth_static_key` WASM `kv_get` → `crypto_decrypt` | `registry/components/auth_static_key/main.go:146` |
| 列表 | `GET /credentials` → `KV.list({prefix})` 列名（不含值） | `credentials.ts:68-78` |
| D1 | 全 repo 無 credential D1（D1 只有 KBDB graph） | `kbdb/wrangler.toml:10` |

**風險點（leo 要廢的）**：自管單一 `ENCRYPTION_KEY` → 漏一把，全部 credential 裸。

---

## 2. 目標架構（D19）

### 2.1 三方職責
| 元件 | 存什麼 | 誰拿得到明文 |
|---|---|---|
| **CF Workers per-script Secrets**（掛在 cypher worker 上的 secrets） | 密文值本體（token/SA JSON/private key） | **只有 CF runtime 在執行期以 env var 注入時**。管理 API 唯寫讀不回值（T1.5 spike 實證），arcrun TS/WASM/owner 界面**都拿不回** |
| **使用者的 D1**（與 KBDB 共用那顆 `arcrun-kbdb`） | credential **目錄**：`api_key / name / service / sensitivity / created_at / last_used_at / secret_ref`，**無密文** | 全員可讀目錄（非機密），**無人從這拿到值** |
| **CREDENTIALS_KV** | 過渡期：舊自管密文；遷移後：降為可選熱讀快取或廢用 | （遷移後不再是真相源） |

### 2.2 D1 schema（明確不含密文欄）
```sql
-- migrations/0002_credentials.sql（kbdb 同一顆 D1，IF NOT EXISTS 冪等）
CREATE TABLE IF NOT EXISTS credentials (
  api_key      TEXT NOT NULL,           -- 租戶
  name         TEXT NOT NULL,           -- credential 名（= auth-recipe required_secrets[].key，如 telegram_bot_token）
  service      TEXT,                    -- 對應 service（telegram / notion …），可空
  sensitivity  TEXT NOT NULL DEFAULT 'standard',  -- 'standard' | 'high'（high = SA JSON/private key）
  secret_ref   TEXT NOT NULL,           -- 指向 Workers Secrets 的 env var 名（如 CRED_TELEGRAM_BOT_TOKEN；不是密文本體）
  created_at   INTEGER NOT NULL,
  last_used_at INTEGER,                 -- 執行注入時更新（治理面 last_used 顯示用）
  PRIMARY KEY (api_key, name)
);
CREATE INDEX IF NOT EXISTS idx_cred_apikey ON credentials(api_key);
```
> ⚠️ **「name 是名字標籤不是密文」**（leo 防誤解）：`name`/`secret_ref` 都是字串標籤，**密文值永不進 D1**。

### 2.3 Workers per-script Secrets 整合（密文存 cypher worker 的 secrets、D1 存 ref）

> 依據：T1.5 spike 2026-07-03，證據 Arcrun#2（curl/wrangler 原始輸出）。本節取代原「CF Secrets Store 產品」設計——T1 spike 實證該產品的 binding 部署時靜態宣告、`.get(ref)` 參數被忽略、REST 唯寫，不支援「runtime 依 D1 ref 動態查任意 secret」，整案曾停等重新設計；總管裁決轉向 Workers per-script Secrets，T1.5 四驗證全過後定案。

**已實證的 Workers per-script Secrets 形態（T1.5 spike，Arcrun#2）：**
- **寫入（動態、免重部署）**：CF API `PUT /accounts/{account_id}/workers/scripts/{script_name}/secrets`，body `{"name":"<secret_ref>","text":"<值>","type":"secret_text"}`（等價 CLI：`wrangler secret put <NAME>`，不需本地源碼）。**不改 wrangler.toml、不重上傳 code**；spike ① 實證寫入後下一次 invocation 立即可讀（連續 5 次呼叫一致，無延遲、無需等 CF roll 版本）。
- **讀取（runtime 動態 by ref）**：secret 以 env var 形式進 worker，`env` 是普通 JS 物件 → **`env[secret_ref]` 用字串動態索引**（spike ② 實證，`?which=SECRET_B` 正確取回對應值）。這正是 Secrets Store binding 做不到的事（T1 spike ④-b：`.get(ref)` 參數被忽略）。
- **唯寫**：管理 API 只能 create/update/delete/list 名字，**讀不回值**（與 Secrets Store 同性質）→ D19「不擁有內容物」保持。
- **`secret_ref` 命名規則照舊**：`secret_ref`＝env var 名（如 `CRED_TELEGRAM_BOT_TOKEN`），D1 目錄存 ref 不存值（§2.2）。命名需避開 cypher worker 既有 vars/bindings 名（施工時以 `CRED_` 前綴隔離命名空間）。
- **數量上限（D21 降級：記錄即可，不做阻擋設計）**：spike ③ 實測 14 把全部 HTTP 201、讀取正常，未觸上限；官方書面上限數字未取得（文件頁被 proxy 擋）。self-hosted 單用戶場景不視為阻擋項；多租戶天花板＝future SaaS 再議，不為它增加複雜度。
- **重部署存活**：spike ④ 實證 `wrangler deploy` 重上傳 code 後 14 把 secret 全數存活、值不變——secrets 是掛在 script 外部的獨立資源，不走 wrangler.toml，理論上不受 `stripOfficialOnlyBindings` 注入邏輯影響（不重演 `[ai]` binding 被清的坑）。⚠️ 誠實註記：spike 測的是 `wrangler deploy` 非實際 `acr update` CLI（底層同一支 `PUT /workers/scripts/{name}` API，推論成立但非直接實測）——正式導入時於非關鍵 worker 跑一次 `acr update` 全流程驗證（併入 T3）。

**寫入路徑的 token 需求**：cypher worker 需持有一把「能打 Workers Scripts secrets API 的 CF API token」＋ account id（寫入/刪除用；本身高敏感，走 `wrangler secret put` 注入 cypher worker，非進 git）。這仍比「自管全域 ENCRYPTION_KEY」好——CF 託管值、token 可輪替、且該 token 讀不回任何 secret 值（API 唯寫）。

**self-hosted（leo21c）**：per-script secrets 是 Workers 原生能力（就是現行 `ENCRYPTION_KEY` 住的機制），任何 CF 帳號皆支援、**無 store 資源需 ensure**（對比原 Secrets Store 設計省去「acr init/update ensure store + 注入 store_id」整步）。`acr init/update` 只需確保上述寫入 token/account id 設定就緒（見 T3）。
> 註：這與「Anthropic Routine env-var 型 self-hosted 不支援」是**不同層**（那是 Anthropic 雲端；這是 CF 帳號能力）——別混（leo 提醒）。

### 2.4 寫入流程（client 加密 → Workers Secrets）
> ⚠️ 鐵律對齊：現行是 **client 端加密 + 自管 ENCRYPTION_KEY**。走 Workers Secrets 後，**密文交給 CF 託管**，arcrun 不再自管金鑰。client 端是否仍加密一層 = 待決（見 §6 開放問題 Q-b）：
> - 選項甲：client 不再加密，明文值經 TLS 送到 cypher → cypher 用「能打 secrets API 的 token」`PUT` 進 Workers Secrets（CF 端託管）。**arcrun TS 短暫經手明文**（記憶體中，不落地）。
> - 選項乙：保留 client 加密當「傳輸期保護」，但這需 arcrun 持有解密金鑰才能寫進 secrets 明文槽 → 又回到自管金鑰，違 D19。
> → 傾向**選項甲**（符合「不持有金鑰」），但「TS 短暫經手明文」需 leo 接受（mindset §7 誠實標：不是零接觸，是不落地、不持久、不持金鑰）。

### 2.5 讀取/注入流程（執行期 by ref）

> 依據：T1.5 spike 2026-07-03（Arcrun#2）——`env[ref]` 動態字串索引實證可行（spike ②），取值不再需要任何對外 REST 呼叫（值已由 CF runtime 以 env var 注入 worker）。

1. cypher 執行 workflow，遇 recipe `auth_service='telegram'`。
2. 查 D1 `credentials` where api_key+name=`telegram_bot_token` → 取 `secret_ref` + `sensitivity`。
3. 用 `secret_ref` 在執行期動態取值：**`env[secret_ref]`**（Workers Secrets 以 env var 注入，字串索引原生可行；零網路呼叫、零延遲）。
4. 依 `auth_recipe:telegram` 的 `inject.path/header` 注入（與現行 auth-dispatcher 同型，只是值來源從「KV 密文+WASM 解密」換成「`env[ref]` 取值」）。
5. 更新 D1 `last_used_at`。
> ⚠️ rule 02 §2.2/§2.3 對齊：解密/注入仍不在 cypher TS 實作業務邏輯。`env[ref]` 取值是「host 能力」（類比 `kv_get`/`crypto_decrypt`），落在 wasi-shim host function（新增 `secret_get(ref)`，host 端實作＝`env[ref]`，spike ② 已證此場景成立），**WASM 零件仍是注入邏輯的所在**。施工時須守此界線（見 §5）。

---

## 3. 治理 / UX 端點（n8n 模式）

| 端點 | 行為 | 對齊 D19 |
|---|---|---|
| `GET /credentials` | 讀 **D1**，回 `[{name, service, sensitivity, created_at, last_used_at}]`（**不含 secret_ref 對外、不含值**） | 顯示清單 + last_used |
| `PUT /credentials/:name`（replace） | 整筆覆寫：寫新值進 Workers Secrets（API `PUT` 同 ref 覆蓋或新 ref）+ 更新 D1 metadata | 只能 replace |
| `DELETE /credentials/:name` | 刪 D1 row + 刪 Workers Secrets secret（API `DELETE .../secrets/{ref}`） | 可 delete |
| ~~`GET /credentials/:name/value`~~ | **不存在 / 移除任何讀回值的路徑** | 讀不回值 = 設計 |

CLI 薄殼（rule 07）：`acr creds list`（讀 D1 顯示）、`acr creds replace <name>`（覆寫）、`acr creds delete <name>`。**移除任何「印出 credential 值」的路徑**。

---

## 4. 遷移：雙讀過渡 + 回填 + 回滾

### 4.1 雙讀過渡（不停機）
執行期取 credential 值的順序：
1. 先查 D1 有無 `secret_ref` → 有 → 走 Workers Secrets 取值（`env[ref]`，新家）。
2. 找不到 → fallback 舊路徑：`CREDENTIALS_KV {api_key}:cred:{name}` + WASM `crypto_decrypt`（舊自管）。
→ 新寫入一律走新家；舊資料未回填前仍可讀。

### 4.2 回填（一次性、冪等、可審）
`POST /credentials/migrate-to-workers-secrets`（類比現有 `migrate-cron-index` 冪等端點；端點名隨 2026-07-03 修訂由 `migrate-to-secrets-store` 更名，設計不變）：
- 對每個 `{api_key}:cred:{name}` KV row：WASM 解密取明文 → `PUT` 進 Workers Secrets（領 `secret_ref`＝env var 名）→ 在 D1 建 row（含 ref + metadata）。
- 冪等：D1 已有該 (api_key,name) row 且 ref 可解析 → 跳過。
- **誠實回報**逐筆 ok/fail（不假綠，mindset §7）。
- **回填完成且驗證通過後**，才執行 §4.4 廢 ENCRYPTION_KEY。

### 4.3 回滾錨點
- 回填**不刪 KV 舊密文**（保留為回滾錨點），只新增 D1+Workers Secrets。
- 出問題 → 雙讀 fallback 自動回到 KV 路徑；D1 row 可刪、Workers Secrets secret 可刪 → 回到純 KV 狀態。
- 只有在「全租戶回填驗證綠 + 觀察期無 fallback 命中」後，才進 §4.4。

### 4.4 廢自管 ENCRYPTION_KEY（最後一步）
- 移除 `crypto_decrypt` 對 credential 的依賴路徑（KV 密文路徑停用）。
- `wrangler secret delete ENCRYPTION_KEY`（cypher / auth_static_key / auth_service_account）。
- 清理 KV 舊密文（確認 Workers Secrets 是唯一真相源後）。
- ⚠️ **此步不可逆**，須 leo 明示放行 + 回填觀察期通過。

---

## 5. 與鐵律 / 既有架構對齊（施工硬約束）
- **rule 02 §2.2/§2.3**：cypher TS 不實作 credential 解密/注入業務邏輯。Workers Secrets 取值走 host function 邊界（新增 `secret_get(ref)` 於 wasi-shim，host 端實作＝`env[ref]`），注入仍在 WASM auth primitive。
- **rule 01 加解密**：自管 AES-GCM 路徑在回填後廢除；新家由 CF Workers Secrets 託管（arcrun 不持金鑰，管理 API 唯寫讀不回值）。
- **不新增 component**（mindset §1）；不新增 service binding（rule 03 §3.1）。
- **薄殼（rule 07）**：CLI/MCP 只做「list 顯示 / replace 覆寫 / delete」介面轉換，secrets 能力在 API。
- **部署繞開 GitHub Actions**：D1 migration + secrets 寫入設定走 `acr init/update`（wrangler 直推），不掛 Actions；secrets 本體是掛在 script 外部的獨立資源，重部署不受影響（T1.5 spike ④）。
- **D1 同源**：用使用者既有 `arcrun-kbdb` D1（不新建第二顆），migration `0002_credentials.sql` 走現有 `deploy.ts` migration 注入路徑（與 `0001_base.sql` 同模式）。

---

## 6. 開放問題（施工前須 leo / 總管拍板）
- **Q-a（核心）**：✅ **已解（T1 + T1.5 spike，證據 Arcrun#2）**。原問「Secrets Store 動態 by-ref 取值路徑是否可行」→ T1 spike 負結果（binding 靜態、`.get(ref)` 參數被忽略、REST 唯寫），該產品路徑不成立；總管裁決轉向 **Workers per-script Secrets**，T1.5 spike 四驗證全過（§2.3），硬前置解除。
- **Q-b**：寫入時 client 是否仍加密一層（§2.4 甲/乙）。傾向甲（不持金鑰，TS 短暫經手明文不落地）——需 leo 接受此誠實 trade-off。
- **Q-c**：`sensitivity` 分級的判定（誰標 high/standard）——auth-recipe 可加 `sensitivity` 欄宣告該 service 的 credential 等級，或一律 high。
- **Q-d**：與 redesign.md C（友善前門：.env → 一次填）整合——C 的「值來源 .env」在新架構下是「.env 明文 → cypher `PUT` 進 Workers Secrets」，與 §2.4 甲一致。確認 C 依附本 SDD（值的家是 Workers Secrets 不是 D1）。

---

## 7. 任務分解（T1.5 全過，T2-T9 解凍施工——2026-07-03，依總管 Arcrun#2 裁決）
- [x] T1 spike：leo21c CF 帳號 Secrets Store create store + worker by-ref 取值驗證（解 Q-a）。**結果：負結果，路徑不成立**（binding 靜態宣告、`.get(ref)` 參數被忽略、REST 唯寫；2026-07-02，證據 Arcrun#2）→ 轉向 T1.5。
- [x] T1.5 spike：**Workers per-script Secrets 四驗證全過**（① API 動態加免重部署即讀 ② `env[ref]` 動態索引可行 ③ 14 把未觸上限（D21：記錄即可）④ 重部署後存活；2026-07-03，證據 Arcrun#2）。**方向定案，硬前置解除。**
- [x] T2 D1 migration `0002_credentials.sql`（§2.2）+ deploy.ts 注入。2026-07-03 完成：
      `kbdb/migrations/0002_credentials.sql` 新增（IF NOT EXISTS 冪等，schema 對齊 §2.2）；
      `cli/src/lib/deploy.ts` §3.6 新增區塊，重用既有 `applyD1Migration` helper，緊接在 0001_base.sql
      套用之後、同一顆 D1（`ctx.d1DatabaseId`）、同一支 CF D1 query API，機制與 0001 完全一致。
      `cli` package `tsc --noEmit` exit 0（無輸出）。**端到端證據**（leo21c `arcrun-kbdb` D1，
      uuid `1099d0f3-d062-4202-984c-5a57b0a8c031`）：`POST .../d1/database/{id}/query` 送整份
      0002_credentials.sql → `{"success":true,...}`；`SELECT name FROM sqlite_master WHERE
      type='table' AND name='credentials'` → 回 `{"name":"credentials"}`；schema dump 確認欄位與
      `idx_cred_apikey` index 完全對齊 §2.2；重跑同一份 migration 第二次 → `success:true`（冪等驗證）。
- [x] T3 `acr init/update` 確保 secrets 寫入設定就緒：cypher worker 持有「能打 Workers Scripts secrets API 的 CF API token」+ account id（§2.3 寫入路徑；per-script secrets **無 store 資源需 ensure**，原「ensure store + 注入 store_id」整步取消）＋ 於非關鍵 worker 跑一次 `acr update` 全流程驗證 secrets 存活（補 spike ④ 的誠實缺口）。〔依 T1.5 spike 2026-07-03 改寫，證據 Arcrun#2〕2026-07-03 完成（比照
      ENCRYPTION_KEY 既有模式，未發明新模式）：
      1. `cli/src/lib/deploy.ts` `injectWranglerConfig` 新增 `CF_ACCOUNT_ID` 注入（非機密帳號 id，
         比照 `WORKER_SUBDOMAIN` 同一套機制，`ctx.accountId` 是 init/update 早就有的值）。
      2. `cli/src/commands/init.ts` 加「下一步 ③」印手動指令 `wrangler secret put CF_SECRETS_API_TOKEN
         --name arcrun-cypher-executor`（`CF_SECRETS_API_TOKEN` 是機密，比照 `ENCRYPTION_KEY` 走
         手動 `wrangler secret put`，不自動代設）。
      驗證：`cli` `tsc --noEmit` exit 0。**誠實缺口 closure（不重複 T1.5 spike ④，而是對本次真的
      改動過的 committed code 做一次真實驗證）**：`acr update` 目前仍受 mistakes #23 限制（硬綁 GitHub
      codeload `uncle6me-web/Arcrun@main`，會抓不到 Gitea 本次改動、且會把 leo21c 的 24 個 worker
      全部拉回 GitHub 上的舊版覆蓋掉剛部署的 T4/T5 成果）——判斷後**刻意不跑**，跑了只會摧毀
      本次測試環境又證明不了任何新東西，同 mistakes #23 已記錄的已知限制。改採 SDD 本身列的
      替代選項「lighter confirmation... on real committed code」：對已經是本次真實委的 code 的
      leo21c `arcrun-cypher-executor` 做第二次 `wrangler deploy`（同 T5 手法：本地 patch
      `wrangler.toml` 真實 id → deploy → 立刻 restore），部署後 CF API 確認 `CF_SECRETS_API_TOKEN`
      / `ENCRYPTION_KEY` 兩個 secret 都還在（`GET .../workers/scripts/.../secrets` 清單不變），
      並以真實 `curl POST /credentials` 打一次確認寫入路徑仍正常運作（回 `{"success":true,
      "secret_ref":"CRED_REDEPLOY_PROBE_..."}` ）——證明「secrets 在對本次改動過的 code 重部署後
      存活且可用」，非僅沿用 T1.5 spike 對 throwaway worker 的舊證據。測試資料已清理，
      `wrangler.toml` 已 restore 回 git 追蹤版本（grep 確認無 leo21c 帳號專屬 id 殘留）。
      **懸而未決（誠實記錄，非本次能力範圍解決）**：`acr update` 硬綁 GitHub codeload 與
      「cloud-worker 只碰 Gitea」的架構前提仍有結構性衝突（mistakes #23 原文），T3 的
      「acr init/update 確保 secrets 設定就緒」邏輯本身（CF_ACCOUNT_ID 注入 + 印 token 指令）
      已可用，但只要走 `acr update` 這條指令本身，部署物來源仍是 GitHub 舊碼，這個更深的問題
      留給 leo 裁決（非 T3 範圍能解）。
- [x] T4 wasi-shim 新增 `secret_get(ref)` host function（守 rule 02 邊界；host 端實作＝`env[ref]` 動態索引，spike ② 已證可行，零網路呼叫）。〔依 T1.5 spike 2026-07-03 改寫，證據 Arcrun#2〕2026-07-03 完成：
      `cypher-executor/src/lib/wasi-shim.ts` — `ArcrunHostEnv` 加 index signature（`[secretRef: string]:
      unknown`）讓 host function 能對任意 secret_ref 動態取值；`WasiHostFunctions` 加
      `secret_get?: (ref: string) => Promise<string | null>`；`u6u` WASI imports 加 `secret_get`
      wiring（與 `kv_get` 同款 pointer/memory-write 機制：0 成功/1 錯誤/2 找不到）；host 端實作
      `secretGet(env, ref)`＝`env[ref]` 動態索引，**只允許 `CRED_` 前綴**（比照 `routedKvGet` 的
      前綴檢查精神，拒絕讀 `ENCRYPTION_KEY`/`CF_SECRETS_API_TOKEN` 等非 credential 機密，我方判斷的
      安全邊界，SDD 未明文但符合 §2.3「CRED_ 前綴隔離命名空間」精神）；`createArcrunHostFunctions`
      掛上 `secret_get`。驗證：`tsc --noEmit` exit 0（無輸出）；`vitest run tests/wasi-shim.test.ts`
      18/18 通過（新增 `createArcrunHostFunctions — secret_get` 4 案例對 fake env 物件測 CRED_ 前綴放行
      /不存在回 null/非 CRED_ 前綴拒絕/型別不符回 null；`u6u.secret_get` wiring 1 案例測未注入時
      同步 fallback）。**誠實撞牆記錄**：原規劃另外 2 個測 pointer/memory-write 機制的案例
      （直接呼叫 `.imports.u6u.secret_get(...)`）失敗，查證後發現 vitest-pool-workers 環境的
      WebAssembly 支援 JSPI，`hostWrap()` 把所有 async host function（含既有 `kv_get`）包成
      `WebAssembly.Suspending` 物件，此物件設計上只能當 WASM import 綁定用、不能在 JS 端直接
      `fn(...)` 呼叫——用 probe 測試證實 `kv_get` 的 import 同樣不可直接呼叫，確認是既有架構的
      環境限制非 secret_get 缺陷，已移除該 2 案例並在測試檔內註記；pointer 機制的真實驗證改由
      T5 wrangler 部署端到端涵蓋。
- [x] T5 寫入路徑：`POST/PUT /credentials` 改寫 Workers Secrets（API `PUT`）+ D1 ref（§2.4）。2026-07-03 完成：
      `cypher-executor/src/routes/credentials.ts` 整檔改寫 POST（建立）+ 新增 PUT `/credentials/:name`
      （覆寫）：兩者共用 `writeCredential()` → 1) `putWorkerSecret()` PUT 進 CF Workers Scripts secrets
      管理 API（唯寫）2) `upsertCredentialRow()` D1 upsert（不含密文，覆寫時保留原 `created_at`）。
      `secret_ref` 命名＝`CRED_<NAME 大寫>_<sha256(api_key) 前 8 碼大寫>`（`cypher-executor/src/lib/hash.ts`
      匯出既有 `sha256Prefix`，避免跨租戶同名 credential 撞名覆蓋彼此的 secret；純函式衍生非解密/簽章邏輯，
      不違 rule 02 §2.2）。DELETE/GET `/credentials` 本次刻意不動（仍讀寫舊 KV，T9 治理端點範圍），
      已在檔頭與 §3 comment 誠實標注此缺口。`types.ts` Bindings 加 `CREDENTIALS_DB: D1Database`
      （與 KBDB base 共用同一顆 `arcrun-kbdb` D1）+ `CF_SECRETS_API_TOKEN?` / `CF_ACCOUNT_ID?`。
      `wrangler.toml` 加 `[[d1_databases]] binding="CREDENTIALS_DB"`（沿用 kbdb 同款 database_id
      注入機制，deploy.ts 不用改）+ `[vars] CF_ACCOUNT_ID=""` 佔位（T3 由 deploy.ts 自動注入非機密
      帳號 id，同 WORKER_SUBDOMAIN 模式）。**§2.4 選項甲落地**（client 不再 AES-GCM 加密，明文值經
      TLS 傳輸，cypher 短暫在記憶體經手明文不落地不持金鑰）——與舊版 `01-tech-stack.md` 記載的
      `{name,encrypted,iv}` 傳輸格式不同，此為本 SDD 對舊格式的刻意取代，§6 Q-b 仍列需 leo 明確
      接受的誠實 trade-off（先落地，若不接受選項甲需回頭改，見任務完成報告「撞牆」段）。
      驗證：`tsc --noEmit` exit 0（cli + cypher-executor 皆是）；`vitest run` 26/27 通過（1 個
      pre-existing 無關失敗，見 T4 記錄，non-regression 已用 git stash 驗證）。**端到端證據**（真實
      leo21c `arcrun-cypher-executor` worker，非測試替身——寫入端點本身就是要驗的東西，無替代）：
      比照 mistakes #23 手法本地 patch `wrangler.toml`（KV/D1 id、CF_ACCOUNT_ID、WORKER_SUBDOMAIN、
      KBDB_BASE_URL 換 leo21c 真實值，strip `[ai]`/`[[routes]]`）→ `wrangler deploy --dry-run` 核對
      binding 表（`env.CREDENTIALS_DB (arcrun-kbdb) D1 Database` 等全對）→ 真部署 → `wrangler secret put
      CF_SECRETS_API_TOKEN`（沿用 `CLOUDFLARE_API_TOKEN`）→ `curl POST /credentials` 回
      `{"success":true,"secret_ref":"CRED_TELEGRAM_BOT_TOKEN_9BB9FB82",...}` → CF API `GET
      .../workers/scripts/arcrun-cypher-executor/secrets` 確認該 secret 真的存在 → D1 query 確認
      對應 row（api_key/name/service/sensitivity/secret_ref/created_at 全對）→ `curl PUT
      /credentials/telegram_bot_token` 覆寫值，確認 `secret_ref` 不變、`created_at` 不變（覆寫語意
      正確）→ 清理：CF API `DELETE .../secrets/{ref}` + D1 `DELETE FROM credentials`，二次查詢確認
      皆已清空。部署完成後**立刻 restore `wrangler.toml` 備份**，git 追蹤版本無 leo21c 帳號專屬 id
      殘留（已 grep 確認）。
- [x] T6 讀取/注入路徑：auth-dispatcher 改 D1 ref → `env[ref]` 取值（§2.5）+ 更新 last_used。
      〔2026-07-05 本機（有 TinyGo 0.40.1）接手，方案 A，leo 已拍板〕**接續 2026-07-04 [cloud-worker]
      揭露的正確落地方式**（cypher 先取值塞 payload，因 auth worker 讀不到 cypher 的 secrets）：
      **WASM 端（T7 fallback 骨架，可獨立部署驗）**：
        - `registry/components/auth_static_key/main.go`：Input 加 `ResolvedSecrets map[string]string`；
          `authenticate` 的解密迴圈與 `handleResolveCredentials` 都改為「`input.ResolvedSecrets[key]`
          有值就用，沒有才 fallback 舊 `kvGet`+`cryptoDecrypt`」。default（此欄 nil）行為完全等於遷移前舊碼。
        - `registry/components/auth_service_account/main.go`：SA JSON 取得同型（有 resolved 就用，沒有才舊路徑）。
        - `tinygo build -target=wasi` 兩支皆通過（static_key 1.13MB / service_account 1.18MB，在 2MB 限制內），
          copy 到 `.component-builds/{name}/component.wasm`。
      **TS 端（T6 主路徑）**：`cypher-executor/src/actions/auth-dispatcher.ts` 新增
        `resolveSecretsFromNewHome(env, apiKey, names)`：查 D1 `credentials`（api_key+name IN names）拿
        `secret_ref` → `createArcrunHostFunctions(env, apiKey).secret_get(ref)`（T4，host 端 = `env[ref]`）
        取明文 → 組 `{name:value}` map，**取不到值的 name 缺席（不放空字串）** 讓 WASM fallback（T7）→
        取到值的 name `UPDATE credentials SET last_used_at`。`tryAuthDispatch` 取 recipe 非 optional
        `required_secrets[].key` 當 names、`resolveCredentialRefs` 用已收集的 names，兩者都把
        `resolved_secrets` 塞進送 auth WASM 的 POST body。**rule 02 §2.2 對齊**：只查 ref/取值/塞字串，
        不解密、不展開模板、不組 JWT（grep 確認無 `crypto.subtle`/`interpolate`/`{{secret.`/`BUILTIN_*`）。
      **驗證**：`cypher-executor` + `cli` `tsc --noEmit` 皆 exit 0；`vitest run` 41/42（新增
        `tests/auth-dispatcher.test.ts` 6 案例全過：D1 有 ref+新家有值→進 map／無 ref→缺席走 fallback／
        有 ref 但新家沒值 secret_get 回 null→缺席不放空字串／混合／空 names／取值後更新 last_used_at；
        剩 1 個 pre-existing 無關失敗 executor.test.ts「不存在」措辭，見 T8 記錄，非本次退步——
        git baseline 35/36 → 41/42，只增不減）。
      **待 leo21c 部署驗（本任務不部署，`acr update` 硬綁 GitHub codeload=mistakes #23，交總管/後續）**：
        WASM 端到端（resolved_secrets 命中新家的真實注入）在 vitest 測不到（JSPI import 限制，同 T4 記錄），
        `secret_get` 的 pointer/memory-write 也待部署端到端涵蓋。TS 側 `resolveSecretsFromNewHome` 的
        D1 查詢 + secret_get + last_used 更新已用真實 D1 binding + fake env（模擬 per-script secret 注入）
        單元覆蓋。
      〔2026-07-04 [cloud-worker] 卡在框架級工具缺口，非設計問題〕**T6 需要修改
      `registry/components/auth_static_key/main.go`（與 `auth_service_account/main.go`）**：
      這兩個 auth primitive 是獨立部署的 TinyGo WASM Worker，密文值現在住在
      **cypher-executor 自己的 per-script secrets**（T5 `putWorkerSecret` 寫入的 script 是
      `arcrun-cypher-executor`，不是 auth_static_key worker）——`env[secret_ref]` 動態索引
      只在「secret 所在的那個 worker 自己的 runtime」內可讀，auth_static_key worker 讀不到
      cypher-executor 的 env。故 T6 的正確落地方式是：cypher-executor（`auth-dispatcher.ts`）
      先查 D1 拿 secret_ref、呼叫 `secret_get(ref)`（T4 已備妥，讀的正是 cypher 自己的
      env）取得明文，再把已解析好的值放進送給 auth_static_key WASM 的 payload 一個新欄位
      （如 `resolved_secrets: {name: value}`），main.go 收到後優先用它、沒有才 fallback 舊
      `kv_get`+`crypto_decrypt`（這個 fallback 分支同時就是 T7 雙讀）。**這個 main.go 改動
      需要 TinyGo 編譯**——本次雲端環境核實：`tinygo` 未安裝（`which tinygo` 找不到）、
      `apt-cache search tinygo` 查無套件、且 proxy 擋 `github.com`（`curl -sI
      https://github.com/tinygo-org/tinygo/releases` → `HTTP/1.1 403 Forbidden`，
      tinygo 官方無 apt/npm 分發管道，只能從 GitHub Releases 下載），**無法在本環境安裝
      TinyGo 工具鏈，因此無法重新編譯/部署 auth_static_key.wasm**。誠實結論：T6/T7 的
      TS 側前置（D1 查詢＋`secret_get` 呼叫）在架構上可行且不衝突 rule 02（cypher 短暫經手
      明文、只轉送給 WASM 做 template 注入，同 §2.4 選項甲的既有精神），但**卡在需要
      TinyGo 編譯環境這一步，非本次雲端工人能力範圍**，需總管本機（有 TinyGo）或 leo
      決定是否要在雲端環境補裝工具鏈（例如允許 proxy 放行 github.com release 下載，或
      改用其他 TinyGo 取得管道）。整案不硬繞（不改用「TS 直接組 header」這種違反 rule 02
      的捷徑），留給有 TinyGo 環境的一方接手 main.go 改動 + 重新編譯部署。
- [x] T7 雙讀 fallback（§4.1）。〔2026-07-05 本機，隨 T6 一併落地。〕如原設計預期：
      **不需額外的「fallback 判斷」程式碼**——T6 的 `resolved_secrets` 就是「有給才用、沒給
      就照舊 kv_get+crypto_decrypt」的設計（WASM 端 `input.ResolvedSecrets[key]` 的 `ok` 判斷即分流）。
      雙讀在**兩層**自然成立：
        (1) cypher 層——`resolveSecretsFromNewHome` 查 D1 查不到 ref（從未回填）或 `secret_get` 回 null
            （新家還沒值）→ 該 name 不進 `resolved_secrets` map；
        (2) WASM 層——收到的 `resolved_secrets` 缺該 key → 走既有 `kvGet`+`cryptoDecrypt` 舊路徑。
      新寫入（T5）一律新家；舊資料未回填（T8）前仍可讀（舊 KV 不刪，§4.3 回滾錨點不動）。
      單元測試涵蓋「無 ref→缺席」「有 ref 但新家沒值 secret_get 回 null→缺席」兩條 fallback 分流
      （見 T6 的 `tests/auth-dispatcher.test.ts`）。default（無 resolved_secrets 欄）行為 = 遷移前舊碼，
      故 WASM 端此改動可獨立部署、零行為改變地驗「沒弄壞既有」。
- [x] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`（§4.2，冪等可審）。
      2026-07-04 [cloud-worker] 完成：`cypher-executor/src/routes/credentials.ts` 新增
      端點，重用 `createArcrunHostFunctions(env, apiKey).crypto_decrypt`（wasi-shim.ts
      唯一合法 `crypto.subtle.decrypt` 呼叫點，本檔不重新實作解密，grep 確認全 repo
      `crypto.subtle` 呼叫點未增加）解出舊 KV 明文 → `putWorkerSecret` → D1 upsert。
      冪等：D1 已有可解析 `secret_ref` 的 row 就跳過（不重打 CF API）。逐筆誠實回報
      ok/skipped/fail。**單元測試**（`tests/credentials.test.ts`，vitest-pool-workers +
      D1 mock，`tests/setup.ts` 建表）：401 檢查、冪等 skip、空結果、以及「假造密文在
      測試環境缺 CF token 時誠實回報 fail 不假綠」4 案例全過。`tsc --noEmit` exit 0；
      `vitest run` 35/36（1 個 pre-existing 無關失敗，見完成記錄）。
      **端到端真實驗證**（leo21c `arcrun-cypher-executor`，非測試替身）：patch
      `wrangler.toml` 真實 id（同 T5 手法）→ `wrangler deploy --dry-run` 核對 binding
      表全對 → 真部署 → 打 `POST /credentials/migrate-to-workers-secrets` 對真實租戶
      `leo` 名下**兩筆真實既存**的舊 KV credential（`google_service_account`、
      `notion_token`，非測試殘留，KV 內容確認是合法 `{encrypted, iv}` JSON 結構，
      base64 長度正常）→ **端點誠實回報兩筆皆 fail**（`Imported AES key length must be
      128, 192, or 256 bits but provided 0`），**未寫入任何部分資料到 D1 或 Workers
      Secrets（已查證 D1 `credentials` 表對這兩筆 name 完全無 row，非部分髒寫）**。
      **重大誠實發現（非本次程式碼缺陷，是既有基礎設施缺口）**：這個錯誤代表
      `env.ENCRYPTION_KEY` 在 **cypher-executor 這個 worker 自己的 runtime** 內讀到空字串
      （`hexToUint8Array('').length === 0`）——過去 `crypto_decrypt` 只在 auth_static_key/
      auth_service_account 這兩個獨立 worker 上被呼叫過（各自有自己設定好的
      `ENCRYPTION_KEY` secret），**cypher-executor 自己的 `ENCRYPTION_KEY` secret 從未被
      任何程式路徑真正呼叫驗證過**（Phase 6.6 CI 記錄有把同一份 `.env` 值 pipe 給三個
      worker，理論上應該一致，但 CF Workers Secrets 管理 API 本質唯寫、無法讀回比對，
      無法排除某個環節曾經漏設或設成空值）。T8 是**史上第一個**在 cypher-executor 自己
      的 runtime 呼叫 `crypto_decrypt` 的程式路徑，因此第一次揭露這個潛在缺口。
      **不可逆風險，本次不處理，等 leo**：重設 cypher-executor 的 `ENCRYPTION_KEY` 這件事
      本身不可逆——如果現在的空值就是問題根源，重設一把新 key 沒問題；但如果問題其實
      出在別處（例如 `env` binding 讀取路徑有 bug），錯誤地假設「重設 key 就會好」而
      去 `wrangler secret put` 可能導致**這兩把真實 credential（Google Service Account
      + Notion token）從此永久無法解密**（唯寫 API 讀不回舊值比對，猜錯就回不了頭）。
      故本次**刻意不猜、不重設**，只誠實回報現象＋兩種可能成因，交給有本機環境能
      實際比對三個 worker `ENCRYPTION_KEY` 是否一致的一方（總管/leo）診斷後裁決。
      測試資料已清理（探測用 `t89_probe_secret`／`t89_legacy_probe` 皆已刪除，D1 與 KV
      復原至只剩原本兩筆真實資料的乾淨狀態，已 curl 覆核）；部署完成後 `wrangler.toml`
      已 `git checkout` 復原，grep 確認無 leo21c 帳號專屬 id 殘留。
- [x] T9 治理端點 list/replace/delete + 移除任何 read-value 路徑（§3）+ CLI 薄殼。
      2026-07-04 [cloud-worker] 完成：`GET /credentials` 改讀 D1（與既有 `/credentials/
      catalog` 共用同一份 query，`/catalog` 保留給 Console 既有呼叫不受影響）；
      `DELETE /credentials/:name` 改為「D1 有 secret_ref → 刪 Workers Secret + D1 row；
      沒有（從未回填過，只存在舊 KV）→ fallback 刪舊 KV key」，避免 GET 改讀 D1 後
      「查不到卻刪不掉」的孤兒資料。**全程無任何回傳密文值的路徑**（GET 只回
      name/service/sensitivity/created_at/last_used_at，DELETE 不讀值只刪）。CLI 薄殼
      `cli/src/commands/creds.ts` 加 `acr creds list`（讀 D1 顯示）/`acr creds replace
      <name> <value>`（PUT 整筆覆寫）/`acr creds delete <name>`；順手修好 `acr creds
      push`（原實作對應 T5 之前的 `{name,encrypted,iv}` 格式已過期，改直接 PUT 明文
      `{name,value}`，否則會 400——這是既有 CLI 的誠實缺口修正，非新增範圍外功能）。
      **驗證**：`tsc --noEmit`（cli + cypher-executor）exit 0；`vitest run`
      credentials.test.ts 全過（見 T8 記錄，同一檔涵蓋兩個任務）；CLI `tsc` build 後
      `node dist/index.js creds --help`/`creds replace --help` 手動核對指令走線正確。
      **端到端真實驗證**（leo21c `arcrun-cypher-executor`）：
      `POST /credentials`（T5 既有路徑）建一筆 → `GET /credentials` 立刻讀到（D1 新讀
      路徑生效，與 `/catalog` 回傳一致）→ `DELETE` 新路徑分支：CF API 直接核對
      `arcrun-cypher-executor` secrets 清單，刪除前有 `CRED_T89_PROBE_SECRET_*`、刪除後
      該 secret 真的消失（只剩 `CF_SECRETS_API_TOKEN`/`ENCRYPTION_KEY`）＋ D1 row 真的
      清空 → 另外手工在 KV 塞一筆「只存在舊 KV、無 D1 row」的假資料，`DELETE` 走
      legacy-kv fallback 分支，CF API 核對 KV key 真的被刪除。兩分支皆對真實
      leo21c 帳號驗證通過，非模擬。
- [ ] T10 回填驗證 + 觀察期 → 廢 ENCRYPTION_KEY（§4.4，leo 明示放行）。

> **每個 cred 操作跨 TS / WASM / host-function / 兩個 store，必端到端實測**（防再假綠，mindset §7）。原「T1 不通則整案停」已兌現一輪：T1 負結果 → 整案停 → 總管裁決轉向 → T1.5 全過 → **T2-T9 解凍**。備援（若施工再撞死路）：codegen binding + 自動重部署（Arcrun#2 總管裁決的方向 1）。
