---
title: oscal 逐檔分類
status: 🟢 **完成**（2026-09-16，實查 HEAD `84b8426a`）。163 檔全數分類，無跳過。
notion:
  parent: {id: CM-1840, title: "FR-105 第 2 棒 oscal 逐檔分類——163 檔／19,996 行，對照第 1 棒標鏡像與重複實作（只分析不動程式）", url: "https://app.notion.com/p/FR-105-2-oscal-163-19-996-1-3dd346da4cd08178915addc8c204e82e"}
relates: [FR-102, FR-100, FR-080, FR-069, FR-038, FR-036, FR-032, FR-028]
---

# oscal 逐檔分類

**實查日期**：2026-09-16 ｜ **當時 HEAD**：`84b8426a` ｜ **branch**：`feature/review`

> 🔴 **本表是某一天的快照，引用前先重驗。** 每個數字下方都附了產生它的指令，重跑一次比相信這份表安全。

---

## 一句話結論

`oscal` **是主專案最大的一塊自有資產，不是套件的殼**：163 支檔案裡，
**11,494 行（57.5%）是主專案自己寫的業務**（Word／Excel 解析、兩份文件比對、
CMMC 格式轉換、匯入管線），**4,757 行（23.8%）是轉接殼**（把前端的欄位名翻成
`jedi_oscal_v2` 套件的形狀、再交給套件存），其餘 3,619 行（18.1%）是序列化器、
DTO、mapper 這類接線與型別。

真業務是殼的 **2.4 倍**——比 `module_frame` 那邊的 1.45 倍更懸殊。原因是這個模組
獨佔了整個「把客戶的既有文件吃進系統」那條線：`domain/oscal/parser/`（2,027 行）
與 `app/oscal/service/import_diff/`（1,384 行）合計 3,411 行**零套件引用**，
套件完全不參與。

殼的部分則高度集中在兩處：**框架維護四支**（`framework_*`，1,101 行，
主體是 v1 形狀 ↔ v2 形狀的來回翻譯）與 **SSP 子物件五支**（`ssp_party` ／`components`
／`inventory_items`／`leveraged`／`system_characteristic`，714 行）。

---

## ① 現況盤點

### 四層規模

| 目錄 | 檔數 | 行數 |
|---|---:|---:|
| `api/oscal` | 67 | 3,984 |
| `app/oscal` | 46 | 10,964 |
| `domain/oscal` | 36 | 4,549 |
| `infra/oscal` | 14 | 499 |
| **合計** | **163** | **19,996** |

另有 `di_containers/oscal/oscal_containers.py`（509 行，不在四層內，判 DI 註冊時要看）。

<details>
<summary>怎麼重跑</summary>

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git rev-parse --short HEAD
for L in api app domain infra; do
  d=$L/oscal
  printf '%-22s %s 檔 / %s 行\n' $d \
    "$(git ls-files $d | grep '\.py$' | wc -l | tr -d ' ')" \
    "$(git ls-files $d | grep '\.py$' | xargs wc -l | tail -1 | awk '{print $1}')"
