# portal-auth — Tasks（實作切分，每階段可獨立 PR）

> 狀態：**全部未動工——本 SDD 待總管/leo 審後才開工**（B 類流程：SDD 審 → 實作 PR → component-pr-review-standard → 總管 review → gated 部署 leo21c）
> 對應：Gitea #24/#25；design.md 各 §

依賴鏈：P1 → P2 → P3 → P4（P2 不依賴 P1 可並行起，但 P3 要兩者都齊）。

---

## P1 — KBDB「庫」filter 地基（design §3.2/§3.3）｜觸碰：`kbdb/`

- [x] 開工第一件事：實測 Vectorize metadata filter `$in` 支援與否（決定主路徑 vs fan-out fallback）
  - **核實結論（2026-07-14）**：**支援，走主路徑 `$in`，不需 fan-out fallback**。證據：① 官方文件
    developers.cloudflare.com/vectorize/reference/metadata-filtering/ 明列 8 運算子
    `$eq/$ne/$in/$nin/$lt/$lte/$gt/$gte`，「For $in and $nin, filter object values can be arrays of
    string, number, boolean, or null values」；② 本 repo `@cloudflare/workers-types@4.20260702.1`
    原生 typing `VectorizeVectorMetadataFilterCollectionOp = "$in" | "$nin"`（index.d.ts:15782），
    wrangler 4.98.0。限制：metadata filtering 只對 2023-12-06 之後建的 index 有效（本專案 index 皆是）。
    本機無 Vectorize runtime，`$in` 的**線上實跑**併入部署排練驗（見部署清單）。
- [x] `/entries/search`＋`/entries` 加 `library` 多值參數（D1 json_extract IN＋NULL→general fallback）
  - 實作註：SQL 用 `COALESCE(json_extract(metadata_json,'$.library'),'general') IN (…)`——與 design §3.3
    的 OR 形狀語意完全等價（單組佔位符較簡）。本機 miniflare＋local D1 十案例實跑全過（PR 附證據）。
- [x] embed upsert metadata 加 `library` 欄；semanticSearch filter 支援 library（**$in 主路徑**，fan-out 不需）
  - 實作註：Vectorize 端在**寫入時正規化**（未標記→`'general'`）——Vectorize filter 做不了 COALESCE，
    寫入端蓋章後查詢端單純 `$in`；與 D1 查詢端 fallback 語意對齊。
- [x] Vectorize `library` metadata index 建立步驟＋reindex backfill 寫進部署清單
  - **部署清單（待雲端排練驗——本機無 Vectorize runtime，semantic 路徑 code 完成、線上實跑未驗）**：
    1. `wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name library --type string`
    2. `POST /embed/backfill {"reindex":true}` 分批到 remaining=0（index 只收建立後 upsert 的向量）
    3. 驗收抽查：semantic 帶 `library=` 過濾命中／不帶行為不變
    4. 掛號：`cli/src/lib/deploy.ts` `ensureVectorizeMetadataIndexes()` 待補 `library`（cli/ 屬 P1
       派工範圍外「只動 kbdb」，隨 P3 或部署 PR 補——kbdb/wrangler.toml 註解已標）
- [x] cypher `kbdb-proxy` 透傳 `library` 參數（供 owner/admin 面用；portal 面走 P3 的注入，不經這）
  - ⚠️ 範圍註（2026-07-14）：P1 派工紅線「只動 kbdb、不碰 cypher-executor」與本項矛盾——照窄範圍
    執行，本項順延（一行 query 透傳，隨 P3 動 cypher 時一併）。
  - ✅ 已隨 P3 補上（2026-07-14 P3 PR）：`/kbdb/search` 與 `/kbdb/entries` 透傳白名單各加 `library`。
- [x] 測試：D1 filter 單元測、NULL fallback、多值、semantic filter（mock VECTORIZE）
  - `kbdb/tests/library-filter.test.ts` 11 項（SQL 形狀/route 解析/向後相容/降級仍 enforce/$in 構造/
    寫入端正規化）＋既有 6 項全綠（17/17）；tsc exit 0。
- **驗收**：curl `/entries/search?q=&library=finance` 只回 finance＋未標記條目歸 general 可驗；semantic 同
- **工程量**：小-中（0.5–1 個 CC 工作天）

## P2 — portal_user 模型＋認證 API（design §2/§4）｜觸碰：`cypher-executor/`（新 route 檔）

- [x] seed template `portal_user`＋`portal_library`（init-seed 慣例）
  - 實作註（2026-07-14）：種子資料 `cypher-executor/src/lib/portal-seeds.ts`（*-seeds.ts 慣例類別）；
    冪等 ensure 在 `routes/portal.ts` `ensurePortalTemplates()`，掛進 `POST /init/seed`（回應加
    `portal_templates` 區塊）＋ bootstrap 前自動 ensure（沒跑過 seed 也能 bootstrap）。
- [x] PBKDF2-SHA256 雜湊模組（600k iters、常數時間比對、`pbkdf2-sha256$…` 格式）
  - `cypher-executor/src/lib/portal-auth.ts`（WebCrypto deriveBits；verify 從儲存格式解析 iterations
    → 換 KDF/參數可共存漸進遷移；constantTimeEqual 同 mcp PR#15 實作）。
