藏書地圖看得到自己的知識——租戶字串改從「寫入端」來,不再拿環境變數預設值(Arcrun#108) #112

Merged
Leo merged 2 commits from fix/library-map-108 into main 2026-08-12 16:24:45 +00:00
Owner

Arcrun#108。leo 的 1854 條三元組在 owner_id=bfezv28v,cypher 拿 repo toml 帶的官方預設值 CONSOLE_TENANT = "leo" 去過濾 ⇒ 藏書地圖回 0 個庫。


① 租戶字串改成從哪裡來、為什麼

答:從「寫入這批知識的那一方」來——也就是實例 namespace(使用者 ~/.arcrun/config.yamlapi_key)——而不是從一份手抄的環境變數預設值來。

為什麼不是「掛到登入者身上」

票上問要不要收斂到 #105 的答案。我的判斷是收斂到同一條原則,但不是同一個載體,理由:

在這個架構裡租戶不是每人一個。Portal 帳號共用同一台實例的知識庫(design D-2:帳號自己住 {tenant}::portal 子 namespace),帳號之間的差別是 libraries 權限,不是 owner_id。把 owner_id 複製一份到每個帳號上,只會多一個可以各自過期的副本——那正是本票的病本身,不是解藥。

#105 真正的教訓不是「一律搬到帳號上」,而是三句話:

過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、而且要能被機械驗證。

#105 的權威來源是登入者(因為 MCP 那條路的身分本來就在登入時拿到了);#108 的權威來源是寫入端用的那個 namespace(因為知識是 CLI/同步小幫手/MCP 寫進去的)。兩張票收斂的是這條原則,落點不同是因為「誰擁有這個事實」不同。

實際怎麼做

1. 唯一產地:cypher-executor/src/lib/tenant.ts

export type TenantId = string & { readonly __tenantId: unique symbol };

// 知識資料面:ARCRUN_NAMESPACE → CONSOLE_TENANT → 丟錯。沒有字面預設值。
export function knowledgeOwner(env: Bindings): TenantId
// 請求自帶的租戶(X-Arcrun-API-Key)
export function tenantFromApiKey(apiKey: string): TenantId
// 帳號層:回 string,**刻意不是 TenantId** → 型別上不可能流進知識資料面
export function accountTenant(env: Bindings): string
// 資料面過濾的唯一入口(只吃 TenantId)
export function ownerQuery(t: TenantId): string      // "owner_id=..."
export function ownerField(t: TenantId): string

|| 'leo' 從解析路徑上整個拿掉:那個預設值就是把「這台機器沒設定」偽裝成「你沒有資料」的元凶。解析不到 → TenantUnresolvedError → 誠實回「讀不到」(#100 同一條)。

2. 值的正解由 CLI 從真相源導出,而且先驗再寫

acr update 把 config 的 api_key 注入成 cypher 的 ARCRUN_NAMESPACE。但只在確認那個 namespace 底下真的查得到知識時才寫GET /kbdb/map?owner_id=<api_key>,查得到庫 → 寫;查不到/問不到 → 一個字都不動,既有值原封保留)。

為什麼要先驗:一鍵安裝的實例,知識可能本來就寫在 CONSOLE_TENANT 底下。無條件蓋成本機 api_key 會把一台原本正常的實例指向空的那一格——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。

沒注入時回退 CONSOLE_TENANT對官方 prod 與尚未更新的實例,這次改動是惰性的(行為一字不變)。

CONSOLE_TENANT 本身一個字都沒動——它同時是帳號子 namespace 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。

3. 空地圖分四態(沿 #100「讀不到就說讀不到」)

no_library_grantfiltered_outscope_mismatchconfirmed_empty
scope_mismatch 這一格以前不存在,所以「設定錯誤」被畫成「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。

4. 同族一起修(下面那道閘一次抓到的)

console-dashboard.ts 4 處、console-auth.ts 1 處是同一句話——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。


② 我留的那道閘是什麼、怎麼證明它會擋

「靜態租戶字串不得用於資料面過濾」,三條規則,全部盯行為不盯字詞:

規則 盯的行為
T1 租戶環境變數(CONSOLE_TENANTARCRUN_NAMESPACE)只能在 src/lib/tenant.ts 讀取 你在把部署設定讀成身分
T2 as TenantId 只能在 tenant.ts,且不得套在字面字串上 繞過唯一產地自己造一個租戶
T3 同一行既在組 owner_id、值又來自 portalTenant()accountTenant() 拿帳號層字串過濾知識資料面(=#108 那一行)

外加型別閘ownerQuery()/ownerField() 只吃 TenantId,而 TenantId 只能由 tenant.ts 產出 ⇒ 想把隨手一個 string 丟進去,tsc 當場不給過。

會擋,不是只提醒

擋在出貨路徑上:scripts/build-worker-artifacts.mjs.worker-builds/ 的產地)編成品前先掃,違規 → 建置中止。違規的碼編不出成品、出不了貨,不是「有人記得跑檢查才會發現」。

實跑(把 #108 那一行放回 portal-data.ts 再編):

✔ node_modules 檢查通過:
   cypher-executor  (pnpm (frozen))
   ...
❌ 建置中止:cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法(Arcrun#108 的閘):

   [T1] src/routes/portal-data.ts:958  const demoTenant = c.env.CONSOLE_TENANT || "leo";
         → 租戶環境變數只能在 src/lib/tenant.ts 讀取。在別處讀它=又一次「身分來自環境變數」
           (#105/#108 同形),請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。

知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField()
(cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地)。
本機自查:cd cypher-executor && npm run check:tenant

還原後同一支指令:

✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts
✔ arcrun-cypher-executor  js=575KB  sha256=e0026a23792f  source=b223a698

逐行檢查(--stdin 模式,給日後接 PreToolUse hook 用)餵 #108 原句:

[T1] src/routes/portal-data.ts(新寫入的第 2 行):return env.CONSOLE_TENANT || 'leo';
  → 租戶環境變數只能在 src/lib/tenant.ts 讀取。…
[T3] src/routes/portal-data.ts(新寫入的第 4 行):const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
  → 這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——正是 #108 那一行(1854 條三元組被過濾成 0)。…
exit=1

判準看「有沒有在做那件事」

owner_id 這個詞出現在註解、JSDoc、型別宣告、property 讀取(x.owner_id)、帳號層過濾(owner_id: ns)、請求自帶租戶(owner_id: apiKey)都不擋——tests/tenant-gate.test.ts 有 11 條專門的零誤攔案例。誤攔比漏攔更容易殺死一道閘(被擋煩了就有人把它關掉)。

閘自己能被測試

規則本體是純函式、零 node 相依(cypher-executor/scripts/tenant-source-rules.mjs),走檔案系統的那半分開(check-tenant-source.mjs)。tests/tenant-gate.test.ts 直接餵字串驗 5 種壞例子會擋、11 種合法寫法零誤攔,另外用 ?raw真原始碼驗現況乾淨。掃描範圍只有 cypher-executor/src/,測試與 fixture 都在範圍外 ⇒ 這道閘擋不到自己

✓ tests/tenant-gate.test.ts (24 tests) 97ms

⚠️ 誠實標記:原本想一併接進 .claude/hooks/pre-write-guard.sh(寫入前就擋),但那是受保護檔案、我改不動。檢查器已備妥 --stdin <相對路徑> 模式,要接只需在 hook 加一段(規範與現成片段寫在 .claude/rules/02-forbidden.md 第六類)。目前的閘是「出貨閘 + 型別閘 + npm test 前置」,已經擋得住出貨。


③ 部署到 leo21c 時要注意什麼(給總管拿去跟 leo 要部署閘)

1. 光部署新版還不夠——ARCRUN_NAMESPACE 必須真的進到 cypher worker 的 [vars]
沒有它就回退 CONSOLE_TENANT,地圖還是空的(只是這次會誠實告訴你「命名空間對不上」而不是「你沒有資料」)。

  • acr update:會自動驗 + 注入(leo 的 config api_key 應為 bfezv28v;先驗 GET /kbdb/map?owner_id=bfezv28v 回得到 9 個庫才寫)。
  • wrangler 直推單顆 cypher:要自己帶 --var ARCRUN_NAMESPACE:bfezv28v(wrangler deploy 是整份覆蓋,toml 沒寫的 var 會消失——#106 那個坑)。

2. 部署前先跑這條核對,貼輸出:

curl -s "https://<cypher>/kbdb/map?owner_id=bfezv28v" -H "X-Arcrun-API-Key: bfezv28v" | jq '.count'

回 9 → 注入 bfezv28v 是安全的。回 0 → 先不要注入,回來查(可能 08-11 之後又搬過)。

3. 帳號那半不要動。 CONSOLE_TENANT 維持現值——它是帳號子 namespace({tenant}::portal)的組成,改了 leo 會登不進 Portal。本 PR 沒有動它,部署時也不要順手改。

4. 部署後的驗收(票上那四條):

  • kbdb_get_map(AR-Mira)→ kb 1851/合計 1854
  • 打開 Portal「總圖」頁面看畫面(/portal/data/graph/overview 這條也改用同一個命名空間了,之前一樣是空的)
  • 換一個只授權部分庫的帳號 → 只看得到那幾個庫
  • kbdb_search / kbdb_query 回歸

5. 這次改動對「沒注入 ARCRUN_NAMESPACE」的實例是惰性的(官方 prod、其他 self-hosted 未更新者行為一字不變),所以先部 leo21c、其他實例慢慢跟是安全的。


沒動

庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、CONSOLE_TENANT、任何金鑰、租戶字串仍不下發前端。leo 的資料沒有被改名回 leo


驗證

cypher-executor   vitest  441 綠 / 14 紅
                  14 紅與 base commit e05518a 逐條相同(既有:auth-dispatcher 5、
                  console-library-map-page 6、executor 1、portal-admin 1、portal-data 1)
                  base:14 failed | 402 passed (416)
                  本次:14 failed | 441 passed (455)   ← +39 全是本次新增測試
                  tsc  5 個既有錯誤,零新增(改前改後逐字相同)

cli               node:test  60/60 綠(含本次新增 12 條)
                  tsc  零錯誤

閘                壞例子 exit 1 ✓/build 建置中止 ✓/乾淨時通過 ✓(輸出見上)

成品              .worker-builds 重編,repo_dirty=false、repo_head=b223a698
                  arcrun-cypher-executor sha256=e0026a23792f
                  bundle 內含 ARCRUN_NAMESPACE ×3、TenantUnresolvedError ×5、scope_mismatch ×3

◐ 未驗(誠實標記):端到端——kbdb_get_map 回 1851、Portal 總圖畫面、部分授權帳號實測,全都需要部署到 leo21c,而那道閘要 leo 親手解(紅線)。上面 ③ 就是為了讓那一趟一次過。

新增測試涵蓋:knowledgeOwner 解析三態、/portal/data/map 真的用注入的 namespace 查詢(bfezv28v 不是 leo)、庫權限只看得到授權的庫、空地圖四態、回應不含租戶字串、CLI 先驗再寫的三態 + 不覆蓋既有值。

修 `Arcrun#108`。leo 的 1854 條三元組在 `owner_id=bfezv28v`,cypher 拿 repo toml 帶的官方預設值 `CONSOLE_TENANT = "leo"` 去過濾 ⇒ 藏書地圖回 0 個庫。 --- ## ① 租戶字串改成從哪裡來、為什麼 **答:從「寫入這批知識的那一方」來——也就是實例 namespace(使用者 `~/.arcrun/config.yaml` 的 `api_key`)——而不是從一份手抄的環境變數預設值來。** ### 為什麼不是「掛到登入者身上」 票上問要不要收斂到 `#105` 的答案。我的判斷是**收斂到同一條原則,但不是同一個載體**,理由: 在這個架構裡租戶**不是每人一個**。Portal 帳號共用同一台實例的知識庫(design D-2:帳號自己住 `{tenant}::portal` 子 namespace),帳號之間的差別是 `libraries` 權限,**不是 `owner_id`**。把 `owner_id` 複製一份到每個帳號上,只會多一個可以各自過期的副本——那正是本票的病本身,不是解藥。 `#105` 真正的教訓不是「一律搬到帳號上」,而是三句話: > **過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、而且要能被機械驗證。** `#105` 的權威來源是登入者(因為 MCP 那條路的身分本來就在登入時拿到了);`#108` 的權威來源是**寫入端用的那個 namespace**(因為知識是 CLI/同步小幫手/MCP 寫進去的)。兩張票收斂的是這條原則,落點不同是因為「誰擁有這個事實」不同。 ### 實際怎麼做 **1. 唯一產地:`cypher-executor/src/lib/tenant.ts`** ```ts export type TenantId = string & { readonly __tenantId: unique symbol }; // 知識資料面:ARCRUN_NAMESPACE → CONSOLE_TENANT → 丟錯。沒有字面預設值。 export function knowledgeOwner(env: Bindings): TenantId // 請求自帶的租戶(X-Arcrun-API-Key) export function tenantFromApiKey(apiKey: string): TenantId // 帳號層:回 string,**刻意不是 TenantId** → 型別上不可能流進知識資料面 export function accountTenant(env: Bindings): string // 資料面過濾的唯一入口(只吃 TenantId) export function ownerQuery(t: TenantId): string // "owner_id=..." export function ownerField(t: TenantId): string ``` `|| 'leo'` 從解析路徑上整個拿掉:那個預設值就是把「這台機器沒設定」偽裝成「你沒有資料」的元凶。解析不到 → `TenantUnresolvedError` → 誠實回「讀不到」(`#100` 同一條)。 **2. 值的正解由 CLI 從真相源導出,而且先驗再寫** `acr update` 把 config 的 `api_key` 注入成 cypher 的 `ARCRUN_NAMESPACE`。但**只在確認那個 namespace 底下真的查得到知識時才寫**(`GET /kbdb/map?owner_id=<api_key>`,查得到庫 → 寫;查不到/問不到 → 一個字都不動,既有值原封保留)。 > 為什麼要先驗:一鍵安裝的實例,知識可能本來就寫在 `CONSOLE_TENANT` 底下。無條件蓋成本機 `api_key` 會把一台**原本正常**的實例指向空的那一格——那是 `#97`/`#106` 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。 沒注入時回退 `CONSOLE_TENANT` ⇒ **對官方 prod 與尚未更新的實例,這次改動是惰性的**(行為一字不變)。 `CONSOLE_TENANT` 本身**一個字都沒動**——它同時是帳號子 namespace 的組成,改它會讓舊實例登不進去。兩個不同的事實,兩個 var。 **3. 空地圖分四態(沿 `#100`「讀不到就說讀不到」)** `no_library_grant`/`filtered_out`/`scope_mismatch`/`confirmed_empty`。 `scope_mismatch` 這一格以前不存在,所以「設定錯誤」被畫成「你沒有資料」。回應仍**不含租戶字串**(design §3.3 紅線)。 **4. 同族一起修**(下面那道閘一次抓到的) `console-dashboard.ts` 4 處、`console-auth.ts` 1 處是同一句話——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。 --- ## ② 我留的那道閘是什麼、怎麼證明它會擋 **「靜態租戶字串不得用於資料面過濾」**,三條規則,全部盯**行為**不盯字詞: | | 規則 | 盯的行為 | |---|---|---| | T1 | 租戶環境變數(`CONSOLE_TENANT`/`ARCRUN_NAMESPACE`)只能在 `src/lib/tenant.ts` 讀取 | 你在把部署設定讀成身分 | | T2 | `as TenantId` 只能在 `tenant.ts`,且不得套在字面字串上 | 繞過唯一產地自己造一個租戶 | | T3 | 同一行既在組 `owner_id`、值又來自 `portalTenant()`/`accountTenant()` | 拿帳號層字串過濾知識資料面(=#108 那一行) | 外加**型別閘**:`ownerQuery()/ownerField()` 只吃 `TenantId`,而 `TenantId` 只能由 `tenant.ts` 產出 ⇒ 想把隨手一個 string 丟進去,`tsc` 當場不給過。 ### 會擋,不是只提醒 擋在**出貨路徑**上:`scripts/build-worker-artifacts.mjs`(`.worker-builds/` 的產地)編成品前先掃,違規 → 建置中止。**違規的碼編不出成品、出不了貨**,不是「有人記得跑檢查才會發現」。 實跑(把 `#108` 那一行放回 `portal-data.ts` 再編): ``` ✔ node_modules 檢查通過: cypher-executor (pnpm (frozen)) ... ❌ 建置中止:cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法(Arcrun#108 的閘): [T1] src/routes/portal-data.ts:958 const demoTenant = c.env.CONSOLE_TENANT || "leo"; → 租戶環境變數只能在 src/lib/tenant.ts 讀取。在別處讀它=又一次「身分來自環境變數」 (#105/#108 同形),請改呼叫 knowledgeOwner(env)(知識資料面)或 accountTenant(env)(帳號層)。 知識資料面請用 knowledgeOwner(env) + ownerQuery()/ownerField() (cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地)。 本機自查:cd cypher-executor && npm run check:tenant ``` 還原後同一支指令: ``` ✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts ✔ arcrun-cypher-executor js=575KB sha256=e0026a23792f source=b223a698 ``` 逐行檢查(`--stdin` 模式,給日後接 PreToolUse hook 用)餵 `#108` 原句: ``` [T1] src/routes/portal-data.ts(新寫入的第 2 行):return env.CONSOLE_TENANT || 'leo'; → 租戶環境變數只能在 src/lib/tenant.ts 讀取。… [T3] src/routes/portal-data.ts(新寫入的第 4 行):const res = await kbdbFetch(c.env, `/map?owner_id=${encodeURIComponent(portalTenant(c.env))}`); → 這一行拿**帳號層**租戶字串去組知識資料面的 owner_id 過濾——正是 #108 那一行(1854 條三元組被過濾成 0)。… exit=1 ``` ### 判準看「有沒有在做那件事」 `owner_id` 這個詞出現在註解、JSDoc、型別宣告、property 讀取(`x.owner_id`)、帳號層過濾(`owner_id: ns`)、請求自帶租戶(`owner_id: apiKey`)都**不擋**——`tests/tenant-gate.test.ts` 有 11 條專門的零誤攔案例。誤攔比漏攔更容易殺死一道閘(被擋煩了就有人把它關掉)。 ### 閘自己能被測試 規則本體是**純函式**、零 node 相依(`cypher-executor/scripts/tenant-source-rules.mjs`),走檔案系統的那半分開(`check-tenant-source.mjs`)。`tests/tenant-gate.test.ts` 直接餵字串驗 5 種壞例子會擋、11 種合法寫法零誤攔,另外用 `?raw` 讀**真原始碼**驗現況乾淨。掃描範圍只有 `cypher-executor/src/`,測試與 fixture 都在範圍外 ⇒ **這道閘擋不到自己**。 ``` ✓ tests/tenant-gate.test.ts (24 tests) 97ms ``` > ⚠️ 誠實標記:原本想一併接進 `.claude/hooks/pre-write-guard.sh`(寫入前就擋),但那是受保護檔案、我改不動。**檢查器已備妥 `--stdin <相對路徑>` 模式**,要接只需在 hook 加一段(規範與現成片段寫在 `.claude/rules/02-forbidden.md` 第六類)。目前的閘是「出貨閘 + 型別閘 + `npm test` 前置」,已經擋得住出貨。 --- ## ③ 部署到 leo21c 時要注意什麼(給總管拿去跟 leo 要部署閘) **1. 光部署新版還不夠——`ARCRUN_NAMESPACE` 必須真的進到 cypher worker 的 `[vars]`。** 沒有它就回退 `CONSOLE_TENANT`,地圖還是空的(只是這次會誠實告訴你「命名空間對不上」而不是「你沒有資料」)。 - 走 `acr update`:會自動驗 + 注入(leo 的 config `api_key` 應為 `bfezv28v`;先驗 `GET /kbdb/map?owner_id=bfezv28v` 回得到 9 個庫才寫)。 - 走 `wrangler` 直推單顆 cypher:**要自己帶** `--var ARCRUN_NAMESPACE:bfezv28v`(wrangler deploy 是整份覆蓋,toml 沒寫的 var 會消失——`#106` 那個坑)。 **2. 部署前先跑這條核對,貼輸出:** ``` curl -s "https://<cypher>/kbdb/map?owner_id=bfezv28v" -H "X-Arcrun-API-Key: bfezv28v" | jq '.count' ``` 回 9 → 注入 `bfezv28v` 是安全的。回 0 → **先不要注入**,回來查(可能 08-11 之後又搬過)。 **3. 帳號那半不要動。** `CONSOLE_TENANT` 維持現值——它是帳號子 namespace(`{tenant}::portal`)的組成,改了 leo 會登不進 Portal。本 PR 沒有動它,部署時也不要順手改。 **4. 部署後的驗收(票上那四條):** - `kbdb_get_map`(AR-Mira)→ `kb` 1851/合計 1854 - 打開 Portal「總圖」頁面看畫面(`/portal/data/graph/overview` 這條也改用同一個命名空間了,之前一樣是空的) - 換一個只授權部分庫的帳號 → 只看得到那幾個庫 - `kbdb_search` / `kbdb_query` 回歸 **5. 這次改動對「沒注入 `ARCRUN_NAMESPACE`」的實例是惰性的**(官方 prod、其他 self-hosted 未更新者行為一字不變),所以先部 leo21c、其他實例慢慢跟是安全的。 --- ## 沒動 庫權限過濾(**一字未改**,回歸測試釘住)、帳號資料落點、`CONSOLE_TENANT`、任何金鑰、租戶字串仍不下發前端。leo 的資料**沒有**被改名回 `leo`。 --- ## 驗證 ``` cypher-executor vitest 441 綠 / 14 紅 14 紅與 base commit e05518a 逐條相同(既有:auth-dispatcher 5、 console-library-map-page 6、executor 1、portal-admin 1、portal-data 1) base:14 failed | 402 passed (416) 本次:14 failed | 441 passed (455) ← +39 全是本次新增測試 tsc 5 個既有錯誤,零新增(改前改後逐字相同) cli node:test 60/60 綠(含本次新增 12 條) tsc 零錯誤 閘 壞例子 exit 1 ✓/build 建置中止 ✓/乾淨時通過 ✓(輸出見上) 成品 .worker-builds 重編,repo_dirty=false、repo_head=b223a698 arcrun-cypher-executor sha256=e0026a23792f bundle 內含 ARCRUN_NAMESPACE ×3、TenantUnresolvedError ×5、scope_mismatch ×3 ``` **◐ 未驗(誠實標記)**:端到端——`kbdb_get_map` 回 1851、Portal 總圖畫面、部分授權帳號實測,全都需要部署到 leo21c,而那道閘要 leo 親手解(紅線)。上面 ③ 就是為了讓那一趟一次過。 新增測試涵蓋:`knowledgeOwner` 解析三態、`/portal/data/map` 真的用注入的 namespace 查詢(`bfezv28v` 不是 `leo`)、庫權限只看得到授權的庫、空地圖四態、回應不含租戶字串、CLI 先驗再寫的三態 + 不覆蓋既有值。
Leo added 2 commits 2026-08-12 16:19:10 +00:00
leo 2026-08-12 實撞:藏書地圖回 0 個庫,同一分鐘 KBDB 裡有 1854 條三元組,
`arcrun_whoami` 顯示 admin/全部知識庫、`kbdb_search` 也查得到——只有地圖那格是空的。

病根(不是資料掉了,是讀寫兩端各拿一個來源):
  寫入端 owner_id = `~/.arcrun/config.yaml` 的 `api_key`(CLI push/小幫手上傳/MCP,
    leo = `bfezv28v`)
  讀取端過濾 = `portalTenant(env) = env.CONSOLE_TENANT || "leo"`
    ——repo toml 帶的**官方 prod 值**,而 `acr` 從來不注入 CONSOLE_TENANT
  ⇒ 那個 `"leo"` 不是理論邊角,是每台 self-hosted 實例的實際行為,1854 條全被濾掉。

與 #105(`env.MCP_OWNER_NAMESPACE || "leo"`)同一句話,換一個檔案。

租戶字串該從哪裡來(本票的核心判斷):
  **從「寫入這批知識的那一方」來,不是從一份手抄的環境變數預設值來。**
  不是「掛到每個帳號上」——portal 帳號共用同一台實例的知識庫(design D-2),
  帳號之間的差別是 libraries 權限不是 owner_id;複製一份到帳號上只是多一個會過期的副本。
  #105 真正的教訓是:過濾用的租戶字串要有單一權威來源、解析不到要誠實失敗、且要能機械驗證。

修法:
1. 唯一產地 `cypher-executor/src/lib/tenant.ts`
   - `knowledgeOwner(env)` → branded `TenantId`:`ARCRUN_NAMESPACE` → `CONSOLE_TENANT` →
     丟 `TenantUnresolvedError`。**沒有字面預設值**——`|| 'leo'` 正是把「這台機器沒設定」
     偽裝成「你沒有資料」的元凶。
   - `accountTenant(env)` → 普通 `string`(帳號子 namespace `{tenant}::portal` 與 cypher
     自己寫的設定用它)。**回 string 是刻意的**:型別上就不可能流進知識資料面。
   - 資料面過濾一律經 `ownerQuery()` / `ownerField()`,只吃 `TenantId`。
2. 值的正解由 CLI 從真相源導出:`acr update` 把 config 的 `api_key` 注入成 `ARCRUN_NAMESPACE`,
   但**先驗再寫**(`GET /kbdb/map?owner_id=<api_key>` 查得到庫才寫;查不到/問不到就一個字
   都不動)。無條件覆蓋會把「知識本來就在 CONSOLE_TENANT 底下」的一鍵安裝實例指向空的那一格
   ——那是 #97/#106 那類「更新一次把人家的東西弄不見」,比原本的 bug 更糟。
   未注入時回退 CONSOLE_TENANT ⇒ 對官方 prod 與未更新的實例,這次改動是惰性的。
3. 空地圖分四態(沿 #100「讀不到就說讀不到」):no_library_grant/filtered_out/
   scope_mismatch/confirmed_empty。scope_mismatch 以前不存在,所以設定錯誤被畫成
   「你沒有資料」。回應仍不含租戶字串(design §3.3 紅線)。
4. 同族一起修(同一道閘一次抓到):console-dashboard 4 處、console-auth 1 處
   ——console 首頁的規模數字與藏書地圖對 leo 也一直是空的。

留下的閘(規則存在但沒機制驗證=會再犯第三次):
  · 型別閘:TenantId 只能由 tenant.ts 產出 → 拿隨手一個 string 去過濾,tsc 當場不給過。
  · 出貨閘:scripts/build-worker-artifacts.mjs 編 tier2 成品前先掃,違規 → 編不出成品。
  · 閘自己可測:規則是純函式(tenant-source-rules.mjs),tests/tenant-gate.test.ts
    逐條驗「5 種壞例子會擋」+「11 種合法寫法零誤攔」;掃描範圍只有 src/,擋不到自己。
  規範寫入 .claude/rules/02-forbidden.md 第六類、system-dev/wiki/mistakes.md #26。

沒動:庫權限過濾(一字未改,回歸測試釘住)、帳號資料落點、任何金鑰、租戶字串仍不下發前端。

驗證:
  cypher   vitest 441 綠 / 14 紅,14 紅與 base commit e05518a 逐字相同(既有)
           tsc 5 個既有錯誤,零新增
  cli      node:test 60/60 綠(含本次新增 12 條);tsc 零錯誤
  閘       壞例子實跑 exit 1;build 實跑「建置中止」;乾淨時實跑通過
  端到端   ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR ③)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
原始碼修好不等於使用者拿得到:`.worker-builds/` 是安裝器與 `acr update`
實際部署的那份執行檔。本次一併重編,repo_dirty=false、repo_head=b223a698。

  arcrun-cypher-executor  sha256=e0026a23792f  source=b223a698 ← #108
  arcrun-kbdb             sha256=9c6d41895d78  source=f87d0e92(未變)
  arcrun-http-request     sha256=9a9dcb71879a  source=1e85dfb4(未變)
  arcrun-code             sha256=285a7406ec69  source=621cb8d9(未變)
  arcrun-mcp              sha256=3ebc0d441bc0  source=10d150ac(未變)

其餘四顆的 js 位元組有變是 esbuild 版本/相依重裝造成的重編,source commit 未動。
本次起這條路徑多一道閘:編成品前先跑租戶來源檢查,違規直接「建置中止」
(見同 PR 的 scripts/build-worker-artifacts.mjs)——實跑輸出貼在 PR。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Leo merged commit 2fcae722e7 into main 2026-08-12 16:24:45 +00:00
Sign in to join this conversation.