done
```

用 `git ls-files` 不用 `find`——`find` 會撈到 `__pycache__` 與未入版控的殘留。
行數一律 `wc -l`（數換行符），與 FR-102 總表 A 的 Python 數法在本模組**恰好同值**
（19,996），`module_frame` 那邊則差 9 行。

</details>

### 對外 route：61 條

用 AST 數（不是 regex——`add_resource(` 常換行寫，regex 會漏抓）。
61 條全部在 `api/oscal/__init__.py` 統一註冊，掛 blueprint `oscal`、前綴 `/api/1.0`。
注意：**所有 route 註冊都寫在 `__init__.py`，各 route 檔本身只定義 Resource 類別**。

另有 **3 條被註解掉不計入**（`grep -rn "^\s*#.*add_resource" api/oscal/`）：

| 被註解的 route | 註解說明 |
|---|---|
| `OscalExportRoute` → `/oscal/export/<doc_type>/<uid>` | 無停用註解，連 import 那行一起被註解（`__init__.py:88-89`） |
| `SspScopedExcelImportRoute` → `/ssp/<ssp_uid>/excel-import/<parse_uid>` | `[FR-038 DEAD-V1｜2026-06-18 停用待清理]` scoped excel 只用 upload，預覽走非 scoped 端點 |
| `SspScopedExcelImportConfirmRoute` → `…/confirm` | 同上 |

### 前端四層查的第三層先講清楚

`ui_routes` 全庫只有 **1 列**與本模組相關——
`id=33, name='compliance-framework-manage', url='/compliance-framework/compliance-framework-manage', enable=1`。
它是**選單項不是 API**，管的是「左側選單看不看得到合規框架管理」這一件事；
61 條 API route 不會逐條出現在 `ui_routes` 裡。SSP 那批（`/ssp/*`，佔 61 條的 40 條）
更是**完全不經選單**——它們掛在專案頁面內的分頁裡（`/project/projects/:id/...`），
由專案選單進入。因此下表不設第三層欄位，在此統一交代。

```bash
PGPASSWORD=<查 .env> psql -h localhost -p 5432 -U cmmgr -d guidant_ai_dev \
  -c "SELECT id, name, url, enable FROM public.ui_routes
      WHERE name ILIKE '%framework%' OR name ILIKE '%ssp%' OR name ILIKE '%oscal%';"
```

### route 清單與前端查證結果

四層＝① 前端 API 常數表 `src/config/api/api.js` ② 前端實際呼叫（`.vue`／`service/*.js`）
③ DB `public.ui_routes`（見上段）④ 非瀏覽器呼叫者（`scripts/`、e2e）。

#### 框架維護（13 條）

| # | route | 吃哪支 service | ①常數 | ②呼叫 | ④非瀏覽器 | 判定 |
|---|---|---|---|---|---|---|
| 1-2 | `POST /oscal-frameworks`、`GET /oscal-frameworks/menu` | `FrameworkAppService` | `OSCAL_FRAMEWORKS(_MENU)` | ✅ | — | **活** |
| 3-4 | `POST /oscal-framework`、`GET/PUT/DELETE /oscal-framework/<uid>` | 同上 | `OSCAL_FRAMEWORK` | ✅ | — | **活** |
| 5 | `POST /oscal-framework-versions` | `FrameworkVersionAppService` | `OSCAL_FRAMEWORK_VERSIONS` | ✅ | e2e `compliance-framework-import-version.steps.js:171` | **活** |
| 6-7 | `GET /oscal-framework-version/download/oscal/<uid>[/token]` | 同上 | `OSCAL_FRAMEWORK_VERSION_DOWNLOAD` | ✅ `signedDownloadUtil.js:73-78` | — | **活**（簽章下載兩段式） |
| 8 | `GET/PUT/DELETE /oscal-framework-version/<uid>`、`POST /oscal-framework-version` | 同上 | `OSCAL_FRAMEWORK_VERSION` | ✅ | — | **活** |
| 9 | `GET /oscal-framework-version/<uid>/catalog-tree` | `FrameworkVersionEditService` | `…CATALOG_TREE` | ✅ 9 處 | — | **活** |
| 10-12 | `PUT/DELETE /oscal-catalog-group\|control\|control-assessment/<uid>` | 同上 | `OSCAL_CATALOG_*` 三個 | ✅ `FrameworkVersionEditService.js` | — | **活** |
| 13-16 | `/oscal-framework-parse-jobs`、`/parse`、`/<uid>`、`/<uid>/confirm` | `FrameworkParseJobService` | `OSCAL_FRAMEWORK_PARSE_JOBS` | ✅ 5 處 | e2e `compliance-framework-import-version.steps.js:50,155,164` | **活** |

#### 資源庫（4 條）

| # | route | ①常數 | ②呼叫 | ④非瀏覽器 | 判定 |
|---|---|---|---|---|---|
| 17 | `POST /oscal/resource-libraries/list` | `RESOURCE_LIBRARIES_LIST` | ✅ `ModuleFrame.vue:453` | e2e `module-frame.steps.js:70` | **活** |
| 18 | `POST /oscal/resource-libraries` | `RESOURCE_LIBRARY_CREATE` | ✅ `ModuleFrame.vue:773`、`SspDocxImportPage.vue:718` | e2e `resource-library-factory.js:53` | **活** |
| 19 | `GET /oscal/resource-library/<uid>` | `RESOURCE_LIBRARY`（有定義） | ❌ **全前端零呼叫** | ❌ | **疑似死** — 見 ④ 4.2 |
| 20 | `POST /oscal/resource-library/<uid>/publish` | 同上（同一常數） | ❌ **全前端零呼叫** | ❌ | **疑似死** — 見 ④ 4.2 |

#### SSP 匯入（8 條）

| # | route | 吃哪支 service | ①常數 | ②呼叫 | ④非瀏覽器 | 判定 |
|---|---|---|---|---|---|---|
| 21-23 | `/ssp-excel-imports/parse`、`/ssp-excel-import/<uid>[/confirm]` | `SspExcelImportAppService` | `SSP_EXCEL_IMPORT(_PARSE)` | ✅ | e2e `module-frame-import.steps.js:242,269,286` | **活** |
| 24-26 | `/ssp-docx-imports/parse`、`/ssp-docx-import/<uid>[/confirm]` | `SspDocxImportAppService` | `SSP_DOCX_IMPORT(_PARSE)` | ✅ | `scripts/smoke_test_ssp_docx_parser.py`、`scripts/e2e_test_module_frame_with_docx.py`、e2e `project-ssp-import-docx.steps.js:58` | **活** |
| 27 | `POST /ssp/<ssp_uid>/excel-import/upload` | 同 Excel | `SSP_EXCEL_IMPORT_SSP` | ✅ `SspExcelImportService.js:56` | — | **活** |

#### SSP 子物件維護（14 條）

| # | route | 吃哪支 service | ①常數 | ②呼叫 | ④非瀏覽器 | 判定 |
|---|---|---|---|---|---|---|
| 28 | `GET/PUT /ssp/<uid>/system-characteristic` | `SspSystemCharacteristicAppService` | `SSP_SYSTEM_CHARACTERISTIC` | ✅ 9 處 | e2e `project-ssp-basic.steps.js:81,90` | **活** |
| 29-30 | `/ssp/<uid>/components[/<item_uid>]` | `SspComponentsAppService` | `SSP_COMPONENTS` | ✅ | e2e `ssp-helpers.js:54` | **活** |
| 31-32 | `/ssp/<uid>/inventory-items[/<item_uid>]` | `SspInventoryItemsAppService` | `SSP_INVENTORY_ITEMS` | ✅ | e2e `ssp-helpers.js:56` | **活** |
| 33-34 | `/ssp/<uid>/leveraged[/<item_uid>]` | `SspLeveragedAppService` | `SSP_LEVERAGED(_ITEM)` | ✅ 20 處 | e2e `ssp-helpers.js:55` | **活** |
| 35-36 | `/ssp/<uid>/parties[/<party_uid>]` | `SspPartyAppService` | `SSP_PARTIES`／`SSP_PARTY` | ✅ 21 處 | e2e `ssp-helpers.js:53` | **活** |
| 37-39 | `/ssp/<uid>/ssp-resources`、`/items[/<item_uid>]` | `SspResourcesAppService` → `SspResourcesContextService` | `SSP_RESOURCES`／`SSP_RESOURCE_ITEM(S)` | ✅ 22 處 | — | **活** |

#### SSP 程序書池與控制項實作（16 條）

| # | route | 吃哪支 service | ①常數 | ②呼叫 | ④非瀏覽器 | 判定 |
|---|---|---|---|---|---|---|
| 40-41 | `/ssp/<uid>/document-pool[/<doc_uid>]` | `SspDocumentPoolService` | `SSP_DOCUMENT_POOL` | ✅ 12 處 | — | **活** |
| 42-45 | control／objective 各一組 `document-mapping(s)` 四條 | 同上 | `SSP_DOCUMENT_MAPPING(S)`／`SSP_OBJECTIVE_MAPPING(S)` | ✅ | — | **活** |
| 46 | `GET /ssp/<uid>/control-tree` | `SspControlImplementationService` | `SSP_CONTROL_TREE` | ✅ 22 處 | e2e `agent-task-management.steps.js:49`、`my-tasks-detection.steps.js:35`、`project-task-agent-dispatch.steps.js:37` | **活** |
| 47 | `GET /ssp/<uid>/control-implementations` | 同上 | `SSP_CONTROL_IMPLEMENTATIONS` | ✅ `SspService.js:11` | — | **活** |
| 48 | `GET/PUT /ssp/<uid>/control-implementation/<ci>` | 同上 | `SSP_CONTROL_IMPLEMENTATION` | ✅ | — | **活** |
| 49 | `PUT …/objective/<si>` | 同上 | `SSP_OBJECTIVE` | ✅ | — | **活** |
| 50 | `DELETE …/objective/<si>/document/<file_id>` | 同上 | `SSP_OBJECTIVE_DOCUMENT` | ✅ | — | **活** |
| 51-54 | control／objective 各一組 `reference-document(s)` 四條 | 同上 | `SSP_REFERENCE_DOCUMENT(S)` | ✅ | — | **活** |
| 55-58 | `/ssp/<uid>/control-implementations/export`、`/import`、`/import/validate`、`/import/confirm` | `SspControlImplImportService` | `SSP_EXPORT`／`SSP_IMPORT(_VALIDATE/_CONFIRM)` | ✅ `SspService.js:90-94` 等 6 處 | — | **活** |

#### 匯出與範本 SSP（3 條）

| # | route | 吃哪支 service | ①常數 | ②呼叫 | ④非瀏覽器 | 判定 |
|---|---|---|---|---|---|---|
| 59 | `GET /ssp/<uid>/export` | `SspExportAppService` | `SSP_DOCUMENT_EXPORT` | ✅ `ProjectPlanningView.vue:2238` | e2e `planning-batch-ops.steps.js:211` | **活** |
| 60 | `GET /module-frame/<mf_uid>/template-ssp` | `ResourceLibraryAppService` | 拼 `MODULE_FRAME` | ✅ `ModuleFrameTemplateService.js:23` | — | **活** |
| 61 | `GET /oscal/catalog-controls/<uid>/assessments`（**BE 無此 route**） | — | `CATALOG_CONTROL_ASSESSMENTS` | ⚠️ 前端有打（`ModuleFrameTemplateService.js:198`） | — | **前端打空氣** — 見 ⑤ 5.4 |

> 第 61 列不是 BE 的 route（AST 只數到 60 條掛在 oscal blueprint 上、加上第 59 的
> `/ssp/<uid>/export` 共 61）。列在此是因為前端確實會打這個路徑，而 BE 全 repo 沒有
> 任何地方註冊它——屬於**前端打得到但後端不存在**的缺口，詳見 ⑤。

### 這模組自己持有的表

`compliance.framework_parse_jobs`、`compliance.ssp_docx_parse_jobs`、
`compliance.ssp_excel_parse_jobs` 三張**匯入工作表**，ORM 在 `infra/oscal/model/`。
模組所有其他資料——框架、版本、catalog、profile、SSP 本體與所有子物件——
**都不在主專案的表**，而是由 `jedi_oscal_v2` 持有。這是本模組「殼」那一半的由來：
三張 job 表記的是「這份檔案解析到哪、使用者選了什麼」，解析完寫進套件就結束。

---

## ② 逐檔分類表

### 四類的判準（依賴方向，不是檔名）

- **真業務**：主要邏輯自己寫，`jedi_*` 引用少或零；刪掉這支功能就消失。
- **轉接殼**：主體是「開 `@transaction` → 呼叫套件 service → 欄位對應」，商業規則在套件。
  **殼不等於可刪**——它是「主專案回答套件問題」的必要接線。要另外標「有沒有帶主專案獨有規則」。
- **共用設施**：沒有 route、沒有自己的業務語意，是給別的模組或別的檔用的（DTO、序列化器、
  error code、port、mapper、resolver）。
- **死碼**：全 repo 零 import、零 route 註冊、零 DI 註冊（四層都要查才算）。
- **還看不準**：允許存在，要寫卡在哪。本表 0 支。

---

### `api/oscal`（67 檔／3,984 行）

#### routes/ — 20 檔／2,314 行

| 檔 | 行 | 類別 | 依賴證據 | 殼帶主專案規則？ |
|---|---:|---|---|---|
| `__init__.py` | 207 | **共用設施** | blueprint 組裝＋**全模組 61 條 route 的唯一註冊處**；19 個 host import（全是自己模組的 Resource 類別），jedi=0。零 DB、零業務 | — |
| `routes/ssp/ssp_control_implementation_route.py` | 309 | **轉接殼** | 10 個 Resource 類別（控制樹／IR CRUD／AO／程序書關聯）全轉呼叫 `SspControlImplementationService`；jedi=2、host=4 | ⚠️ 守門不在 route——全部落在 app service 的 `SspPermissionChecker`（資源域守門，需先 resolve ssp_uid 才知道判誰） |
| `routes/ssp/ssp_document_pool_route.py` | 183 | **轉接殼** | 6 個 Resource 轉呼叫 `SspDocumentPoolService`；jedi=2 | ⚠️ 同上 |
| `routes/framework/framework_parse_job_route.py` | 178 | **轉接殼** | 4 個 Resource 轉呼叫 `FrameworkParseJobService`；multipart 檔案接收在 route | ⚠️ 守門在 service（`require_platform_admin`） |
| `routes/framework/oscal_framework_version_route.py` | 157 | **轉接殼** | 轉呼叫 `FrameworkVersionAppService`＋一支簽章下載（`OscalExportAppService.export('catalog',…)` 回裸 OSCAL JSON） | ✅ **帶** — 唯一在 route 層 import `common.authz` 的檔；簽章下載 token 兩段式是主專案機制 |
| `routes/framework/framework_version_edit_route.py` | 132 | **轉接殼** | 4 個 Resource 轉呼叫 `FrameworkVersionEditService` | ⚠️ 守門在 service |
| `routes/ssp/ssp_scoped_excel_import_route.py` | 130 | **轉接殼** | 3 個 Resource，**其中 2 個已無 route 註冊**（`__init__.py:116-117` 被註解，標 `FR-038 DEAD-V1`） | ⚠️ 守門在 service |
| `routes/ssp/ssp_control_impl_import_route.py` | 114 | **轉接殼** | 4 個 Resource 轉呼叫 `SspControlImplImportService`；blob 回傳走 `send_file` | ⚠️ 守門在 service |
| `routes/ssp/ssp_excel_import_route.py` | 112 | **轉接殼** | 3 個 Resource 轉呼叫 `SspExcelImportAppService` | ⚠️ 守門在 service |
| `routes/ssp/ssp_docx_import_route.py` | 105 | **轉接殼** | 3 個 Resource 轉呼叫 `SspDocxImportAppService` | ⚠️ 守門在 service |
| `routes/framework/oscal_framework_route.py` | 104 | **轉接殼** | 4 個 Resource 轉呼叫 `FrameworkAppService`；marshmallow dump | ⚠️ 守門在 service |
| `routes/ssp/ssp_resources_route.py` | 88 | **轉接殼** | 3 個 Resource 轉呼叫 `SspResourcesAppService` | ⚠️ 守門在 service |
| `routes/ssp/ssp_components_route.py` | 85 | **轉接殼** | 2 個 Resource 轉呼叫 `SspComponentsAppService` | ⚠️ 守門在 service |
| `routes/resource_library_route.py` | 84 | **轉接殼** | 4 個 Resource 轉呼叫 `ResourceLibraryAppService`。**`ResourceLibraryRoute`（詳情）與 `ResourceLibraryPublishRoute`（發布）兩支已註冊但前端零呼叫**——見 ④ 4.2 | ✅ **帶** — `@require_platform_admin_route` 掛在 publish；建立端點有 12 行註解說明為何**刻意不掛**（建立只寫租戶私有庫，公版治理靠 publish 守住） |
| `routes/ssp/ssp_inventory_items_route.py` | 84 | **轉接殼** | 2 個 Resource 轉呼叫 `SspInventoryItemsAppService` | ⚠️ 守門在 service |
| `routes/ssp/ssp_party_route.py` | 84 | **轉接殼** | 2 個 Resource 轉呼叫 `SspPartyAppService`＋marshmallow serializer | ⚠️ 守門在 service |
| `routes/ssp/ssp_leveraged_route.py` | 78 | **轉接殼** | 2 個 Resource 轉呼叫 `SspLeveragedAppService` | ⚠️ 守門在 service |
| `routes/ssp/ssp_export_route.py` | 72 | **轉接殼** | 1 個 Resource 轉呼叫 `SspExportAppService`；blob 回傳 | ✅ 帶 — RFC 5987 中文檔名編碼（與 `module_frame` 的 `mf_ssp_export_route` 同法） |
| `routes/ssp/ssp_system_characteristic_route.py` | 51 | **轉接殼** | 1 個 Resource 轉呼叫 `SspSystemCharacteristicAppService` | ⚠️ 守門在 service |
| `routes/oscal_export_route.py` | 39 | 🔴 **死碼** | 定義 `OscalExportRoute`。**四層全零**：route 註冊被註解（`__init__.py:89`）、模組內 import 那行也一起被註解（`:88`）、模組外零 import、不進 DI。詳見 ④ 4.1 | — |
| `routes/__init__.py`／`routes/framework/__init__.py`／`routes/ssp/__init__.py` | 0×3 | **共用設施** | 空 | — |

> **這模組的守門位置與 `module_frame` 相反，是刻意的。** `module_frame` 的 route 幾乎每支
> 都掛 `@require_capability(...)`（主體域守門：只看「你是誰」）；`oscal` 的 SSP 那批
> route 一個也沒掛——因為它們是**資源域守門**：要先把 `ssp_uid` 反查成專案、才知道
> 要判這個人在**哪個專案**裡的角色。route 層表達不了，所以統一落在 app service 的
> `SspPermissionChecker`。這符合 `common/authz/` 的雙軌設計（CLAUDE.md 授權守門段）。

#### serializers/ — 47 檔／1,670 行

除下列四支外，其餘 43 支全是 **共用設施**（純 marshmallow Schema，零業務、零 DB；
`oscal_common/` 下 19 支另被 `api/project/serializers/project.py`
與 `api/module_frame/serializers/module_frame.py` 借用，是跨模組的欄位契約）。

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `serializers/ssp/ssp.py` | 29 | 🔴 **死碼** | `SystemSecurityPlanResponseSchema` 只被 `serializers/__init__.py:12` re-export，全 repo 無其他命中（含字串式 `Nested`）。詳見 ④ 4.3 |
| `serializers/ssp/ssp_system_characteristic.py` | 18 | 🔴 **死碼** | `SystemCharacteristicResponseSchema` 只被上面那支死 schema 用字串式 `Nested` 引用（`ssp.py:19`）＋`__init__` re-export。⚠️ 與**活的** `ssp_system_characteristic_inproject.py` 的 `SspSystemCharacteristicResponseSchema` 名字只差 `Ssp` 前綴，清理時極易誤刪 |
| `serializers/ssp/ssp_system_implementation.py` | 17 | 🔴 **死碼** | 同上（`ssp.py:20` 字串式引用＋`__init__` re-export） |
| `serializers/framework/oscal_framework_menu.py` | 12 | 🔴 **死碼** | `OscalFrameworkMenuResponseSchema` 只被 `__init__` re-export。route 用的是**同名不同物**的 `OscalFrameworkMenuSchema`（定義在 `oscal_framework.py:70`，無 `Response` 三字） |
| `serializers/framework/oscal_framework_version_menu.py` | 11 | 🔴 **死碼** | 只被上面那支死 schema 與自己（`children` 遞迴）引用＋`__init__` re-export |
| `serializers/framework/oscal_framework.py` | 165 | **共用設施** | 6 個 Schema，含 route 實際在用的 `OscalFrameworkMenuSchema`／`OscalFrameworkPageQueryResponse`／`OscalFrameworkResponseSchema` |
| `serializers/ssp/ssp_docx_import.py` | 185 | **共用設施** | 12 個 Schema（匯入決策／預覽／confirm 請求回應），被 `ssp_docx_import_route` 用 |
| `serializers/ssp/ssp_excel_import.py` | 137 | **共用設施** | 7 個 Schema，被 `ssp_excel_import_route` 用 |
| `serializers/framework_parse_job/framework_parse_job_schemas.py` | 201 | **共用設施** | 被 `framework_parse_job_route` 用 |
| （其餘 38 支 serializer） | — | **共用設施** | 純 Schema／空 `__init__` |

> ⚠️ **這五支死 schema 用 grep import 抓不到、也不能只看 grep 命中數。**
> 它們互相用 marshmallow 的字串式 `fields.Nested("X")` 引用，形成一個**封閉的小圈**：
> `ssp.py` 引 `SystemCharacteristic`／`SystemImplementation`，`oscal_framework_menu` 引
> `oscal_framework_version_menu`，而圈外沒有任何人引用這個圈。守衛測試
> `test/test_module_boundaries.py:2028-2042` 把這幾行列為字串式 `Nested` 白名單，
> 理由寫的是「本卡範圍外未動」——**那是說明沒清，不是說明它們是活的**。

---

### `app/oscal`（46 檔／10,964 行）

#### service/ — 直屬 14 支，模組的重心

| 檔 | 行 | 類別 | 依賴證據 | 殼帶主專案規則？ |
|---|---:|---|---|---|
| `ssp_control_implementation_service.py` | 1,004 | **真業務**（殼＋大量業務混住，見下方拆解） | jedi=19／host=4。19 個 jedi import **多數是讀資料的來源**（v2 IR／statement／catalog、`jedi_compliance_audit` 的輪次與 AP、`jedi_flow_engine` 的 job）。單一方法 `build_control_tree_by_ssp_id` 就 **400 行**（408-807），做的是「把 catalog 控制樹 ＋ 已填值 ＋ 本輪任務狀態 ＋ 上一輪結果」四份資料揉成前端要的一棵樹 | ✅ 帶 — `SspPermissionChecker` 守門；輪次凍結範圍（`_frozen_round_control_scope`）與「規劃期重驗」（`_is_planning_stage_reverify`）是產品的稽核輪次語意，套件不知道 |
| `ssp_excel_import_app_service.py` | 937 | **真業務** | jedi=5／host=8。26 支方法，主體是自寫的匯入編排：上傳→解析→比對→預覽→確認四階段，含 TTL 逾期清理、參與方鉤稽、決策合併、內容覆寫。套件只在最後一步收寫入 | ✅ 帶 — ① 三種落點分流（資源庫範本／專案 SSP／新建資源庫）② 專案角色守門（`_require_project_manager`／`_require_project_participant`）③ 檔案 TTL 與暫存清理 |
| `ssp_docx_import_app_service.py` | 787 | **真業務** | jedi=2／host=8。與上一支同形（四階段匯入編排），多一段「依框架挑 adapter 跑格式轉換」（`_run_framework_adapter`）與 docx 修訂記號正規化（`_normalize_docx_revisions`，54 行） | ✅ 帶 — 同上三條，另加 CMMC 格式判定 |
| `framework_parse_job_service.py` | 674 | **真業務**（殼＋業務混住） | jedi=14／host=4。`parse`（112 行）與 `confirm`（118 行）是自寫編排，`_apply_decisions_and_overrides`（83 行）處理使用者逐項決策，`_persist_catalog`（54 行）才把結果交給套件 | ✅ **帶** — ① `require_platform_admin` 守門 ② `_effective_tenant_id`（多租戶落點判定）③ `_guard_version_replaceable`（版本可否覆寫的產品規則） |
| `resource_library_app_service.py` | 541 | **真業務**（非純殼——卡片問的那支） | jedi=11／host=2。**看行數分布就知道不是殼**：`create_resource_library` 單一方法 **104 行**，做的是「clone catalog → 依規則建 profile → 建空範本 SSP → 寫 link record」四段編排；`_build_catalog_tree`（65 行）與 `update_applicable_controls`（60 行）是自寫的樹狀組裝與差異更新；`_init_ao_workflows`（38 行）掛預設流程範本。套件提供的是 clone／CRUD 原語，**編排與資源庫這個概念本身是主專案的** | ✅ 帶 — ① 資源庫的 link record 存主專案的 `compliance.module_frames`（原生 SQL，`_MF` 常數）② 暱稱 enrich（`enrich_audit_nicknames`）③ `_fmt_dt`（本 route 直接回 dict 未套 Schema，須自行格式化時間，否則回 GMT 格式） |
| `ssp_control_impl_import_service.py` | 431 | **真業務** | jedi=3／host=3。openpyxl 手刻 9 欄 Excel（建 workbook、套配色邊框、下拉、隱藏 A 欄）＋解析＋逐列驗證＋批次 upsert。與 `module_frame_template_import_service.py` 共用同一份欄位契約——見 ⑤ 5.1 | — |
| `framework_version_edit_service.py` | 351 | **轉接殼** | jedi=14／host=2、**零主專案業務 import**。檔頭 12 行就是 v1↔v2 欄位對照表（`control.description` ↔ statement part prose、`group.description` ↔ v2 無此欄讀 None 寫忽略、`uid` ↔ `str(id)`）。主體是「收 FE 的 v1 形狀 → 翻成 v2 → 呼叫套件」 | ✅ **帶兩條** — ① 雙軌守門：`publish_status != 'draft'` 拒絕、catalog 被 profile 引用時不允許刪 ② `require_platform_admin` |
| `ssp_document_pool_service.py` | 313 | **轉接殼＋業務** | jedi=8／host=3。程序書池本體落 `jedi_compliance_audit` 的 `ssp_reference_documents`，控制項／AO 關聯的 `context_id` 要反查 v2 的 IR.id／statement.id | ✅ 帶 — ① `SspPermissionChecker` ② **IR／statement 不存在時的三種行為**（寫入自動建、讀取回 `[]`、刪除回 `False`）是產品的 UX 判斷 ③ `context_type` 的 `control_implementation`／`objective` 慣例 |
| `framework_app_service.py` | 247 | **轉接殼** | jedi=7／host=2。檔頭自陳「補套件沒有的主專案職責——分頁、filter、main_version 字串解析、建框架時順手建首版」。模組層級的 `apply_sorts_in_memory`（在 Python 端做多欄排序，因為套件 query 不支援跨欄位 OR 搜尋）**被 `framework_version_app_service.py:26` 借用** | ✅ 帶 — `require_platform_admin`；分頁／排序／搜尋是主專案的 API 契約 |
| `ssp_resources_context_service.py` | 230 | **轉接殼＋業務** | jedi=3／host=1。落 v2 `ssp_components`，以 `type` 區分 hardware／system。**兩個入口共用這一支**：專案走 `SspResourcesAppService`、資源庫範本走 `module_frame` 的 `ModuleFrameSspResourcesService` | ✅ **帶** — ① FR-032 軟參照欄（`device_id`／`system_characteristic_id`／`responsible_party` 走 props）② 讀取時 enrich 設備／資訊系統主檔名稱 ③ `sys_impl_main_id`／`ensure_sys_impl_main_id` 是**刻意保留的 no-op 簽章**，讓兩個入口不必改 |
| `framework_version_app_service.py` | 229 | **轉接殼** | jedi=5／host=3。檔頭自陳「對外對齊舊 v1 版本管理 response shape（FE 零改動）」。版本樹（parent／children／is_root）在本層用 Python 算，避免動套件 repo 的 NULL 過濾 | ✅ 帶 — `require_platform_admin`；v1 shape 相容是主專案對 FE 的契約 |
| `ssp_inventory_items_app_service.py` | 194 | **轉接殼＋業務** | jedi=4／host=6。**直接 import `module_frame` 的 `ModuleFrameInventoryService` 當欄位對應表**（`_InvMap`／`_build_props`／`_build_implemented`） | 🔴 **帶** — **FR-032 master-snapshot**：picker 帶 `ref_id` 時由後端從主檔撈明細寫入（不信前端傳值、單向凍結），free input 才原樣存。檔頭寫明這是相對範本版的差異——範本可以 store-as-is，專案是租戶實際資料不行 |
| `ssp_party_app_service.py` | 169 | **轉接殼** | jedi=5／host=3。**共用 `module_frame` 的 `ModuleFramePartyService` 純 mapping 靜態方法**。自己只做「`metadata_id` 從哪來＋權限」 | ✅ 帶 — `SspPermissionChecker`；`metadata_id` 解析（party 在 v2 掛 SSP 的 metadata 而非 SSP 本體，`get_ssp` 只吃 uuid 故繞 `list_ssps` 取 root） |
| `ssp_components_app_service.py` | 142 | **轉接殼** | jedi=4／host=5。同上，共用 `ModuleFrameComponentsService` 的 `_to_dict`／`_build_props` | ✅ 帶 — `SspPermissionChecker`；uid（OSCAL uuid）↔ int id 的對外識別轉換 |
| `ssp_leveraged_app_service.py` | 141 | **轉接殼** | jedi=4／host=5。同上，共用 `ModuleFrameLeveragedService` | 🔴 **帶** — 繼承範本版的**空值哨兵**：`party_uuid`（uuid 非空欄）空值落全零 UUID、`date_authorized`（非空欄）空值落今天，讀回時換成 None。這是主專案替套件的設計缺口補位——見 ⑤ 5.2 |
| `ssp_system_characteristic_app_service.py` | 68 | **轉接殼**（薄） | jedi=3／host=3。共用 `ModuleFrameSystemCharacteristicService` 的 mapping | ✅ 帶 — 空 SC 時 GET 回 placeholder、PUT 首次自動建立 |
| `ssp_resources_app_service.py` | 52 | **轉接殼**（薄，真的只轉呼叫） | jedi=2／host=2。全部委派 `SspResourcesContextService`，自己只做 `ssp_uid` → `ssp_id` 反查與守門 | ✅ 帶 — `SspPermissionChecker` |
| `oscal_export_app_service.py` | 47 | **轉接殼** | jedi=2／host=1。接套件 `OscalIoService.export_oscal`。**注意它註冊在 `flow_control_container` 不是 `oscal_container`**（`flow_control_containers.py:230`）。使用者是 `oscal_framework_version_route.py:138`（框架簽章下載），**不是**被註解掉的 `OscalExportRoute` | ⚠️ 帶一點 — 本期權限只有 jwt 認證，檔頭自陳 per-project 授權是 follow-up |
| `service/__init__.py` | 0 | **共用設施** | 空 | — |

##### `ssp_control_implementation_service.py` 的殼段／業務段拆解

這支 1,004 行、19 個套件引用，是全模組最像「殼」卻最不是殼的一支。按方法區塊估算：

| 區段 | 約行數 | 性質 |
|---|---:|---|
| `build_control_tree_by_ssp_id`（408-807） | 400 | **業務**——四份資料（catalog 樹／已填值／本輪任務／上輪結果）揉成前端的樹 |
| `_resolve_inscope_catalog`（160-223）＋`_frozen_round_control_scope`（295-333）＋`_prev_round_context`（359-405） | 155 | **業務**——稽核輪次的控制項範圍判定，是產品的輪次模型 |
| `build_classifier_catalog_by_ssp_id`（237-292） | 56 | **業務**——給證據分類器用的控制項母體 |
| CRUD 三支（`get`／`update`／`list_control_implementations`） | 57 | **殼**——轉呼叫套件 IR CRUD |
| 程序書關聯六支（`add_reference_documents` 等） | 47 | **殼**——全部一行轉呼叫 `SspDocumentPoolService` |
| get-or-create 與 props 解析小工具（`_ir_for_control`／`_get_or_create_*`／`_props_to_map` 等 9 支） | 82 | **共用設施**（同檔內用，另 `_P_DESC`／`_P_STATUS`／`_props_to_map` 三個被 `ssp_control_impl_import_service.py:34-38` 借用） |

粗分約 **業務 611 行／殼 104 行／工具 82 行**，其餘是 import 與類別骨架。
這個拆法是按方法區塊估的，不是逐行計數。

#### import_diff/ — 4 檔／1,384 行，整包零套件引用

```bash
grep -rnE '^\s*(from|import) jedi_' app/oscal/service/import_diff/ | wc -l   # → 0
```

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `ssp_diff_service.py` | 751 | **真業務** | 零 jedi、零 DB、無狀態。7 個區段（控制項說明／逐 AO／參與方／元件／外部授權／資產清冊／系統特性）× 4 種差異狀態（未變／已改／新增／消失）的比對演算法＋智慧預設表。呼叫端自己餵資料進來 |
| `decision_merge.py` | 318 | **真業務** | 零 import（只有標準庫）。把使用者逐項選的「用新的／保留現況／略過」摺成一份最終 OSCAL dict。**關鍵設計是「保留現況＝省略不寫」**——套件的 update 模式不刪缺漏的列、也不把省略欄位設成 null，所以省略剛好等於保留 |
| `snapshot_views.py` | 315 | **真業務** | 零 import。把套件產的 OSCAL 快照 dict 重塑成比對器要的「現況側」視圖，鍵用的是自然身分（參與方 name/email、元件 title+type、控制項 control-id） |
| `import_diff/__init__.py` | 0 | **共用設施** | 空 |

#### import_adapter/ — 6 檔／1,057 行

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `_common.py` | 620 | **真業務** | 零 import（只有標準庫）。Excel 與 Word 兩條匯入線共用的 OSCAL 組裝器——系統特性／元件／外部授權／資產清冊／SoA props。**核心設計**：OSCAL 要求實作說明掛在 by-component 上、by-component 又要指向一個 component，故每份匯入的 SSP 都合成一個標準的 `this-system` component 當錨點 |
| `docx_to_oscal_ssp.py` | 152 | **真業務** | host=1（引 `_common`）、jedi=0。Word 專屬的參與方與控制項實作抽取 |
| `excel_to_oscal_ssp.py` | 144 | **真業務** | host=1、jedi=0。Excel 專屬的同上 |
| `party_match_enrich.py` | 78 | **共用設施** | jedi=1。把比對到的 `matched_user_id`／`matched_org_unit_id` 補上暱稱與帳號（CLAUDE.md 審計欄位規範）。批次查避 N+1、失敗靜默略過（預覽不可因此爆） |
| `v2_candidate_loader.py` | 54 | **轉接殼** | host=1、jedi=0（但實際跑時吃注入的套件 service）。從 v2 catalog 撈控制項候選清單餵給 Word 解析器 |
| `import_adapter/__init__.py` | 9 | **共用設施** | re-export |

#### excel_parser/ — 7 檔／1,047 行

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `sheet_handlers.py` | 428 | **真業務** | jedi=0、host=4。9 張 sheet 的逐列解析。**`:43` 直接 import `module_frame` 的 `excel_template/sheet_definitions.py` 的 10 個 sheet 常數**——匯出端定義、匯入端引用，單一真相 |
| `v2_bundle.py` | 217 | **真業務** | 零 import。把 Excel 解析出的舊鍵（`devices`／`info_systems`／`leveraged`）換成新的 OSCAL 鍵（`components`／`inventory_items`／`leveraged_authorizations`）。缺這層匯入確認會直接 412 |
| `parser.py` | 153 | **真業務** | jedi=1、host=4。openpyxl 入口，逐 sheet 分派給 handler |
| `types.py` | 111 | **共用設施** | 零 import。`ParsedExcel`／`ValidationError` 兩個 dataclass |
| `validators.py` | 66 | **真業務** | host=1。逐列驗證器，含「參與方角色四值」與「勾選欄的 14 種真值寫法」（含 `是`、`Y`、`1`） |
| `version_check.py` | 63 | **真業務** | 零 import。樣板版本相容性檢查。**檔內 20 行註解說明為何拒絕 v2.x 與 v1.x**：sheet 名與欄位鍵全不同，硬讀會雙重錯位、資料全錯，強制使用者重下樣板是唯一安全路徑 |
| `excel_parser/__init__.py` | 9 | **共用設施** | re-export |

#### export/ — 6 檔／883 行

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `ssp_docx_generator.py` | 258 | **真業務** | 零 jedi、host=2。docxtpl ＋ python-docx 排版：渲染靜態段落 → 存檔 → 重開 → 逐控制項動態 append（標題三 ＋ AO 表格 ＋ 實作說明）→ 附錄。範本檔 `app/oscal/templates/ssp/ssp_cmmc_template.docx` 進版控，由 `scripts/generate_ssp_docx_template.py` 產生 |
| `ssp_v2_content_loader.py` | 217 | **轉接殼** | jedi=1（實際跑吃注入的套件 service）、host=1。六個資料來源全部向套件拿（系統特性／人員／受評標的／外部服務／控制實作／控制標題），組成 `SspExportDataModel` |
| `ssp_export_model.py` | 169 | **共用設施** | 零 import。純 dataclass，讓 generator 不必知道資料來源差異 |
| `ssp_libreoffice_converter.py` | 124 | **真業務** | 零 import（只有標準庫）。LibreOffice headless 子行程包裝（docx → pdf／odt），含三平台 binary 名稱解析與暫存檔清理 |
| `ssp_export_app_service.py` | 115 | **轉接殼** | jedi=2、host=3。統一入口，協調 loader ＋ generator ＋ converter | ✅ 帶 — `SspPermissionChecker`＋`hotpath_verify`（FR-064 防竄改熱點驗證） |
| `export/__init__.py` | 0 | **共用設施** | 空 |

#### dto/ — 3 檔／29 行

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `dto/ssp/ssp_reference_document_dto.py` | 29 | **共用設施** | jedi=1。被程序書池 service 與 route 用 |
| `dto/__init__.py`／`dto/ssp/__init__.py`／`app/oscal/__init__.py` | 0／0／7 | **共用設施** | 空或只有 docstring |

---

### `domain/oscal`（36 檔／4,549 行）

#### parser/ — 7 檔／2,027 行，整包零套件引用

```bash
grep -rnE '^\s*(from|import) jedi_' domain/oscal/parser/ | wc -l   # → 0
```

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `docx_section_extractors.py` | 892 | **真業務** | 零 import（只有 python-docx）。依「章節標題 ＋ 表格欄位標題」雙錨點在 Word 裡定位特定表格並抽出欄位。回純 dict／list，不回 OSCAL 型別，讓各框架的轉換器自由組裝 |
| `docx_parser_core.py` | 555 | **真業務** | 零 jedi、host=4。Word 檔的通用結構解析（段落／表格依文件順序走訪）＋控制項識別碼比對 |
| `ssp_intermediate.py` | 369 | **共用設施** | 零 jedi、host=2。OSCAL 對齊的中間層 dataclass（`ParsedSsp`／`ParsedParty`／`ParsedComponent` 等）。**被 7 處模組內引用**，是整條匯入線的共同語言。命名一律用 OSCAL 術語、不用 CMMC 專有名詞，框架專屬屬性統一塞 `framework_specific_props` |
| `control_id_matcher.py` | 120 | **真業務** | 零 jedi、host=1。控制項識別碼的樣式比對與模糊比對（rapidfuzz）。檔頭記 FR-069 P0-② 把它從 `common/util/` 搬來的理由：它是 OSCAL 專屬業務知識，放 `common/` 會造成 `common/` → `domain.oscal` 的反向依賴 |
| `docx_intermediate.py` | 72 | **共用設施** | 零 import。`ParsedDocx`／`CandidateControl` 等 dataclass，被 5 處引用 |
| `framework_patterns.py` | 19 | **真業務** | 零 import。四種框架（CMMC L1／L2、NIST 800-171、ISO 27001）的控制項識別碼正規式。**CMMC 那條同時吃 1.0 與 2.0 兩種寫法**，因為實際客戶文件常混用 |
| `parser/__init__.py` | 0 | **共用設施** | 空 |

#### adapter/ — 4 檔／959 行

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `cmmc_ssp_adapter.py` | 860 | **真業務** | 零 jedi、host=4。把框架無關的 `ParsedDocx` 轉成 OSCAL 對齊的 `ParsedSsp`。**整支是 CMMC 的文件慣例知識**：封面表格哪一格是版本、五個固定角色的順序、哪個角色對應「單位」哪些對應「人員」（含一條反直覺的：Information Provider 字面是上游廠商，但實際填的是窗口聯絡人，要對使用者不對單位） |
| `i_ssp_docx_adapter.py` | 78 | **共用設施** | 零 jedi、host=2。轉換器的抽象介面 |
| `adapter_registry.py` | 21 | **共用設施** | host=1。框架代號 → 轉換器的查表。設計理由：加 ISO 27001 只要在 DI 加一行註冊，不必動 app service |
| `adapter/__init__.py` | 0 | **共用設施** | 空 |

#### service/ — 14 檔／1,043 行

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `ssp_project_resolver.py` | 184 | **共用設施**（跨模組） | jedi=3、host=1。`ssp_uid` → SSP／專案的反查鏈＋寫入階段判定。**被 `common/authz/ssp/SspPermissionChecker` 用**（授權軸的資源解析），屬全專案共用的守門基礎設施 |
| `ssp_context_resolver.py` | 158 | **共用設施** | jedi=1、host=2。判斷一份 SSP 屬專案還是資源庫範本，讓同一個端點雙端共用。**走 profile 反查而非 `SSP.template_module_frame_id`**——檔頭寫明後者雖有欄位但從沒接上、DB 該欄 100% 是 null |
| `framework_parse_job_domain_service.py` | 123 | **真業務** | 零 jedi、host=2。框架解析工作的 domain service（自持表） |
| `reconciliation/base.py` | 107 | **共用設施** | host=1。比對演算法的抽象基底（三段回退：完全相符 → 正規化後相符 → 模糊相符），子類只實作 5 個掛鉤 |
| `reconciliation/person_reconciler.py` | 101 | **真業務** | jedi=1、host=4。解析出的人員 ↔ 系統使用者的比對 |
| `reconciliation/organization_reconciler.py` | 101 | **真業務** | jedi=1、host=4。解析出的單位 ↔ 系統組織單位的比對 |
| `ssp_docx_parse_job_domain_service.py` | 71 | **真業務** | 零 jedi、host=2。自持表的 domain service |
| `ssp_excel_parse_job_domain_service.py` | 71 | **真業務** | 同上 |
| `reconciliation/_normalizers.py` | 51 | **共用設施** | 零 import。字串正規化 helper |
| `party_reconciliation_service.py` | 30 | **共用設施** | host=1。門面：依 `party_type` 把混合清單分派給兩個比對器。檔頭寫明簽章 100% 不變、既有呼叫端零行變動 |
| `reconciliation/__init__.py` | 18 | **共用設施** | host=2。**只 re-export 不 import `ssp_intermediate` 的那兩支**——檔頭寫明 re-export 具體比對器會觸發循環 import，呼叫端與 DI 必須從各自模組路徑 import |
| `reconciliation/match_method.py` | 10 | **共用設施** | 零 import。比對方式列舉，被 7 處引用 |
| `service/__init__.py` | 0 | **共用設施** | 空 |

#### entity/ 與 repository/ — 10 檔／520 行

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `entity/framework_parse_job_entity.py` | 76 | **共用設施** | 零 import。entity ＋ query entity，被 5 處引用 |
| `entity/ssp_docx_parse_job_entity.py` | 70 | **共用設施** | 同上，4 處 |
| `entity/ssp_excel_parse_job_entity.py` | 70 | **共用設施** | 同上，4 處 |
| `import_pipeline/bundle_restore.py` | 208 | **真業務** | 零 jedi、host=2。確認階段的 dict ↔ dataclass 還原。只支援新版形狀，舊版拋 `LegacyConfirmRequired` 讓呼叫端掉回舊路徑 |
| `repository/i_framework_parse_job_repo.py` | 39 | **共用設施** | host=1。抽象介面 |
| `repository/i_ssp_docx_parse_job_repo.py`／`i_ssp_excel_parse_job_repo.py` | 25／25 | **共用設施** | 同上 |
| `repository/i_ssp_catalog_title_query.py` | 24 | **共用設施** | 零 import。控制標題查詢的抽象介面，**被 `module_frame` 那側也用到**（同一份 v2 catalog 鏈） |
| `entity/__init__.py`／`repository/__init__.py`／`import_pipeline/__init__.py`／`domain/oscal/__init__.py` | 0／0／1／0 | **共用設施** | 空 |

---

### `infra/oscal`（14 檔／499 行）

| 檔 | 行 | 類別 | 依賴證據 |
|---|---:|---|---|
| `repository/ssp_catalog_title_query.py` | 93 | **真業務** | jedi=4、host=1。**跨疆界唯讀查詢**：`ssp_id` → `import_profile_id` → `source_catalog_id` → 控制標題與 AO 清單。檔頭記舊版走已退役的 AP 鏈、且 `from jedi_oscal.infra.model.ap...` 會把 v1 套件拉進啟動圖撞 v2 同名表直接炸 `create_app` |
| `repository/framework_parse_job_repo_impl.py` | 91 | **真業務** | jedi=1、host=4。繼承 `BaseRepositoryImpl` |
| `repository/ssp_excel_parse_job_repo_impl.py` | 56 | **真業務** | 同上 |
| `repository/ssp_docx_parse_job_repo_impl.py` | 54 | **真業務** | 同上 |
| `mapper/framework_parse_job_mapper.py` | 39 | **共用設施** | host=2。entity ↔ model |
| `model/framework_parse_job.py` | 36 | **真業務** | jedi=2。ORM，含 `TenantScopedMixinModel`（多租戶） |
| `mapper/ssp_docx_parse_job_mapper.py`／`ssp_excel_parse_job_mapper.py` | 35／35 | **共用設施** | host=2 各 |
| `model/ssp_docx_parse_job.py`／`ssp_excel_parse_job.py` | 30／30 | **真業務** | jedi=2 各 |
| `__init__.py`×4（`infra/oscal`／`mapper`／`model`／`repository`） | 0×4 | **共用設施** | 空 |

---

## ③ 分類統計

分類單位是檔。混住兩種性質的檔（如 `ssp_control_implementation_service.py` 是業務＋殼、
`ssp_document_pool_service.py` 是殼＋業務）按**主體**歸類，另在 ② 表的證據欄註明混住情形。

| 類別 | 檔數 | 佔比 | 行數 | 佔比 |
|---|---:|---:|---:|---:|
| **真業務** | 37 | 22.7% | 11,494 | 57.5% |
| **轉接殼** | 34 | 20.9% | 4,757 | 23.8% |
| **共用設施** | 86 | 52.8% | 3,619 | 18.1% |
| **死碼** | 6 | 3.7% | 126 | 0.6% |
| **還看不準** | 0 | 0% | 0 | 0% |
| **合計** | **163** | 100% | **19,996** | 100% |

> **共用設施佔過半檔數但只佔 18.1% 行數**——裡面有 22 支空的或只有一兩行的 `__init__.py`，
> 加上 43 支純 marshmallow Schema 與一批 dataclass。**看行數比看檔數準。**

<details>
<summary>怎麼重跑統計</summary>

把 ② 表的每一列（檔路徑 → 類別）餵進下面這段，行數用 `wc -l` 現算：

```bash
git ls-files api/oscal app/oscal domain/oscal infra/oscal \
  | grep '\.py$' | while read f; do printf '%s\t%s\n' "$(wc -l < "$f"|tr -d ' ')" "$f"; done
```

行數合計必須等於 19,996，檔數必須等於 163；對不上就是分類有漏或重複。

</details>

**按層分布**：

| 層 | 真業務 | 轉接殼 | 共用設施 | 死碼 |
|---|---:|---:|---:|---:|
| `api/` | 0 | 19 檔／2,188 行 | 42 檔／1,670 行 | 6 檔／126 行 |
| `app/` | 19 檔／7,983 行 | 15 檔／2,569 行 | 12 檔／412 行 | 0 |
| `domain/` | 11 檔／3,121 行 | 0 | 25 檔／1,428 行 | 0 |
| `infra/` | 7 檔／390 行 | 0 | 7 檔／109 行 | 0 |

四件事從這張表看得出來：

1. **`api/` 一支真業務都沒有**，全是殼與序列化器。這是乾淨的形狀——route 層不該有業務。
   對照 `module_frame` 那邊 `api/` 還有一支 162 行的真業務（route 層自己用 pandas 讀 Excel 比對欄位標題），
   `oscal` 這邊沒有同類問題。
2. **`domain/` 3,121 行真業務、零殼**——這是全模組最值錢的一塊：Word 解析（2,027）、
   CMMC 轉換（860）、比對器（202）、匯入還原（208）。套件完全不參與。
3. **殼集中在 `app/` 與 `api/`**（4,757 行的 100%），與 `module_frame` 同形。
4. **六支死碼全在 `api/`**，且全是序列化器與一支被註解掉的 route，沒有一支在 `app/` 以下。

**對 FR-102 總表 B 該列（`README.md:100`）逐句核對**：

| FR-102 原句 | 核對結果 |
|---|---|
| 「`domain/oscal` 4,549 行是真 domain 資產（docx 解析 892 ＋ CMMC adapter 860 ＋ parser core 555）」 | **成立**，三個數字逐一重跑相符。但「4,549 行全是真 domain 資產」需精確化：實查 3,121 行是真業務，另 1,428 行是 dataclass／抽象介面／resolver 這類共用設施 |
| 「`import_diff/ssp_diff_service.py`（751 行）零個 `jedi_oscal_v2` 引用、全自寫」 | **成立**，且**整包 `import_diff/` 4 檔 1,384 行全都零套件引用**（比原句涵蓋更廣） |
| 「`infra/oscal` 自己持有三張匯入 job 表」 | **成立**。三張表的 repo **全都有寫入者也有讀取者**（卡片問的「只寫不讀／只讀不寫孤兒」不成立）：三支 domain service 各被對應的 app service 注入使用 |
| 「`framework_app_service.py`（247 行）幾乎每支就一行轉呼叫套件，檔頭自陳補套件沒有的主專案職責——分頁、filter」 | **成立**。另補：同檔的 `apply_sorts_in_memory` 被 `framework_version_app_service` 借用，是兩支殼共用的排序工具 |
| 「真業務與轉接殼混住同一 service 目錄，模組層級單一標籤蓋不住」 | **成立**，這正是本表存在的理由 |

FR-102 對本模組的五句證據**沒有一句已失效**，兩句需要精確化。

---

## ④ 可以立刻收的

> 判準是「說得出證據才叫可收」。下面每一項都附了怎麼驗它真的沒人用。
> **本卡只分析不動程式**，列在此處是備妥證據，清不清屬異動、要決策者裁。

### 4.1 一支被註解掉的 route 檔（39 行）

`api/oscal/routes/oscal_export_route.py` 整檔（定義 `OscalExportRoute`）。

**四層查證結果**：

| 層 | 結果 |
|---|---|
| route 註冊 | ❌ `__init__.py:89` 被註解 |
| 模組內 import | ❌ `__init__.py:88` 那行 import **也一起被註解** |
| 模組外 import | ❌ 0（`grep -rn "OscalExportRoute" --include='*.py' .` 只命中定義本身、兩行註解，以及三處提到它名字的文字說明） |
| DI 註冊 | 不適用（Resource 類別不進 DI） |

**怎麼驗**：
```bash
grep -rn "OscalExportRoute" --include='*.py' . | grep -v __pycache__
grep -rn "oscal/export/" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/
grep -rn "oscal/export/" ~/Projects/Billows/Audit-Manager/compliance-manager-test/site-regression/
```

⚠️ **但它背後的 `OscalExportAppService`（47 行）是活的，不能一起收**——
`api/oscal/routes/framework/oscal_framework_version_route.py:138` 用它做框架的 OSCAL 下載
（`export('catalog', catalog_uid, fmt='json')`），走 `flow_control_container` 注入。
另外三處註解文字（`ssp_export_app_service.py:6,113`、`oscal_containers.py:406`）
寫著「json/yaml/xml 匯出已移至 `OscalExportRoute`」——**那個目的地其實是關著的**，
這三行說明與現況不符。

### 4.2 兩條註冊了但前端零呼叫的資源庫端點

`GET /oscal/resource-library/<uid>`（詳情）與 `POST /oscal/resource-library/<uid>/publish`（發布），
對應 `resource_library_route.py` 的 `ResourceLibraryRoute`（67-73 行）與
`ResourceLibraryPublishRoute`（76-84 行）。

**四層查證結果**：

| 層 | 結果 |
|---|---|
| FE① `api.js` 常數 | ✅ 有定義（`RESOURCE_LIBRARY:145`，註解寫「+ /\<uid\> [GET 詳情] [+ /publish POST]」） |
| FE② 實際呼叫 | ❌ **全前端零命中**——`grep -rn "API.RESOURCE_LIBRARY\b" src/` 回 0 行，`grep -rn "resource-library" src/` 只命中三行**註解文字** |
| ③ `ui_routes` | 不適用（不是選單） |
| ④ 非瀏覽器 | ❌ 0——e2e step code 零命中（只在 `docs/test-cases/module-frame.md` 的文字說明裡被提到）、`scripts/` 與 `bin/` 零命中 |

**怎麼驗**：
```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
grep -rn "RESOURCE_LIBRARY\b" src/ | grep -v "config/api/api.js"
grep -rn "resource-library/" src/
cd ~/Projects/Billows/Audit-Manager/compliance-manager-test
grep -rn "resource-library/" site-regression/steps/ site-regression/support/ site-regression/pages/
```

⚠️ **兩支背後的 service 方法不能一起收**：
- `get_resource_library`（`resource_library_app_service.py:205`）**被六處後端內部呼叫**
  （`ssp_excel_import_app_service.py:367,464,817`、`ssp_docx_import_app_service.py:165,337,454,603`），
  是匯入線驗證「這個資源庫存不存在」的手段。
- `publish_resource_library`（`:533`）**只有這個端點呼叫**，收掉端點等於收掉整個發布功能。
  「發布」是資源庫從租戶私有變成系統公版的唯一途徑（建立端點刻意不掛平台管理員守門，
  就是因為治理靠發布這關擋）。**列在此處只是標出「目前對外不可達」，不是建議砍。**

### 4.3 五支互相引用成封閉圈的死序列化器（87 行）

| 檔 | 行 | 類別名 |
|---|---:|---|
| `api/oscal/serializers/ssp/ssp.py` | 29 | `SystemSecurityPlanResponseSchema` |
| `api/oscal/serializers/ssp/ssp_system_characteristic.py` | 18 | `SystemCharacteristicResponseSchema` |
| `api/oscal/serializers/ssp/ssp_system_implementation.py` | 17 | `SystemImplementationResponseSchema` |
| `api/oscal/serializers/framework/oscal_framework_menu.py` | 12 | `OscalFrameworkMenuResponseSchema` |
| `api/oscal/serializers/framework/oscal_framework_version_menu.py` | 11 | `OscalFrameworkVersionMenuResponseSchema` |

以及 `api/oscal/serializers/__init__.py:10-14` 對它們的五行 re-export。

**四層查證結果**：

| 層 | 結果 |
|---|---|
| route 使用 | ❌ 0——沒有任何 route 的 `@marshal_with` 或 `.dump()` 用到這五個類別 |
| 模組內 import | 只有 `serializers/__init__.py` 的 re-export，以及**圈內互相的字串式 `Nested`** |
| 模組外 import | ❌ 0（唯一一處 `from api.oscal.serializers import ...` 是 `oscal_framework_version_route.py:11`，拿的是 `OscalFrameworkVersionResponseSchema`，不在這五支裡） |
| DI 註冊 | 不適用 |

**怎麼驗**（必須連字串式 `Nested` 一起查，只 grep import 會漏）：
```bash
for c in SystemSecurityPlanResponseSchema SystemCharacteristicResponseSchema \
         SystemImplementationResponseSchema OscalFrameworkMenuResponseSchema \
         OscalFrameworkVersionMenuResponseSchema; do
  echo "--- $c"
  grep -rn "\b$c\b" --include='*.py' . | grep -v __pycache__ | grep -v '^./docs/'
done
```
每一支的命中應該只有三種：自己的 `class` 定義、`serializers/__init__.py` 的 re-export、
圈內另一支的字串式 `Nested`。有第四種就表示它是活的。

🔴 **兩個清理時會踩的坑**：

1. **名字只差三個字母。** 活的是 `ssp_system_characteristic_inproject.py` 的
   `SspSystemCharacteristicResponseSchema`（有 `Ssp` 前綴，被
   `ssp_system_characteristic_route.py:14,23,38` 用）；死的是
   `ssp_system_characteristic.py` 的 `SystemCharacteristicResponseSchema`（無前綴）。
   同理框架選單：活的是 `oscal_framework.py:70` 的 `OscalFrameworkMenuSchema`（無 `Response`），
   死的是 `oscal_framework_menu.py` 的 `OscalFrameworkMenuResponseSchema`（有 `Response`）。
   **grep 時不加 `\b` 邊界會兩邊一起命中，直接誤判。**

2. **守衛測試的白名單不是「它是活的」的證明。** `test/test_module_boundaries.py:2028-2042`
   把這幾支的字串式 `Nested` 行列在白名單，理由寫「本卡範圍外未動」——那是
   FR-092 第 18 棒的作業範圍註記。真要清，這幾行白名單也要一起移除，否則守衛會
   指向不存在的檔案行號。

### 4.4 沒有的

以下曾被懷疑、**實查後確認不可收**，列在此處避免下一個人重查：

- **`infra/oscal` 三張匯入工作表的 repo**：三支都有寫入者也有讀取者（各自的 domain service
  被對應的 app service 注入），沒有孤兒。
- **`ssp_scoped_excel_import_route.py` 的兩個被註解類別**：雖然 route 註冊被註解，但
  同檔的 `SspScopedExcelImportUploadRoute` 仍註冊且前端在用（`SspExcelImportService.js:56`），
  **整檔不可收**，只有那兩個類別可議——而它們的 service 方法是共用的，收了也省不到行數。
- **`app/oscal/service/oscal_export_app_service.py`**：見 4.1 的警告，框架 OSCAL 下載在用。

---

## ⑤ 要設計才能動的

### 5.1 兩支匯入匯出 service 各寫一份 Excel 欄位契約

`app/oscal/service/ssp_control_impl_import_service.py`（431 行）與
`app/module_frame/service/module_frame_template_import_service.py`（1,008 行）
各自定義了同一份 Excel 樣板的欄位契約。**實查逐項比對結果**：

| 常數 | 比對結果 |
|---|---|
| 九欄中文標題 | **逐字相同**，但變數名不同：oscal 叫 `HEADER_LABELS`、module_frame 叫 `EXPECTED_HEADERS` |
| 六個狀態值（`VALID_STATUSES`） | **值相同、排版不同**（一個寫成三行、一個寫成六行） |
| 四個配色常數（標題字／標題底／唯讀底／可編輯底） | **逐字相同** |
| 換行對齊（`WRAP_ALIGNMENT`） | **逐字相同** |
| 邊框（`THIN_BORDER`） | **逐字相同** |
| 兩個 props 名（`_P_STATUS`／`_P_DESC`） | oscal 側**不自己定義，直接 import** 自 `ssp_control_implementation_service`；module_frame 側自己寫一份 |
| `MAX_FILE_SIZE`（10 MB） | 只有 oscal 側有 |

逐字重複的約 **25 行**（module_frame 那側的檔頭第 3 行明寫「格式對齊
`app/oscal/service/ssp_control_impl_import_service.py`」）。

**為什麼現在動不了**：三個候選落點各有代價，且與「這兩個模組誰是誰的上游」這個更大的
疆界問題綁在一起——

- **抽到 `common/`**：兩邊都只是引用者，但「Excel 樣板的欄位契約」不是通用工具，
  放 `common/` 會讓 `common/` 開始長業務知識。
- **留在其中一側讓另一側引用**：現有依賴方向是 `oscal` → `module_frame`（v2 子物件
  的欄位對應表、Excel sheet 定義都在 `module_frame` 那側定義），照這個方向應該是
  `module_frame` 定義、`oscal` 引用。但 `_P_STATUS`／`_P_DESC` 的方向**恰好相反**
  （`module_frame` 自己寫一份，`oscal` 從自己模組內 import）——兩個方向並存。
- **進套件**：欄位標題是中文、是主專案的 FE 契約，套件不該知道。

**需要什麼前置**：先定「`module_frame` 與 `oscal` 誰是誰的上游」。目前跨模組 import
共 9 處、方向全是 `oscal` → `module_frame`（`app/oscal` 六支引用 `app/module_frame`
的 service 與 `excel_template/sheet_definitions`），反向只有 3 處
（`module_frame` 的 ssp-resources service、兩支 serializer、一支匯出 route）。
**方向已經很清楚是 `module_frame` 在上游**，但這與直覺相反（`oscal` 比較大、比較底層），
需要決策者確認這是刻意的還是長歪的。

### 5.2 五支 SSP 子物件殼與資源庫範本版是共用不是重複

卡片問「五支 `ssp_*_app_service` 與 `module_frame` 六支殼是否鏡像」。**實查結論：
不是兩份真相，是同一份**——五支全都**直接 import `module_frame` 那側的對應表**：

| oscal 側 | 行 | import 了 module_frame 的什麼 |
|---|---:|---|
| `ssp_inventory_items_app_service.py` | 194 | `ModuleFrameInventoryService as _InvMap`、`_build_props`、`_build_implemented` |
| `ssp_party_app_service.py` | 169 | `ModuleFramePartyService`（另在方法內兩處延遲 import 避循環） |
| `ssp_components_app_service.py` | 142 | `ModuleFrameComponentsService as _CompMap`、`_to_dict`、`_build_props` |
| `ssp_leveraged_app_service.py` | 141 | `ModuleFrameLeveragedService` |
| `ssp_system_characteristic_app_service.py` | 68 | `ModuleFrameSystemCharacteristicService as _ScMap` |

五支的檔頭都明寫「欄位 mapping 與資源庫範本版**完全相同**……故直接共用其純 mapping
靜態方法，確保兩端一致、單一真相。差別只在『ssp_id 從哪來 ＋ 權限』」。
**`module_frame` 那一側的分類表記的也是同一件事，兩邊對得上。**

`module_frame` 那側是六支（多一支 `ModuleFrameSspResourcesService`），
`oscal` 這側對應的 `SspResourcesAppService` 走的是**第三支共用 service**
（`ssp_resources_context_service.py`，住在 `oscal` 這側，被兩邊共用）——
所以嚴格說是 **5 ＋ 1 對 5 ＋ 1**，不是六對六。

**卡在哪**：這五支殼繼承了範本版的**空值哨兵**——套件的資料表有幾個欄位不准留空，
前端允許留空，主專案就塞假值進去（`party_uuid` 塞全零 UUID、`date_authorized` 塞今天），
讀出來時再換回空白。這件事在 `module_frame` 那一側的分類表也列為待裁。
**在 `oscal` 這一側看，問題更嚴重一級**：
範本 SSP 是內部資料，專案 SSP 是**租戶的實際稽核資料**，全零 UUID 與「今天」這個假日期
會跟著匯出進客戶的系統安全計畫書。目前 `_to_dict` 有換回 None，但那是讀路徑的補救——
任何第三個寫入者（例如未來新增的批次匯入）不知道這個看不見的約定，就會把假值寫成真值。

**需要什麼前置**：與 `module_frame` 那一側同一題——`jedi_oscal_v2` 除本專案外還有沒有別的使用者。
有就只能維持現狀；沒有就該把欄位改成可留空。

### 5.3 殼裡混著業務、拆不拆會影響套件契約的三支

下列三支的「殼」與「業務」纏在同一個方法裡，不是分開的區段，拆解會動到套件契約：

| 檔 | 混住情形 | 拆了會動到什麼 |
|---|---|---|
| `ssp_control_implementation_service.py`（1,004 行） | `build_control_tree_by_ssp_id` 一個方法 400 行，同時做「向套件拿 catalog 與 IR」（殼）與「疊上本輪任務狀態、上一輪結果、輪次凍結範圍」（業務）。兩者在同一個迴圈裡 | 要拆就得讓套件回一個「帶洞的樹」讓主專案填，等於在套件開一個主專案專屬的回傳形狀 |
| `framework_parse_job_service.py`（674 行） | `confirm`（118 行）裡，使用者的逐項決策（業務）與 catalog 落地（殼）交錯 | 同上，套件的 catalog 匯入要能接受「部分覆寫」的參數 |
| `resource_library_app_service.py`（541 行） | `create_resource_library`（104 行）四段編排全都是「呼叫套件 → 拿結果 → 決定下一步」，中間夾主專案的 link record 原生 SQL | 「資源庫」這個概念要不要進套件是更大的問題（套件只有 catalog／profile／SSP 三個原語，三件組是主專案組出來的） |

**這三支不列在 ④「可以立刻收的」**，因為它們不是可收，是可能該搬——而搬的前提是
先決定套件契約要不要變。

### 5.4 前端打得到、後端不存在的一條路徑

前端 `src/service/ModuleFrameTemplateService.js:198` 打
`GET /oscal/catalog-controls/<control_uid>/assessments`，常數定義在 `api.js:180`。
**後端全 repo 沒有註冊這條 route**（`grep -rn "catalog-controls" --include='*.py' .` 零命中，
套件側也零命中）。

呼叫者是 `ModuleFrameTemplateEditView.vue:566` 的 `loadAOsForControl()`。
**現況不會壞**，因為那段程式碼上方註解寫明「AO 已在 `buildTreeFromProfile` 從
`module_frame.oscal_profile` 預載，此處 cache 應該總是命中。走 API 只是防呆」，
且整段包在 `try/catch` 裡、失敗只 `console.warn` 並回空陣列。

**卡在哪**：這是個沉默的後備路徑——真的走到（快取沒命中）時會拿到 404，
然後靜默回空陣列，前端的 AO 清單變空白、**不報錯**。要嘛後端補這條 route，
要嘛前端把這個後備拿掉改成明確報錯。屬前端與後端契約問題，不在本卡的分析範圍內。

**另外兩條同型的**（都在 `ComplianceFrameworkVersionManage.vue`）：
`OSCAL_FRAMEWORK_VERSION_IMPORT`（`/oscal-framework-version/import/<type>`）與
`OSCAL_FRAMEWORK_VERSION_SAMPLE_DOWNLOAD`（`/oscal-framework-version/download/sample`）
後端也都不存在。但這兩條的前端入口本身是關著的——開啟該對話框的選單項
`onUpdateVersionDocClick` 在 `:596-599` 被整段註解（註解寫「v2 兩階段化後暫時隱藏」），
所以使用者點不到。**三條合起來看是同一件事：框架匯入從舊的「上傳整份文件」
改成新的「解析工作」兩階段流程後，前端留下了指向舊端點的殘骸。**

---

## 需要決策者裁的

本表遇到「兩種合理判法、不自選」的有三件：

### 一、`module_frame` 與 `oscal` 誰是誰的上游

跨模組 import 的方向已經很清楚是 **`oscal` 依賴 `module_frame`**（9 處對 3 處），
包括五支 SSP 子物件的欄位對應表、Excel sheet 定義。但這與規模直覺相反
（`oscal` 兩倍大、概念上更底層）。

- **維持現狀**（`module_frame` 是上游）：零風險，但每個新來的人看到「大模組依賴小模組」
  都會想反過來改，而改了會壞。
- **反轉**（把共用的欄位對應表搬去 `oscal`）：符合直覺，但要動五支 SSP 子物件 service
  ＋六支 `module_frame` service，且 `module_frame` 那側的 Excel 樣板產生器（1,656 行）
  也在被 `oscal` 引用，一併反轉範圍會很大。

**判斷需要的資訊**：這兩個模組未來會不會有一個進套件。若 `module_frame`（資源庫）
要進套件，現在的方向剛好（套件不能依賴主專案）；若 `oscal` 要進，方向就是錯的。

### 二、5.1 那約 25 行 Excel 欄位契約要不要抽、抽去哪

`module_frame` 那一側的分類表也把這題列為待裁，建議兩個模組的結果一起看。
從 `oscal` 這一側實測，**逐字重複約 25 行**，但**變數名不一致**
（`HEADER_LABELS` vs `EXPECTED_HEADERS`）反而是更麻煩的部分——兩邊各改一次時，
grep 同一個名字只會找到一半。

三個候選落點與代價見 5.1。**這題與第一題綁死，建議一起裁。**

### 三、四類「對外不可達」的東西要不要清

| 項目 | 行數 | 收掉的風險 |
|---|---:|---|
| 4.1 被註解的 `oscal_export_route.py` | 39 | 低——但要一併修三處指向它的過期註解 |
| 4.2 資源庫詳情與發布兩條端點 | — | 🔴 **高**——發布是公版治理的唯一途徑，收了等於砍功能。建議只清「詳情」那條或兩條都不動 |
| 4.3 五支死序列化器 ＋ 五行 re-export | 87 | 中——要同步移除守衛測試白名單三行，且名字極易誤刪（見 4.3 的兩個坑） |
| 5.4 前端三條指向不存在端點的殘骸 | — | 中——屬前端 repo，且其中一條是沉默的後備路徑，拿掉前要確認快取真的總是命中 |

「清」屬異動，本案只分析不動程式。