- [x] `POST /portal/admin/bootstrap`（console owner session 閘）
- [x] login/logout/session/改密碼（KV `portal_sess:`、TTL var、每請求回讀 record 驗 status）
  - `PORTAL_SESSION_TTL` 進 wrangler.toml [vars]＋types.ts（預設 604800）。
- [x] admin 用戶 CRUD＋libraries 授權＋庫目錄 CRUD（role=admin 閘）
  - 安全註：admin 操作目標 record 需過「email head entry 指回同 record_id」成員驗證（防拿任意
    record_id 改到不相干 KBDB record）；列表/回應一律剝除 password_hash；未帶密碼建帳號 →
    server 產一次性密碼隨回應回傳一次（不落地明碼）。
- [x] 登入失敗節流（5 次/15 分鐘，KV TTL）
- [x] 順手修 `record-crud.ts` updateRecord grow 路徑漏 owner_id（design §2.2 附帶）
  - 修法：首查 JOIN entries 帶回 record 既有 owner_id，grow 的新 entry 沿用。回歸測試
    `kbdb/tests/record-grow-owner.test.ts`（3 項：帶 owner/原無 owner 維持 null/非 grow 不 INSERT）。
- [x] 測試：bootstrap 閘、登入對錯、停用即拒（session 立即失效）、role 閘、雜湊格式、`{tenant}::portal` 子 namespace 隔離（**搜 email 搜不到**）
  - 單元：`cypher-executor/tests/portal-auth.test.ts` 20 項（fetchMock 假 KBDB host，絕不外連）；
    cypher 全套 133/134（唯一失敗＝executor「不存在的零件」pre-existing，stash 複驗）；kbdb 20/20；
    兩包 tsc exit 0。端到端：本機雙 worker（隔離 persist）33/33 全綠，含**搜尋隔離實測**
    （租戶 ns 搜 email/雜湊 0 筆＋對照組 leo::portal 搜得到＝非假綠）與明碼落地抽查 0 筆。
- **驗收**（=#25 驗收）：新增用戶能登入；停用登入被拒＋既有 session 失效；KBDB 無新表；密碼抽查非明碼
- **工程量**：中（約 1 個 CC 工作天）

## P3 — `/portal` UI：登入殼＋搜尋頁＋設定頁＋scope enforce（design §1/§3.3/§5/§6）｜觸碰：`cypher-executor/`

- [x] `/portal` HTML 殼（重用 console 樣式/搜尋 view 抽共用 helper；`CONSOLE_BRAND` 品牌；零 Mira 字樣）
  - 實作註（2026-07-14）：`routes/portal-ui.ts` 單檔 HTML＋原生 JS（console 同形態）。**UI 重用取捨＝
    複製 console 樣式子集而非抽共用模組**——抽共用要動 console.ts（違「console 現狀不動」鐵律），
    代價＝樣式雙份、改版要兩邊同步（PR 說明誠實記）。零 Mira 字樣由測試機械斷言。
- [x] 未登入只見登入殼；登入後兩頁：搜尋（keyword/semantic/graph 三模式＋source 溯源＋卡片詳頁）＋設定（改密碼/看自己權限/主題）
  - graph 模式按 session `graph_allowed` 顯示（D-4）；工作流頁 admin 額外可見（D-8，
    `PORTAL_SHOW_WORKFLOWS` 預設 admin，唯讀不開 trigger）——前端藏只是 UX，真閘在路由層。
- [x] `/portal/data/*` server-side enforce：session→record→注入 `owner_id`＋`library`；**前端絕不下發租戶字串**
  - `routes/portal-data.ts`（安全核心）：caller 自帶 owner_id/library 一律被 server 值靜默覆蓋
    （e2e 攻擊實測）；`["*"]`＝不注 library；HTML 殼經測試機械斷言零租戶字串/零
    X-Arcrun-API-Key/不打 /kbdb/*。
- [x] 卡片詳頁逐筆驗 library（越庫 id 直讀 → 404）
  - 雙重驗證：owner_id=CONSOLE_TENANT（防拿跨租戶 id 直讀）＋library∈用戶集合（NULL→general）；
    越庫/跨租戶/不存在**同一句 404**（不洩存在性，e2e 逐字比對）。
- [x] graph 粗閘（D-4：無 graph 來源庫權限 → 模式不顯示＋API 403）
  - 來源庫＝portal_library `graph_source='true'` 集合（seed 補 slot＋ensurePortalTemplates 冪等
    grow 舊 template 缺的 slots；admin libraries PATCH 可標記/取消），**沒有任何標記→預設
    ['general']**（D-4 定案）；無權 403、前端模式鈕不顯示；停用庫不算來源。
- [x] 測試：curl 帶 user session 直打 data API 驗 filter 繞不過（=#24 驗收 3 的 server-side 證明）
  - 單元：`cypher-executor/tests/portal-data.test.ts` 21 項（fetchMock 假 host，絕不外連）；
    cypher 全套 154/155（唯一失敗＝executor「不存在的零件」pre-existing，stash 複驗）；
    kbdb 20/20；兩包 tsc exit 0。端到端：本機隔離雙 worker（9790/9791，demo 8787/8788 不碰）
    **47/47 全綠**——含越庫 id 直讀 404、攻擊參數被覆蓋、graph 403→授 general 後放行、
    admin 全庫＋跨租戶不可見、權限 PATCH 即時生效、HTML 殼斷言＋inline JS node --check、
    改密碼全鏈。`wrangler deploy --dry-run` 打包過。
- **驗收**（=#24 驗收）：leo21c `/portal` 可開；未登入只見登入殼；三模式＋溯源可用；A 用戶（僅 general）搜不到 finance 內容——UI 與 curl 雙驗
  - 本機端到端已全數驗過（上項證據）；**leo21c 線上面＝部署後驗**（gated wrangler 直推，B 類流程；
    semantic 線上路徑仍依 P1 部署清單的 Vectorize index＋reindex）。
- **工程量**：大（1–1.5 個 CC 工作天，UI 是最大件）

## P4 — admin 頁＋工作流顯示（design §6）｜觸碰：`cypher-executor/`

- [x] 帳號管理頁（admin-only nav）：列表/新增/停用/重設密碼＋每帳號庫權限勾選
  - 實作註（2026-07-14）：`portal-ui.ts` 加「管理」頁（v-admin，nav 只對 role=admin 顯示，
    真閘＝/portal/admin/* role 閘 403）。新增未帶密碼 → server `generatePassword` 一次性
    密碼**只在畫面顯示一次**（otpbox，關閉即逝；server 只存 hash）；每帳號庫勾選含
    `["*"]` 全庫選項（勾選連動停用個別庫框）；可勾清單＝庫目錄啟用中 ∪ general ∪ 該帳號
    既有授權（目錄外舊授權不憑空消失）。admin API P2 已交付不重做；本波補
    **last-admin 鎖死保護**（總管派工明定，design 未明寫——PATCH 停用/降級「最後一個
    active admin」→ 409；只在目標是 active admin 且 patch 會使其失格時多打一次 list）。
- [x] 庫目錄管理（登記/停用庫）
  - 同頁第二區：登記（name/display_name/description）＋停用/啟用＋ `graph_source`
    圖譜來源標記（D-4，P3 已有 API，本波補 UI）。
- [x] 工作流頁（唯讀 list＋最近執行；`PORTAL_SHOW_WORKFLOWS` 預設 admin；不開 trigger)
  - **P3 已交付**（`/portal/data/workflows` route＋v-workflows 頁＋D-8 閘），P4 沿用零改動。
- **驗收**：admin 全流程「發帳號→授庫→同仁登入查詢→停用」在 UI 走通；一般用戶看不到 admin 頁與（預設下）工作流頁
  - 單元：`tests/portal-admin.test.ts` 14 項（鎖死保護含「另一 admin 是 disabled 不算數」
    「停用一般 user 不多打 list」；一次性密碼/reset 明碼不進 KBDB body；HTML 殼加 admin 頁後
    仍零租戶字串/零 /kbdb//零 API key/零 Mira）。cypher 全套 168/169（唯一失敗＝executor
    「不存在的零件」pre-existing）；kbdb 20/20；兩包 tsc 0；dry-run 打包過；inline JS
    node --check 過（2 段）。
  - 端到端：本機隔離雙 worker（9790/9791，demo 8787/8788 不碰）**41/41 全綠**——bootstrap→
    admin 建帳號拿一次性密碼→新 user 登入→勾庫後**同一 session 搜尋範圍即時變化**（1 筆→2 筆、
    越庫 404→200）→停用 session 立即失效→重設密碼舊密 401 新密 200→last-admin 409（含降級
    即時生效：原 admin session 打 admin API 變 403）→一般 user 打 admin API 403。
  - leo21c 線上面＝部署後驗（gated wrangler 直推，B 類流程）。
- **工程量**：中（約 1 個 CC 工作天）

---

## Bugfix（P1-P4 交付後）

- [x] **PBKDF2 600k → 100k（T6-cloud 部署抓到的框架蟲，2026-07-14，分支 `fix-pbkdf2-cf-limit`）**：
  CF Workers 正式 runtime PBKDF2 上限 100,000 iterations，600k 時 `crypto.subtle.deriveBits`
  直接拒絕 → `/portal/admin/bootstrap` 真雲 500（uncle6 實撞，證據 arcrun-rag
  `docs/manual/uncle6-deploy-record.md`）；miniflare 無此限制＝本機測試全綠假象。
  修：`portal-auth.ts` `PBKDF2_ITERATIONS` 100_000＋註明平台封頂；design D-5 已加修訂註。
  verify 相容性：迭代數從儲存值解析，補測試「600k 舊 hash 仍通」（miniflare 造）；誠實邊界
  ＝真雲 deriveBits 同樣封頂、600k hash 真雲仍拒，但真雲 bootstrap 從未成功過 → 無存量 hash、
  無遷移面。同 PR 順手：`.component-builds/http_request/wrangler.toml` 補
  `global_fetch_strictly_public` flag（self-hosted 同帳號 fetch 1042 正解，Arcrun#33 同族，
  官方行為不變原理同 cypher 前例 rules/03 1042 段）。

- [x] **Portal 來源回溯超連結（2026-07-18，任務層小改，分支 `feat/portal-source-link`；
  來源＝leo RAG demo 客戶測試回饋「來源回溯 gitea:// 沒辦法點」）**：
  新增可選 var `PORTAL_SOURCE_WEB_BASE`（types.ts 註解含語意）；portal-ui 卡片詳頁「來源回溯」
  與 AI 問答出處列在「var 有設＋source 為 gitea://」時渲染 `<a target=_blank rel=noopener>`
  （gitea://<path>#n → {base}/<path>，chunk 錨點捨棄、路徑逐段 encodeURIComponent、href 過
  esc()）；未設 var 行為一字不變（Mira 零影響）。驗證：tsc 0、portal-auth/portal-data 測試
  48/48 綠、渲染後 client JS new Function 語法檢查過。部署：uncle6 重部＋設 var（leo 閘）。

- [x] **搜尋濾內部型別＋總圖頁（2026-07-18 晚，任務層小改，分支 `feat/portal-library-overview`；
  來源＝leo 客戶測試回饋「無標題雜項列」＋「書庫總圖」指示（原案＝Arcrun#39 藏書地圖的 GUI 切片））**：
  ① `filterDeprecatedEntries` 加內部型別過濾（value＝slot 值外漏雜項列、workflow＝工作流定義），
  #46 上游修好後隨整段拔掉；測試補一案（49/49 綠）。
  ② 新增 `GET /portal/data/graph/overview`（全租戶 active 三元組 → nodes/edges，D-4 graph
  粗閘同 neighbors，kbdbFetch 直讀不經 plugin，邊數上限 500 誠實截斷）＋portal「總圖」頁
  （nav 隨 graph_allowed 顯示；手刻力導向佈局、固定種子確定性、紙感樣式、點節點跳圖譜搜尋；
  頁尾連 00-MAP.md＝#39「人機共用同一份地圖」的文字版）。
  ③ #39 本體（library_map Template/ingest 重算/MCP instructions+get_map）不在本次範圍，仍歸 #39 SDD。

- [x] **t95 搜尋 CJK 正規化（2026-07-28，任務層小改）**：
  來源＝leo 實測 geek6688 實例「搜『AI協作』（無空白）0 結果，搜『AI 協作』有結果，中文習慣不是每個人都會加空白」。
  修：`portal-data.ts` 新增 `normalizeCjkQuery()`（CJK/ASCII 邊界自動插空白，純函式可 export 單測）；
  `/portal/data/search` 路由的 `q` 參數先過正規化再查 KBDB；只動查詢端不動索引端。
  驗證：pure function 6 案（純 CJK/ASCII 不動、邊界插空白、已有空白不重複、全形符號不觸發）＋
  integration 2 案（`AI%E5%8D%94%E4%BD%9C` → KBDB 收到 `AI 協作`）。

- [x] **t96 圖譜節點模糊比對 fuzzy fallback（2026-07-28，任務層小改）**：
  來源＝leo 實測「graph 模式搜『AI 協作』回鄰居 0 關聯 0，但總圖上明明有節點『AI 協作規範書』—精確比對太嚴」。
  修：`portal-data.ts` 新增 `findBestNodeMatch()`（contains 比對＋最短名優先，純函式）＋
  `fuzzyFindNode()`（查 KBDB triplet records 找最佳節點名）；
  graph neighbors 路由 plugin fallback 路徑（②）：精確命中 0 鄰居+0 邊 → 以 fuzzyFindNode 找最佳節點名重查；
  工作流路徑（①）套 CJK 正規化但不加 fuzzy fallback（workflow 自管節點解析）。
  B5 分支（work/b5-graph-library-filter-0726）只動同一行的 `libraries` 欄位，衝突面最小。
  驗證：findBestNodeMatch 6 案（空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫）＋
  integration 4 案（有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動）。

- [x] **t97 庫目錄只顯示用戶同步進來的（2026-07-28，任務層小改）**：
  來源＝leo 裁定「用戶沒加上的庫，不要自作主張給它加上」。
  t97a：bootstrap 後不再預埋 `kb` 庫（index.html firstsetup 移除 POST /portal/admin/libraries 種子 call）。
  t97b：GET /portal/admin/libraries auto 段新增 `n === 'general'` 過濾——general 是系統「未標庫」
  fallback 桶，不是用戶加的，不在目錄露臉；資料照舊、B5 權限語意不動。
  驗證：portal-admin.test.ts 新增「auto 過濾 general」1 案（mock KBDB /entries/libraries → ['kb','general','notes']，
  回應 names 含 kb/notes，不含 general）。

- [x] **t114 拿掉「登記到目錄」兩段式（2026-07-28，任務層小改）**：
  來源＝leo 裁定「掃進來的就是要進目錄…加入目錄這件小事還要分兩段做？是在攻打用戶嗎」。
  修：`renderAdminLibs()` 拿掉 auto/已登記視覺分岔，auto 庫直接以完整卡片顯示（保留「同步自動出現」tag，
  去掉「登記到目錄後可以改顯示名」描述文字）；移除「登記到目錄」按鈕與 lib-adopt event handler；
  空狀態文字改「同步小幫手還沒送來任何庫…庫會自動出現在這裡」。
  顯示名：此輪 auto 庫唯讀（與已登記庫現行行為一致，實作成本最低；後續若需可補 auto-adopt on PATCH）。
  搜尋不依賴登記（搜尋走 /portal/data/search → server 注入 library filter，與庫目錄登記簿無關）。
  驗證：HTML 殼測試補「無 lib-adopt、無登記到目錄」斷言；既有 HTML 斷言（零租戶字串等）不回退。

- [x] **t115 kbdb 全域認證中介層（2026-07-28，安全洞熱修；二修 2026-07-28）**：
  實證：不帶任何憑證直打 `arcrun-kbdb.<sub>.workers.dev/entries` → 200 回真實知識；POST 直寫
  成功。B5 權限做在 cypher/portal 層，繞過 portal 直打 kbdb 全破，隱私賣點（原文不出機）形同虛設。
  一修（已在樹上）兩個缺陷：① `if (env.KBDB_INTERNAL_TOKEN)` 才擋 = 沒設 secret 洞照開（fail-open）；
  ② workflows/rag-ingest-card.local.yaml（arcrun-rag repo）有 3 處 `__KBDB_BASE__`
  （post_block/post_triplet 走 http_request 直打 kbdb），rag-chat/graph-neighbors/takedown 同樣；
  token 生效後收卡與查詢全 401。
  二修（本次）：
  ① `kbdb/src/index.ts` middleware 改 fail-closed：
     - 未設 token → POST/PATCH/DELETE/PUT 直接 401（fail-closed for writes）；
       GET 記 console.warn 後放行（讀取升級窗口，老實例不整個炸）。
     - 已設 token → 非 health 路由全部要求 Bearer（行為同一修）。
  ② `kbdb/tests/auth.test.ts` 改 12 項（含 PATCH/DELETE 無 token→401；原「無 token POST 過」改為 401）。
  ③ `kbdb/src/types.ts`＋`kbdb/wrangler.toml` 說明改為 fail-closed 語意。
  ④ workflow http_request 節點（arcrun-rag 側）：規格見本項末「給安裝器的規格」段，由總管派 arcrun-rag。
  cypher 不需改（五處已有 `if (KBDB_INTERNAL_TOKEN) headers[Authorization]=Bearer`）。
  老實例升級路徑：安裝器部署時自動生成同一把 token → `wrangler secret put KBDB_INTERNAL_TOKEN` 注入
  kbdb 與 cypher 兩個 worker；workflow yaml 同批替換 `__KBDB_TOKEN__`（見規格）即封口。
  kbdb_upsert_block WASM 指向死路由 `/blocks`（設 token 前後都壞，不新增破壞）。

- [x] **t116 圖搜尋靜默失敗——portal 補傳 kbdb_base（2026-07-29，任務層小改）**：
  診斷：graph_neighbors workflow 的 fetch_triplets url 含 `{{input.kbdb_base}}/records/...`，
  但 portal 呼叫 executeWebhookGraph 時未傳 kbdb_base → URL 解析失敗 → `HTTP request failed`。
  修法（b 案 雙保險）：portal-data.ts 補傳 `kbdb_base: env.KBDB_BASE_URL ?? ''`；
  yaml 端（`__KBDB_BASE__` 安裝期替換）由總管派 arcrun-rag，詳見本項末規格段。
  執行範圍：`cypher-executor/src/routes/portal-data.ts`（補傳 kbdb_base）。

- [x] **t117 三元組寫入靜默失敗——FOREACH 全失敗浮出水面（2026-07-29，任務層小改）**：
  診斷：youlin 實例 triplet=0，懷疑 post_triplet http_request 401 被靜默吞掉；
  FOREACH 收集到全 `success:false` 的 iterResults 後回 `{success:true, data:{results:[...]}}` —— 呼叫端不知失敗。
  兩層修法：
  ① wasi-shim.ts http_request catch：改寫錯誤 envelope（含 fetch 原始 message）到 WASM 輸出，
    取代只 return 1（WASM 寫 "HTTP request failed"）；
  ② graph-executor.ts FOREACH：若全部 iterResults 均 `success===false`，拋 Error（含首項 status code）
    → catch 轉 ExecutionError → executeWebhookGraph 回 `{success:false, error:"..."}`。
  執行範圍：`cypher-executor/src/lib/wasi-shim.ts`、`cypher-executor/src/graph-executor.ts`。
  測試：vitest 新增兩條（FOREACH 全失敗→error 含 status；wasi-shim catch 路徑）。

- [x] **t122 萃取引擎金鑰雲端下發（2026-07-29）**：
  來源＝總管確認：daemon/config 回傳 extractor:'claude' 寫死且不含金鑰，封測者萃取全滅。
  修：① `POST /portal/admin/extractor`（admin 閘）存 `{engine, gemini_api_key?, llm_model?}` 到 KV
      key=`{tenant}:portal:extractor_config`，金鑰不落 log；
      ② `GET /portal/admin/extractor`（回 engine + has_key:bool，不回明文）；
      ③ `POST /portal/daemon/config` 回傳改讀上述設定（未設→預設 gemma；gemma+有 key → 含金鑰下發）；
      ④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊（引擎選擇＋金鑰輸入＋提示重連）。
  測試：vitest 新增 3 案（未設定→下發 gemma 無金鑰；設定後→下發含金鑰；GET 不回明文）。

- [x] **t128 圖搜尋仍回 0——graph_neighbors 補傳 template（2026-07-29，任務層小改）**：
  診斷：t116 只補了 kbdb_base，但 fetch_triplets.url 同時含 `{{input.template}}`；
  portal 呼叫 executeWebhookGraph 未傳 template → URL 變 `/records/by-template/?owner_id=...` → count=0。
  總管實測：手動補 `"template":"triplet"` → count=1（企業版功能解鎖）。
  修：portal-data.ts graph_neighbors 呼叫補 `template: 'triplet'`。
  執行範圍：`cypher-executor/src/routes/portal-data.ts` 一行。
  測試：portal-data.test.ts 新增整合案（t128 describe，workflow 執行不崩即代表 template 進到 context）。
  workflow input 完整核對清單（portal-data.ts 所有 executeWebhookGraph 呼叫）：
    - graph_neighbors：node ✅、depth ✅、namespace ✅、owner ✅、kbdb_base ✅（t116）、template ✅（本次）
    - rag_chat：question ✅；namespace/kbdb_base 未核實（rag_chat workflow yaml 在 arcrun-rag，非本 repo）
    - takedown：不在 portal-data.ts，在 arcrun-rag 端（dashboard/安裝器），超出本 repo 範圍

- [x] **t130 新實例缺 triplet template → 總圖永遠空（2026-07-29，任務層小改）**：
  真因：`PORTAL_TEMPLATE_SEEDS` 只有 `portal_user`＋`portal_library`；`rag_ingest_card.post_triplet`
  打 `POST /records {template:'triplet'}` → 新實例回 400「template not found: triplet」→ 三元組全滅。
  修：`portal-seeds.ts` 補 `triplet` seed（slots 來源：2026-07-19 kbdb_list_templates 核實 15 槽 + library）。
  呼叫路徑驗證（`ensurePortalTemplates` 全四掛點）：
    ① `POST /init/seed`（acr init 觸發）← 主線，新裝必經
    ② `POST /portal/admin/bootstrap`（首次 admin 設定）
    ③ `POST /portal/daemon/libraries`（daemon 登記資料夾→庫）
    ④ `POST /portal/admin/libraries`（admin 手動新增庫）
  既有壞實例補救：任一掛點重跑即補建（冪等），e.g. 重跑 acr init 或 bootstrap。
  執行範圍：`cypher-executor/src/lib/portal-seeds.ts`（新增 triplet seed）；
    `cypher-executor/tests/portal-auth.test.ts`（mockTemplatesExist 補 triplet + t130 3 條新測試）；
    `cypher-executor/tests/portal-admin.test.ts`（mockTemplatesExist 補 triplet）。
  測試：純資料驗證（seed 含 triplet + 必要 slots）＋冪等驗（已存→existing）＋自動補建（404→POST→created）。

- [x] **t129 AI 問答出處重複一整頁——後端按 page_name 去重（2026-07-29，任務層小改）**：
  診斷：一張卡拆成 3-5 block，每 block 各回一筆 source（同 page_name）→ 前端列一整頁重複。
  選後端修（/portal/data/chat）理由：出處去重是資料清潔，跟前端渲染無關；改後端不需動 HTML。
  修：portal-data.ts 新增純函式 `dedupeSourcesByPage()`（Map 按 page_name/page 去重，hit_count > 1 時附計數）；
  chat 路由 rawSources 先過去重再回。
  執行範圍：`cypher-executor/src/routes/portal-data.ts`（新增 export 函式 + chat route）。
  測試：portal-data.test.ts 新增 5 案（t129 describe：合併計數/各保一筆/page 備用/空陣列/page_name 優先）。

- [x] **t135 庫目錄移除（2026-07-29，任務層小改）**：
  來源＝leo 裁定「需要加移除按鈕，別人裝錯我沒辦法幫他弄，需要可以自主」。
  後端：`DELETE /portal/admin/libraries/:id`（刪登記簿 record，資料不動）＋
  `DELETE /portal/admin/libraries/by-name/:name`（auto 庫；body `confirm:<庫名>` 才執行；
  呼叫 KBDB `PATCH /entries/deprecate-by-library`，entries 標 deprecated → 從 auto 清單消失）。
  KBDB：`deleteRecord` action + `DELETE /records/:id` route；
  `deprecateEntriesByLibrary` action + `PATCH /entries/deprecate-by-library` route（此路由在 `/:id` 之前）。
  Daemon hint（根治只增不減）：`POST /portal/daemon/libraries` 存 active lib names 到 KV（TTL 48h）；
  `GET /portal/admin/libraries` 讀 KV 後對登記庫標 `daemon_watching: false`（若 daemon 有回報但未含該庫）。
  前端：每張庫卡片加「移除」按鈕（登記庫＝`confirm()` 對話框；auto 庫＝`prompt()` 輸入庫名確認）；
  移除後即時從列表消失（不必重整）；`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。
  測試：portal-admin.test.ts 新增 7 案（DELETE/:id 成功/404/非admin403；by-name 成功/無confirm/confirm不符/非admin403）。

- [x] **t131 金鑰設定合併（2026-07-29，任務層小改）**：
  來源＝leo 裁定「一律規定先輸入 gemini api key，聊天和萃都一次設好；如果有 claude，勾選加強版」。
  後端：新增 `POST /portal/admin/ai`（同時做 chat-key＋extractor 的事）＋
  `GET /portal/admin/ai`（回 has_key/use_claude_for_extract/claude_available）＋
  `POST /portal/daemon/report-capabilities`（收 daemon 回報的 has_claude）；
  舊 `/portal/admin/chat-key`、`/portal/admin/extractor` 保留相容。
  前端：設定頁兩區塊合一，Gemini Key 必填欄＋Claude 選填 checkbox（依 daemon 回報 enable/disable）。
  ⚠️ daemon 端（arcrun-rag repo）需實作 `report-capabilities` 呼叫，規格：
    - 連線成功後 POST `{cypher_url}/portal/daemon/report-capabilities`
    - body: `{email, password, has_claude: bool, daemon_version: string, os: string}`
    - has_claude 由現有 `FindClaudeBin()` 決定（t92 已實作）
    - 時機：連線成功後一次＋每次啟動時一次（不加新 timer）
  KV key 設計：`{tenant}:portal:ai_config`（合併設定）`{tenant}:portal:daemon_caps`（能力回報，TTL 7 天）
  測試：portal-admin.test.ts 新增 7 案（全通；全套 238 tests 229 passed，9 failed 皆 pre-existing）。

- [x] **t142 庫目錄顯示同步張數＋關聯數（2026-07-29，任務層小改）**：
  來源＝leo 裁定（政府專案驗收）「雲端子庫顯示同步了幾個 wiki＋幾個三元組，一眼看出這個庫真的有東西」。
  後端（kbdb）：
    ① `GET /entries/library-stats?owner_id=` → `{stats: [{library, card_count}]}`
       SQL：`COUNT(DISTINCT page_name)` GROUP BY library（僅 entry_type='block'，排 deprecated）。
       ⚠️ 卡數＝distinct page_name（一張卡 3-5 個 block），不是 COUNT(*)（防膨脹 3-5 倍）。
       路由在 `/libraries` 之後、`/` 之前（避免被 `/:id` 吃掉）。
    ② `GET /records/triplet-stats?owner_id=` → `{stats: [{library, triplet_count}]}`
       SQL：subquery DISTINCT triplet record IDs + LEFT JOIN library slot（無 slot→general），
       一次 SQL 完成，不 N+1。路由在 `/by-template/:template` 之前。
  後端（portal.ts）：`GET /portal/admin/libraries` 改並行撈三端點（Promise.all + .catch(→null)）；
    已登記庫與 auto 庫各補 card_count + triplet_count（Map O(1) 查找），任一失敗不炸主流程。
  前端（index.html renderAdminLibs）：每張庫卡片補一行
    「N 張知識卡・M 條關聯」（只顯示非零項）；兩者均 0 → 顯示「還沒有內容」，不顯示「0 張」。
  測試：`kbdb/tests/library-stats.test.ts`（14 新案：SQL 形狀/COUNT DISTINCT/entry_type filter/
    deprecated filter/COALESCE general fallback/參數傳入/回傳結構/空陣列不炸）；
    `portal-admin.test.ts`（t97 既有測試補三端點 mock + t142 describe 3 案：
    已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0）。
  vitest + node --check 待 leo 驗收環境跑（本機無 Workers runtime）。

- [x] **P8-資源搭配：節點輸出 KV 寫入只服務 PIPE 讀者（2026-08-09，任務層小改）**：
  來源＝arcrun-rag `system-dev/docs/3-specs/pending-changes.md` P8「短板齊平」（leo 08-08 裁決通過）
  ＋ leo 08-09「不需要換模型，要調整每個 CF 數字搭配」（模型切換已 revert `894d9ab`，本項不碰模型）。
  病：BUILD-006 讓**每個節點**（含 FOREACH 每一圈）把輸出 put 進 EXEC_CONTEXT KV，
    但全 codebase 唯一讀點是 PIPE 邊的 `kvGetNodeOutput`——rag 系工作流（ON_SUCCESS＋對每個）
    完全沒有 PIPE 邊 ⇒ 一張卡 15 次 put 全是寫了沒人讀的。
    KV 免費層 1,000 write/日 ÷ 15 ≈ **66 檔/日＝全系統真正最短的板**（比 neurons 的 119 檔/日更短，
    且免金鑰路與 Gemini 路都吃這條）。§3.8.2 原盤點只算到 execution-logger 那 1 筆，漏了這 15 筆。
  修：`graph-executor.ts` 節點輸出寫 KV 前檢查「該節點有 PIPE 出邊」才寫；
    PIPE 工作流（含「完成後」與未知語意詞預設 PIPE）行為不變，resume 路徑（`resumeFromPaused`）不動。
  實測（youlin stage，兩張同構測試卡 5 blocks＋6 triplets）：
    修前 run `rag_ingest_card-1786208572245` 留 6 個 node key（15 次 put）；
    修後同形狀執行 EXEC_CONTEXT **零 key**；blocks/triplets 照樣寫入成功（KBDB record id 全數回傳）。
  單元測試：`tests/executor.test.ts`「P8：節點輸出 KV 寫入只服務 PIPE 讀者」——
    無 PIPE 圖 put=0／PIPE 圖照舊寫且 `_kv_outputs` 傳遞不變（12 passed；既有 1 failed 為
    「零件不存在」文案舊測試，修法前後皆失敗，與本項無關）。
  一併修復：08-08 deploy-all 重部把 youlin 的 `[ai]` binding 洗掉（`/portal/daemon/extract` 501）
    ——本次以 `KEEP_AI=true` 重部復原（501→200 實測，模型仍 scout 未動）。

- [x] **t218 設定頁補顯示 MCP 連接網址（2026-08-09，任務層小改，arcrun-rag#7）**：
  來源＝封測者原話「說明叫我把 MCP 加進 claude.ai connector，但我找不到網址」。文件
  （`docs-site/use/mcp.md`）一直寫「登入 portal → 設定頁，那裡可以直接複製」，但畫面上
  從沒真的顯示過——用戶照著文件走一定撲空（總管實查：`console-ui/public/portal/index.html`
  grep `mcp|MCP` 0 命中）。
  修：`console-ui/public/portal/index.html` 設定頁新增一塊面板「接上你的 AI（MCP）」，
  純前端字串轉換（不需後端新端點、不需安裝器多寫設定）：MCP 網址＝apiBase 把
  worker 名字從 `arcrun-cypher-executor` 換成 `arcrun-mcp`，同一顆自架帳號的 workers.dev
  子網域（與 `cli/src/lib/deploy.ts:386-392` 部署時組出這兩個網址的邏輯完全對齊，非另立
  一套猜法）；不是這個網址形狀時誠實顯示「尚未偵測到」＋停用複製鈕，不亂猜一個連不到的網址。
  複製按鈕沿用既有 `st-copy-url` 的 clipboard 邏輯，抽出共用 `copyText(btn, url)`（原
  `copyOriginUrl` 只能複製 `location.origin`，MCP 網址不是 origin，需要能傳任意字串）。
  文件（`products/arcrun-rag` docs-site `use/mcp.md`）核對過："不確定是哪一串？登入 portal
  → 設定頁，那裡可以直接複製" 這句在本次修完後才是真的，故文件本身不必改字。
  **端到端實測**（本機真瀏覽器，非 curl）：起 kbdb + cypher-executor 兩個真 `wrangler dev`
  （local D1 套 migrations 0001-0006，兩邊 `KBDB_INTERNAL_TOKEN` 對齊解開 t115 fail-closed
  閘）＋本機靜態伺服 portal（`UI_ORIGINS` 解 CORS）；真的走 `/console/setup`→`/console/login`
  →`/portal/admin/bootstrap` 產生第一個 admin，瀏覽器真登入 `/portal/login`，導到設定頁——
  面板正確顯示、apiBase 是 localhost（不符自架網址形狀）時誠實顯示「尚未偵測到」＋複製鈕
  停用（沒有亂猜一個假網址）。再用瀏覽器 console 把 `window.ARCRUN_API_BASE` 換成貼近真實
  自架形狀的字串（`https://arcrun-cypher-executor.fake9xyz.workers.dev`，故意用假 subdomain，
  不使用任何真實租戶字串以免誤觸真實實例），對照組獨立驗算與 index.html 內同一份公式，
  結果與 `deploy.ts:391` 的組法逐字相同（`https://arcrun-mcp.<sub>.workers.dev/mcp`）；
  真的把這個值餵回頁面 DOM（`#st-mcp-url`），畫面正確顯示、複製鈕從停用變可按。
  複製鈕點擊後 clipboard 沒有變化——經比對**既有、本次未改動**的 `st-copy-url`（知識庫網址
  複製鈕）在同一自動化瀏覽器環境下點擊也是同樣結果，確認是這套瀏覽器自動化工具本身的
  clipboard-write 權限限制（非真人操作，`navigator.clipboard.writeText` 需要真使用者手勢），
  不是本次新增程式碼的迴歸——兩顆鈕行為一致。
  **未測**：真實自架 workers.dev 網址（未取得任何測試帳密，且紅線禁碰 youlin/uncle6）；
  真人用滑鼠點擊複製鈕（受限於自動化環境，上述已用既有鈕做過同構對照）。
  執行範圍：`console-ui/public/portal/index.html`（新增面板 + JS）。未動後端、未部署。

- [x] **Arcrun#108（任務層修正，2026-08-13）：資料面租戶字串收斂到唯一產地**
  P3 的 `/portal/data/*` 一律用 `portalTenant(env) = env.CONSOLE_TENANT || 'leo'` 注 `owner_id`。
  那個字串是**部署環境變數**，而知識是 CLI／同步小幫手／MCP 用**實例 namespace**
  （`~/.arcrun/config.yaml` 的 `api_key`）寫進去的——兩個來源會漂。leo 實撞：
  藏書地圖回 0 個庫，同一分鐘 KBDB 裡有 1854 條三元組（他的在 `owner_id=bfezv28v`）。
  與 `Arcrun#105`（`env.MCP_OWNER_NAMESPACE || "leo"`）同形，低一層。
  **修法**：新增 `cypher-executor/src/lib/tenant.ts` 當唯一產地——
  `knowledgeOwner(env)` 回 branded `TenantId`（`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
  誠實丟錯，**無字面預設值**），資料面過濾一律經 `ownerQuery()/ownerField()`；
  帳號子 namespace（design D-2 的 `{tenant}::portal`）改用 `accountTenant(env)`（回 `string`，
  型別上不可能流進資料面），**帳號落點一字不動**（動了舊實例登不進去）。
  `acr update` 先驗（`GET /kbdb/map?owner_id=<api_key>` 查得到庫）才注入 `ARCRUN_NAMESPACE`。
  空地圖改回四態（`no_library_grant`／`filtered_out`／`scope_mismatch`／`confirmed_empty`），
  沿 Arcrun#100「讀不到就說讀不到」。庫權限過濾一字未動（回歸測試釘住）。
  防複發：`scripts/build-worker-artifacts.mjs` 出貨前掃描，違規編不出成品。
  規範寫入 `.claude/rules/02-forbidden.md` 第六類、`system-dev/wiki/mistakes.md` #26。

## 第二波（不在本 SDD 動工範圍，掛號）

- MCP token 綁庫集合（design §9；PR#15 擴充，只動 `mcp/`）
- graph 逐節點/逐邊細粒度過濾
- Google OAuth2 fast-follow、審計 log、忘記密碼 email 自助、舊資料 library 回填清潔工
