# Module Frame Template Defaults — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 把「現況說明 + 程序書」抽成 module_frame 層的模板預設值；啟動專案時 UPDATE-in-place 進 SSP，避免每個專案重複填寫。

**Architecture:** Component Definition Lite — 在 `compliance` schema 新增 4 張表（control defaults / objective defaults / reference documents / mappings），對應 SSP 端結構。啟動專案時對 Step 7.5 已建好的空殼 SSP rows 做 UPDATE in place（不 INSERT，避免 unique constraint 衝突）。並修補 `_clone_ssp_objectives` 既有 bug 確保 round 2 程序書不消失。

**Tech Stack:** Python 3.11 / Flask-RESTful / SQLAlchemy / dependency-injector / PostgreSQL / pytest / Vue 3 + PrimeVue / vue-i18n

**Spec reference:** `docs/features/FR-018-2604-module-frame-template-defaults/design.md`

---

## File Structure

### Backend — New Files

**SQL migration:**
- `scripts/sql/2026-04-25-module-frame-template-defaults.sql`

**Domain layer (4 entity sets):**
- `domain/module_frame/entity/module_frame_control_default_entity.py` + `_query_entity.py`
- `domain/module_frame/entity/module_frame_control_objective_default_entity.py` + `_query_entity.py`
- `domain/module_frame/entity/module_frame_reference_document_entity.py` + `_query_entity.py`
- `domain/module_frame/entity/module_frame_reference_document_mapping_entity.py` + `_query_entity.py`
- `domain/module_frame/repository/module_frame_control_default_repo.py` (interface)
- `domain/module_frame/repository/module_frame_control_objective_default_repo.py`
- `domain/module_frame/repository/module_frame_reference_document_repo.py`
- `domain/module_frame/repository/module_frame_reference_document_mapping_repo.py`
- `domain/module_frame/service/module_frame_control_default_domain_service.py`
- `domain/module_frame/service/module_frame_control_objective_default_domain_service.py`
- `domain/module_frame/service/module_frame_reference_document_domain_service.py`
- `domain/module_frame/service/module_frame_reference_document_mapping_domain_service.py`

**Infra layer:**
- `infra/module_frame/models/module_frame_control_default.py`
- `infra/module_frame/models/module_frame_control_objective_default.py`
- `infra/module_frame/models/module_frame_reference_document.py`
- `infra/module_frame/models/module_frame_reference_document_mapping.py`
- `infra/module_frame/mapper/module_frame_control_default_mapper.py`
- `infra/module_frame/mapper/module_frame_control_objective_default_mapper.py`
- `infra/module_frame/mapper/module_frame_reference_document_mapper.py`
- `infra/module_frame/mapper/module_frame_reference_document_mapping_mapper.py`
- `infra/module_frame/repository/module_frame_control_default_repo_impl.py`
- `infra/module_frame/repository/module_frame_control_objective_default_repo_impl.py`
- `infra/module_frame/repository/module_frame_reference_document_repo_impl.py`
- `infra/module_frame/repository/module_frame_reference_document_mapping_repo_impl.py`

**App layer:**
- `app/module_frame/dto/module_frame_control_default_dto.py`
- `app/module_frame/dto/module_frame_control_objective_default_dto.py`
- `app/module_frame/dto/module_frame_reference_document_dto.py`
- `app/module_frame/service/module_frame_control_default_service.py`
- `app/module_frame/service/module_frame_control_objective_default_service.py`
- `app/module_frame/service/module_frame_reference_document_service.py`
- `app/module_frame/service/module_frame_template_copy_service.py` (核心對拷邏輯)
- `app/module_frame/service/module_frame_template_import_service.py` (Excel 批次)

**API layer:**
- `api/module_frame/serializers/module_frame_control_default.py`
- `api/module_frame/serializers/module_frame_control_objective_default.py`
- `api/module_frame/serializers/module_frame_reference_document.py`
- `api/module_frame/routes/module_frame_control_default_route.py`
- `api/module_frame/routes/module_frame_control_objective_default_route.py`
- `api/module_frame/routes/module_frame_reference_document_route.py`
- `api/module_frame/routes/module_frame_reference_document_mapping_route.py`
- `api/module_frame/routes/module_frame_template_import_route.py`
- `api/oscal/routes/catalog_control_assessment_route.py`（如已有則跳過）

**Common / Containers:**
- `common/code/module_frame_error_code.py`（新檔）

**Tests:**
- `test/test_module_frame_control_default_service.py`
- `test/test_module_frame_control_objective_default_service.py`
- `test/test_module_frame_reference_document_service.py`
- `test/test_module_frame_template_copy_service.py`（最重要）
- `test/test_module_frame_template_import_service.py`
- `test/test_oscal_project_service_template_copy_integration.py`
- `test/test_clone_ssp_objectives_reference_documents.py`

### Backend — Modify

- `infra/module_frame/models/module_frame.py`：加 relationships（optional cascade）
- `app/project/service/oscal_project_service.py`：
  - `start_oscal_project()` 加呼叫 `_copy_template_to_ssp()`
  - `_clone_ssp_objectives()` 補一行 copy `reference_documents` (§5.5)
- `di_containers/module_frame/module_frame_containers.py`：註冊新 services + repos
- `di_containers/project/...`：inject `module_frame_template_copy_service` 進 `OscalProjectService`

### jedi-oscal package — Modify

- `jedi_oscal/infra/model/ssp/ssp.py`：加 `template_module_frame_id` 欄位（nullable Integer）
- `jedi_oscal/infra/mapper/ssp/ssp_mapper.py`：round-trip 新欄位
- `jedi_oscal/domain/entity/ssp/ssp_entity.py`：加新欄位
- 進版（minor bump）+ 發佈 Nexus

### Frontend — New Files

- `compliance-manager-fe/src/views/module_frame/ModuleFrameTemplateEditView.vue`
- `compliance-manager-fe/src/components/grc/ModuleFrameTemplateImportDialog.vue`（fork `SspImportDialog.vue`）
- `compliance-manager-fe/src/service/ModuleFrameTemplateService.js`

### Frontend — Modify

- `compliance-manager-fe/src/views/module_frame/ModuleFrame.vue`：列表加「編輯預設值」按鈕、建立完跳轉
- `compliance-manager-fe/src/config/api/api.js`：加新 endpoint 常數
- `compliance-manager-fe/src/config/router/index.js`：加 `/module-frame/:uid/template-edit` route
- `compliance-manager-fe/src/config/locales/i18n/zh-tw/module-frame.json`：新 key
- `compliance-manager-fe/src/config/locales/i18n/en/module-frame.json`：新 key

---

## Task Sequence Overview

```
Phase 0: jedi-oscal 進版（前置阻擋）
  Task 0

Phase 1: BE Schema + DDD 基礎層
  Task 1: SQL migration
  Task 2: ORM Models (4)
  Task 3: Domain Entities + Repo Interfaces (4 sets)
  Task 4: Infra Mappers + Repo Impls (4 sets)
  Task 5: Domain Services (4)

Phase 2: BE App Services
  Task 6: ErrorCode + Control Default Service
  Task 7: Objective Default Service
  Task 8: Reference Document Service (含 mappings)
  Task 9: Template Copy Service ⭐ 核心
  Task 10: Template Import Service (Excel)

Phase 3: BE API
  Task 11: Serializers + Control Default Routes
  Task 12: Objective Default Routes
  Task 13: Reference Document + Mapping Routes
  Task 14: Template Import/Export Routes
  Task 15: Catalog AO 列舉 endpoint
  Task 16: DI Containers Wiring

Phase 4: BE 整合
  Task 17: Hook into start_oscal_project + Fix _clone_ssp_objectives ⭐ 關鍵
  Task 18: Backward compat 整合測試 (空模板 + 既有專案啟動)
  Task 19: Golden path 整合測試 + Round 2 程序書 survival 測試

Phase 5: FE Service + 路由
  Task 20: ModuleFrameTemplateService.js + api.js + router + i18n

Phase 6: FE 詳情頁
  Task 21: TemplateEditView 骨架 + Tree
  Task 22: 控制項詳情編輯 + 自動儲存
  Task 23: AO Accordion 編輯
  Task 24: 程序書區塊（reuse Planning 元件）
  Task 25: Stats bar + Header

Phase 7: FE 批次 + 入口
  Task 26: Fork SspImportDialog → ModuleFrameTemplateImportDialog
  Task 27: ModuleFrame 列表入口 + 跳轉

Phase 8: 收尾
  Task 28: Manual e2e 驗證 + 文件交付 (changelog)
```

---

## Task 0: jedi-oscal 加 template_module_frame_id 欄位

**Files (jedi-oscal package, 路徑相對於 `~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal/`):**
- Modify: `jedi_oscal/infra/model/ssp/ssp.py`
- Modify: `jedi_oscal/infra/mapper/ssp/ssp_mapper.py`
- Modify: `jedi_oscal/domain/entity/ssp/ssp_entity.py`
- Modify: `pyproject.toml` (進版 0.0.X → 0.0.X+1)

- [ ] **Step 1: 編輯 SSP ORM model** 加欄位

`ssp.py` 新增：
```python
template_module_frame_id: Mapped[Optional[int]] = mapped_column(
    Integer,
    nullable=True,
    comment="此 SSP 的源頭 module_frame ID（template lineage tracking）",
)
```

- [ ] **Step 2: Mapper round-trip 新欄位**

`ssp_mapper.py` 在 `to_entity` 加 `template_module_frame_id=getattr(model, 'template_module_frame_id', None)`，`to_model` 加 `m.template_module_frame_id = getattr(entity, 'template_module_frame_id', None)`

- [ ] **Step 3: Domain entity 加屬性**

`ssp_entity.py` `OscalSystemSecurityPlanEntity` 加 `template_module_frame_id: Optional[int] = None`

- [ ] **Step 4: Bump version**

`pyproject.toml` 把版本號 minor bump（如 `0.0.8` → `0.0.9`）。**等使用者明確指示後才發 Nexus**（按 CLAUDE.md feedback_jedi_package_publish_flow）。

- [ ] **Step 5: 寫 round-trip 單元測試**

在 jedi-oscal repo 加 `tests/test_ssp_template_module_frame_id.py`：
```python
def test_ssp_template_module_frame_id_round_trip(session):
    # Arrange: create SSP with template_module_frame_id=42
    ssp = OscalSystemSecurityPlan(
        profile_id=1, metadata_id=1, document_id=1, status='draft',
        template_module_frame_id=42,
    )
    session.add(ssp); session.commit()

    # Act: reload
    reloaded = session.query(OscalSystemSecurityPlan).filter_by(id=ssp.id).first()

    # Assert: column persists
    assert reloaded.template_module_frame_id == 42

    # Mapper round-trip
    entity = SspMapper.to_entity(reloaded)
    assert entity.template_module_frame_id == 42
```

- [ ] **Step 6: 跑 jedi-oscal test**

```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal
pytest tests/test_ssp_template_module_frame_id.py -v
```
Expected: PASS

- [ ] **Step 7: 驗證主專案 import 不爆**

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
python -c "from jedi_oscal.infra.model.ssp.ssp import OscalSystemSecurityPlan; print('template_module_frame_id' in OscalSystemSecurityPlan.__table__.c)"
```
Expected: `True`

- [ ] **Step 8: Commit (jedi-oscal repo)**

```bash
cd ~/Projects/Jedicogy/module/jedi-python-package/jedi-oscal
git add jedi_oscal/infra/model/ssp/ssp.py jedi_oscal/infra/mapper/ssp/ssp_mapper.py jedi_oscal/domain/entity/ssp/ssp_entity.py pyproject.toml
git commit -m "feat(ssp): add template_module_frame_id for module_frame lineage"
```

---

## Task 1: SQL Migration（4 新表 + ALTER + GRANT）

**Files:**
- Create: `scripts/sql/2026-04-25-module-frame-template-defaults.sql`

- [ ] **Step 1: 建立 SQL 檔案，按 CLAUDE.md 規範包含日期註解 + GRANT**

完整內容：

```sql
-- Date: 2026-04-25
-- Module Frame Template Defaults — 控制項/AO 預設值 + 程序書池

-- 1. 控制項層預設值 (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_control_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,
    implementation_status       VARCHAR(30) DEFAULT 'unknown',
    implementation_description  TEXT,
    responsible_role            VARCHAR(100),
    control_origination         VARCHAR(50) DEFAULT 'organization',
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcd_module_frame_control UNIQUE (module_frame_id, control_identifier)
);
CREATE INDEX IF NOT EXISTS ix_mfcd_module_frame_id
    ON compliance.module_frame_control_defaults (module_frame_id);

-- 2. AO 層預設值 (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_control_objective_defaults (
    id                          SERIAL PRIMARY KEY,
    uid                         UUID DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id             INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    control_default_id          INTEGER NOT NULL
        REFERENCES compliance.module_frame_control_defaults(id) ON DELETE CASCADE,
    control_identifier          VARCHAR(100) NOT NULL,
    statement_identifier        VARCHAR(100) NOT NULL,
    implementation_status       VARCHAR(50),
    implementation_description  TEXT,
    remarks                     TEXT,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    updated_user                VARCHAR(50),
    CONSTRAINT uq_mfcod_ctrl_default_stmt UNIQUE (control_default_id, statement_identifier)
);
CREATE INDEX IF NOT EXISTS ix_mfcod_module_frame_id
    ON compliance.module_frame_control_objective_defaults (module_frame_id);
CREATE INDEX IF NOT EXISTS ix_mfcod_control_default_id
    ON compliance.module_frame_control_objective_defaults (control_default_id);

-- 3. 程序書池 (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_reference_documents (
    id                  SERIAL PRIMARY KEY,
    uid                 VARCHAR(36) DEFAULT gen_random_uuid() NOT NULL UNIQUE,
    module_frame_id     INTEGER NOT NULL
        REFERENCES public.module_frames(id) ON DELETE CASCADE,
    file_id             INTEGER NOT NULL,
    title               VARCHAR(255),
    description         TEXT,
    created_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at          TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user        VARCHAR(50),
    updated_user        VARCHAR(50)
);
CREATE INDEX IF NOT EXISTS ix_mfrd_module_frame_id
    ON compliance.module_frame_reference_documents (module_frame_id);
CREATE INDEX IF NOT EXISTS ix_mfrd_file_id
    ON compliance.module_frame_reference_documents (file_id);

-- 4. 程序書多對多 mappings (2026-04-25)
CREATE TABLE IF NOT EXISTS compliance.module_frame_reference_document_mappings (
    id                          SERIAL PRIMARY KEY,
    reference_document_id       INTEGER NOT NULL
        REFERENCES compliance.module_frame_reference_documents(id) ON DELETE CASCADE,
    context_type                VARCHAR(30) NOT NULL,
    context_id                  INTEGER NOT NULL,
    created_at                  TIMESTAMPTZ DEFAULT now() NOT NULL,
    created_user                VARCHAR(50),
    CONSTRAINT uq_mfrdm_doc_context UNIQUE (reference_document_id, context_type, context_id),
    CONSTRAINT chk_mfrdm_context_type CHECK (context_type IN ('control_default', 'objective_default'))
);
CREATE INDEX IF NOT EXISTS ix_mfrdm_context
    ON compliance.module_frame_reference_document_mappings (context_type, context_id);
CREATE INDEX IF NOT EXISTS ix_mfrdm_doc_id
    ON compliance.module_frame_reference_document_mappings (reference_document_id);

-- 5. SSP 加 template lineage 欄位 (2026-04-25)
ALTER TABLE oscal.system_security_plans
    ADD COLUMN IF NOT EXISTS template_module_frame_id INTEGER NULL;

-- 6. 權限授予 cm_app (2026-04-25)
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_control_objective_defaults TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_control_objective_defaults_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_documents TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_documents_id_seq TO cm_app;

GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.module_frame_reference_document_mappings TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.module_frame_reference_document_mappings_id_seq TO cm_app;
```

- [ ] **Step 2: 在本機 dev DB 跑 migration 驗證**

```bash
psql $DB_URL -f scripts/sql/2026-04-25-module-frame-template-defaults.sql
```
Expected: 4 CREATE TABLE + 1 ALTER + GRANT 全部成功，無錯誤

- [ ] **Step 3: 驗證 schema 與 cm_app 權限**

```bash
psql $DB_URL -c "\d compliance.module_frame_control_defaults"
psql $DB_URL -c "SELECT has_table_privilege('cm_app', 'compliance.module_frame_control_defaults', 'INSERT');"
```
Expected: 表結構正確，權限 = `t`

- [ ] **Step 4: 確認 ap_task 索引存在（spike Q3）**

```bash
psql $DB_URL -c "\di oscal.assessment_plan_tasks*"
```
Expected: 有 index on `catalog_control_assessment_id`。若無則加 `CREATE INDEX IF NOT EXISTS ix_ap_task_ccai_id ON oscal.assessment_plan_tasks (catalog_control_assessment_id);` 到 migration

- [ ] **Step 5: Commit**

```bash
git add scripts/sql/2026-04-25-module-frame-template-defaults.sql
git commit -m "feat(db): module_frame template defaults schema (4 tables + SSP column)"
```

---

## Task 2: ORM Models（4 張新表）

**Files:**
- Create: `infra/module_frame/models/module_frame_control_default.py`
- Create: `infra/module_frame/models/module_frame_control_objective_default.py`
- Create: `infra/module_frame/models/module_frame_reference_document.py`
- Create: `infra/module_frame/models/module_frame_reference_document_mapping.py`

- [ ] **Step 1: 參考既有 model 寫法**

讀 `infra/module_frame/models/module_frame.py` 跟 `infra/grc/model/ssp_reference_document_mapping.py`（如果有），確認 schema 命名、ENUM 用法、TenantScopedMixinModel 是否需要。

→ 新表透過 module_frame_id FK 間接綁 tenant，**不繼承** TenantScopedMixinModel。

- [ ] **Step 2: 建立 4 個 ORM model 檔案**

每個 model 對應 §3.1 的 schema。範例 `module_frame_control_default.py`：

```python
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Integer, String, Text, ForeignKey, UUID, DateTime, func, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from jedi_common.session.database.declarative_base import Base


class ModuleFrameControlDefault(Base):
    __tablename__ = "module_frame_control_defaults"
    __table_args__ = (
        UniqueConstraint("module_frame_id", "control_identifier", name="uq_mfcd_module_frame_control"),
        Index("ix_mfcd_module_frame_id", "module_frame_id"),
        {"schema": "compliance"},
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    uid: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
    module_frame_id: Mapped[int] = mapped_column(ForeignKey("public.module_frames.id", ondelete="CASCADE"), nullable=False)
    control_identifier: Mapped[str] = mapped_column(String(100), nullable=False)
    implementation_status: Mapped[Optional[str]] = mapped_column(String(30), default="unknown")
    implementation_description: Mapped[Optional[str]] = mapped_column(Text)
    responsible_role: Mapped[Optional[str]] = mapped_column(String(100))
    control_origination: Mapped[Optional[str]] = mapped_column(String(50), default="organization")
    remarks: Mapped[Optional[str]] = mapped_column(Text)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), onupdate=func.now(), nullable=False)
    created_user: Mapped[Optional[str]] = mapped_column(String(50))
    updated_user: Mapped[Optional[str]] = mapped_column(String(50))
```

其他三個 model 結構類似（按 §3.1.2-4 schema）。`module_frame_reference_document.uid` 用 `String(36)` 沿用 SSP convention。

- [ ] **Step 3: 跑 app boot 驗證 model 註冊正確**

```bash
ENV=DEVELOP_PREMISE python -c "from infra.module_frame.models.module_frame_control_default import ModuleFrameControlDefault; print(ModuleFrameControlDefault.__table__)"
```
Expected: 印出 table object，沒有 SQLAlchemy 錯誤

- [ ] **Step 4: Commit**

```bash
git add infra/module_frame/models/
git commit -m "feat(infra): module_frame template defaults ORM models"
```

---

## Task 3: Domain Entities + Repo Interfaces

**Files:**
- Create: `domain/module_frame/entity/module_frame_control_default_entity.py` + `_query_entity.py`
- Create: `domain/module_frame/entity/module_frame_control_objective_default_entity.py` + `_query_entity.py`
- Create: `domain/module_frame/entity/module_frame_reference_document_entity.py` + `_query_entity.py`
- Create: `domain/module_frame/entity/module_frame_reference_document_mapping_entity.py` + `_query_entity.py`
- Create: `domain/module_frame/repository/module_frame_control_default_repo.py`
- Create: `domain/module_frame/repository/module_frame_control_objective_default_repo.py`
- Create: `domain/module_frame/repository/module_frame_reference_document_repo.py`
- Create: `domain/module_frame/repository/module_frame_reference_document_mapping_repo.py`

- [ ] **Step 1: 參考既有 entity 寫法**

讀 `domain/module_frame/entity/module_frame_entity.py` 跟 `module_frame_query_entity.py`，跟著模式建立。

- [ ] **Step 2: 為每組建立 2 個檔案**

`*_entity.py`（dataclass，full record）+ `*_query_entity.py`（dataclass，optional fields for filtering）。共 8 個檔案。

- [ ] **Step 3: 為每組建立 repo interface**

抽象 method 至少包含：
- `get_one(query_entity)` → entity 或 None
- `get_all(query_entity)` → list[entity]
- `add(entity)` → entity
- `update(entity)` → entity
- `delete_by_id(id)` → bool
- `bulk_add(entities)` → list[entity]（給對拷用，optional）

- [ ] **Step 4: Commit**

```bash
git add domain/module_frame/entity/ domain/module_frame/repository/
git commit -m "feat(domain): module_frame template defaults entities + repo interfaces"
```

---

## Task 4: Infra Mappers + Repo Impls

**Files:**
- Create: `infra/module_frame/mapper/module_frame_control_default_mapper.py` + 3 others
- Create: `infra/module_frame/repository/module_frame_control_default_repo_impl.py` + 3 others

- [ ] **Step 1: 參考既有 mapper / repo_impl**

讀 `infra/module_frame/mapper/module_frame_mapper.py` + `infra/module_frame/repository/module_frame_repo_impl.py`。

- [ ] **Step 2: 為每組建立 mapper**

雙向 entity ↔ model（`to_entity` + `to_model` + `to_list_entity`）。

- [ ] **Step 3: 為每組建立 repo impl**

實作 interface 所有 method，使用 `self.session`。**禁止 cross-schema FK constraint**，跨 schema 查詢用 Integer soft reference。

- [ ] **Step 4: Commit**

```bash
git add infra/module_frame/mapper/ infra/module_frame/repository/
git commit -m "feat(infra): module_frame template defaults mappers + repo impls"
```

---

## Task 5: Domain Services

**Files:**
- Create: `domain/module_frame/service/module_frame_control_default_domain_service.py`
- Create: `domain/module_frame/service/module_frame_control_objective_default_domain_service.py`
- Create: `domain/module_frame/service/module_frame_reference_document_domain_service.py`
- Create: `domain/module_frame/service/module_frame_reference_document_mapping_domain_service.py`

- [ ] **Step 1: 參考既有 domain service**

讀 `domain/module_frame/service/module_frame_domain_service.py`。

- [ ] **Step 2: 建立 4 個 domain service**

每個包含薄封裝（轉發 query_entity 給 repo）。集中業務驗證但**不**涉及 SQL。

- [ ] **Step 3: Commit**

```bash
git add domain/module_frame/service/
git commit -m "feat(domain): module_frame template defaults domain services"
```

---

## Task 6: ErrorCode + Control Default App Service

**Files:**
- Create: `common/code/module_frame_error_code.py`
- Create: `app/module_frame/dto/module_frame_control_default_dto.py`
- Create: `app/module_frame/service/module_frame_control_default_service.py`
- Test: `test/test_module_frame_control_default_service.py`

- [ ] **Step 1: 建立 ErrorCode 檔**

按 CLAUDE.md 命名規範：

```python
from jedi_common.enums.base_code import BaseCode

class ModuleFrameErrorCode(BaseCode):
    MODULE_FRAME_CONTROL_DEFAULT_NOT_FOUND   = ("模板控制項預設值不存在", "MODULE_FRAME_404001")
    MODULE_FRAME_OBJECTIVE_DEFAULT_NOT_FOUND = ("模板 AO 預設值不存在",   "MODULE_FRAME_404002")
    MODULE_FRAME_REFERENCE_DOC_NOT_FOUND     = ("模板程序書不存在",        "MODULE_FRAME_404003")
    MODULE_FRAME_CONTROL_NOT_IN_PROFILE      = ("控制項不屬於此模板的 profile", "MODULE_FRAME_400001")
    MODULE_FRAME_OBJECTIVE_NOT_VALID         = ("AO 不屬於該控制項",       "MODULE_FRAME_400002")
```

- [ ] **Step 2: DTO 結構**

`ModuleFrameControlDefaultDto`：含 uid / control_identifier / implementation_status / implementation_description / responsible_role / control_origination / remarks + 審計欄位。

- [ ] **Step 3: TDD — 寫失敗測試**

`test/test_module_frame_control_default_service.py`：

```python
def test_upsert_creates_row_when_not_exists():
    # Arrange: existing module_frame with a profile containing AC.L1-3.1.1
    # Act: service.upsert(uid, "AC.L1-3.1.1", payload)
    # Assert: row exists in module_frame_control_defaults

def test_upsert_updates_row_when_exists():
    # Arrange: existing default row
    # Act: service.upsert(uid, control_id, new payload)
    # Assert: row updated, no duplicate

def test_upsert_raises_when_control_not_in_profile():
    # Act + Assert: raises BadRequestError(MODULE_FRAME_CONTROL_NOT_IN_PROFILE)
```

執行：
```bash
pytest test/test_module_frame_control_default_service.py -v
```
Expected: FAIL（service 還沒寫）

- [ ] **Step 4: 寫 App Service**

`module_frame_control_default_service.py` — 含 `@transaction` 裝飾器、權限檢查（manager 角色）、profile 驗證、upsert 邏輯。

- [ ] **Step 5: 跑測試 PASS**

```bash
pytest test/test_module_frame_control_default_service.py -v
```
Expected: 3 PASSED

- [ ] **Step 6: Commit**

```bash
git add common/code/module_frame_error_code.py app/module_frame/dto/ app/module_frame/service/module_frame_control_default_service.py test/test_module_frame_control_default_service.py
git commit -m "feat(app): module_frame control default service + tests"
```

---

## Task 7: Objective Default App Service

**Files:**
- Create: `app/module_frame/dto/module_frame_control_objective_default_dto.py`
- Create: `app/module_frame/service/module_frame_control_objective_default_service.py`
- Test: `test/test_module_frame_control_objective_default_service.py`

- [ ] **Step 1: TDD test cases**

```python
def test_upsert_objective_default_creates_row()
def test_upsert_validates_statement_identifier_belongs_to_control()  # ccai must exist for that control
def test_upsert_raises_when_control_default_not_exists()
def test_delete_objective_default()
def test_list_by_module_frame()
```

- [ ] **Step 2: Service 邏輯**

PUT body 含 `control_identifier` + `statement_identifier`（catalog_control_assessment.uid）。Service 驗證：
1. module_frame 存在
2. control_default 存在（如沒有，先 lazy 建一筆）
3. statement_identifier 是該 control 的 catalog_control_assessment.uid
4. Upsert objective_default

- [ ] **Step 3: Commit**

```bash
git add app/module_frame/dto/module_frame_control_objective_default_dto.py app/module_frame/service/module_frame_control_objective_default_service.py test/test_module_frame_control_objective_default_service.py
git commit -m "feat(app): module_frame objective default service + tests"
```

---

## Task 8: Reference Document App Service（含 mappings）

**Files:**
- Create: `app/module_frame/dto/module_frame_reference_document_dto.py`
- Create: `app/module_frame/service/module_frame_reference_document_service.py`
- Test: `test/test_module_frame_reference_document_service.py`

- [ ] **Step 1: TDD test cases**

```python
def test_upload_file_creates_doc_in_pool()
def test_attach_doc_to_control_default()
def test_attach_doc_to_objective_default()
def test_detach_mapping_keeps_doc_in_pool()
def test_delete_doc_cascades_mappings_but_keeps_file()
def test_list_pool_includes_mapping_count()
```

- [ ] **Step 2: Service 邏輯**

包含：
- `upload(module_frame_uid, file, meta)` — 走既有 jedi-file-upload 拿 file_id，INSERT 一筆 module_frame_reference_documents
- `attach_to_control_default(...)` / `attach_to_objective_default(...)` — INSERT mappings
- `detach(...)` — DELETE 單筆 mapping（不刪 doc）
- `delete_doc(doc_uid)` — DELETE doc（CASCADE mappings）
- `list_pool(module_frame_uid)` — 查池 + 每筆 mapping count

- [ ] **Step 3: Commit**

```bash
git add app/module_frame/dto/module_frame_reference_document_dto.py app/module_frame/service/module_frame_reference_document_service.py test/test_module_frame_reference_document_service.py
git commit -m "feat(app): module_frame reference document service + mappings + tests"
```

---

## Task 9: Template Copy Service ⭐ 核心對拷邏輯

**Files:**
- Create: `app/module_frame/service/module_frame_template_copy_service.py`
- Test: `test/test_module_frame_template_copy_service.py`

按 design.md §5.0~§5.5。

- [ ] **Step 1: TDD — 失敗測試**

`test_module_frame_template_copy_service.py`：

```python
def test_copy_with_empty_template_is_noop():
    # Arrange: module_frame 無任何 defaults; SSP 已被 Step 7.5 建好空殼
    # Act: copy_service.copy(module_frame_id, ssp_id, ap_id, "tester")
    # Assert: SSP control_impl + objectives 內容完全與 Step 7.5 後一致（NULL desc, status='unknown'）

def test_copy_control_defaults_updates_in_place():
    # Arrange: module_frame 有 1 control_default; Step 7.5 已建空殼 ssp_control_impl
    # Act: copy()
    # Assert: ssp_control_impl 該 row 的 description / responsible_role / origination 被填上模板值
    #         row 數量沒變（沒 INSERT 新 row）

def test_copy_objective_defaults_updates_in_place():
    # 同上但 AO 層
    # 關鍵：用 COALESCE(task_code, title, id::text) 對應 statement_identifier

def test_copy_objective_skips_controls_without_ccai():
    # Arrange: 一個 control 在 catalog 端沒有 catalog_control_assessment（fallback "Evidence upload" path）
    # Act: copy()
    # Assert: 沒有 AO row 被影響；log 出現 INFO「N controls had no CCA」

def test_copy_control_reference_documents_creates_ssp_mapping_rows():
    # 測 Step 3a (mappings 表)

def test_copy_objective_reference_documents_writes_jsonb():
    # 測 Step 3b (JSONB inline)

def test_copy_marks_ssp_template_module_frame_id():
    # Assert: ssp.template_module_frame_id == module_frame_id after copy

def test_copy_failure_rollback_via_transaction():
    # 故意讓某一步失敗，驗證整 transaction 回滾

def test_copy_reference_document_shares_same_file_id():
    # 對應 design.md §5.3：file_id 共享，不複製檔案本體
    # Arrange: module_frame_reference_documents.file_id = 999
    # Act: copy()
    # Assert: oscal.ssp_reference_documents.file_id == 999 (not a new file_id)
```

執行：
```bash
pytest test/test_module_frame_template_copy_service.py -v
```
Expected: ALL FAIL

- [ ] **Step 2: 寫 Copy Service 主體**

`module_frame_template_copy_service.py`：

```python
from sqlalchemy import text
from jedi_common.session.database.session_context import get_session
from jedi_common.handler.exception import BadRequestError, NotFound
from common.code.module_frame_error_code import ModuleFrameErrorCode
import logging

logger = logging.getLogger(__name__)


class ModuleFrameTemplateCopyService:
    """
    對 Step 7.5 已建好的 SSP 空殼 row 做 UPDATE in place。
    不 INSERT 任何 control_impl / objective row。

    重要：本 service 必須在 @transaction-decorated method 裡呼叫
    （session 由 jedi_common get_session() 取得，與呼叫端共享 transaction scope）
    """

    def __init__(self, module_frame_control_default_domain_service,
                 module_frame_control_objective_default_domain_service,
                 module_frame_reference_document_domain_service,
                 module_frame_reference_document_mapping_domain_service):
        self._ctrl_default_ds = module_frame_control_default_domain_service
        self._obj_default_ds = module_frame_control_objective_default_domain_service
        self._ref_doc_ds = module_frame_reference_document_domain_service
        self._ref_doc_mapping_ds = module_frame_reference_document_mapping_domain_service

    def copy(self, module_frame_id: int, ssp_id: int, ap_id: int, curr_user: str) -> dict:
        """
        Returns: {"controls_updated": N, "objectives_updated": M, "ref_docs_copied": K}
        Raises: 任何 SQL/驗證錯誤都 raise 出去，由呼叫端 @transaction rollback
        """
        session = get_session()

        # Step 1: 控制項層 UPDATE in place
        ctrl_result = session.execute(text("""
            UPDATE oscal.system_security_plan_control_implementations ci
            SET
              implementation_status = COALESCE(mfcd.implementation_status, ci.implementation_status),
              implementation_description = COALESCE(mfcd.implementation_description, ci.implementation_description),
              responsible_role = COALESCE(mfcd.responsible_role, ci.responsible_role),
              control_origination = COALESCE(mfcd.control_origination, ci.control_origination),
              remarks = COALESCE(mfcd.remarks, ci.remarks),
              updated_user = :curr_user,
              updated_at = now()
            FROM compliance.module_frame_control_defaults mfcd
            WHERE mfcd.module_frame_id = :mf_id
              AND ci.system_security_plan_id = :ssp_id
              AND ci.control_identifier = mfcd.control_identifier
        """), {"mf_id": module_frame_id, "ssp_id": ssp_id, "curr_user": curr_user})
        controls_updated = ctrl_result.rowcount

        # Step 2: AO 層 UPDATE in place
        obj_result = session.execute(text("""
            UPDATE oscal.ssp_control_implementation_objectives obj
            SET
              implementation_status = COALESCE(mfod.implementation_status, obj.implementation_status),
              implementation_description = COALESCE(mfod.implementation_description, obj.implementation_description),
              remarks = COALESCE(mfod.remarks, obj.remarks),
              updated_user = :curr_user,
              updated_at = now()
            FROM compliance.module_frame_control_objective_defaults mfod
            JOIN oscal.catalog_control_assessments ccai
              ON ccai.uid::text = mfod.statement_identifier
            JOIN oscal.assessment_plan_tasks ap_task
              ON ap_task.catalog_control_assessment_id = ccai.id
             AND ap_task.assessment_plan_id = :ap_id
            WHERE mfod.module_frame_id = :mf_id
              AND obj.system_security_plan_id = :ssp_id
              AND obj.control_identifier = mfod.control_identifier
              AND obj.statement_identifier = COALESCE(ap_task.task_code, ap_task.title, ap_task.id::text)
        """), {"mf_id": module_frame_id, "ssp_id": ssp_id, "ap_id": ap_id, "curr_user": curr_user})
        objectives_updated = obj_result.rowcount

        # Step 3a: 控制項層程序書 → ssp_reference_documents + mappings
        self._copy_control_reference_documents(session, module_frame_id, ssp_id, curr_user)

        # Step 3b: AO 層程序書 → ssp_control_implementation_objectives.reference_documents JSONB
        self._copy_objective_reference_documents_to_jsonb(session, module_frame_id, ssp_id, ap_id, curr_user)

        # Step 4: 標記 SSP 來源
        session.execute(text("""
            UPDATE oscal.system_security_plans
               SET template_module_frame_id = :mf_id
             WHERE id = :ssp_id
        """), {"mf_id": module_frame_id, "ssp_id": ssp_id})

        # Log fallback skip (沒對應的 control 是因為 control 沒 catalog_control_assessment, fallback Evidence Upload path)
        expected_count = session.execute(text(
            "SELECT count(*) FROM compliance.module_frame_control_defaults WHERE module_frame_id = :mf_id"
        ), {"mf_id": module_frame_id}).scalar()
        if controls_updated < expected_count:
            logger.info(
                f"[template_copy] module_frame={module_frame_id}: "
                f"{expected_count - controls_updated} of {expected_count} control defaults skipped "
                f"(SSP control_impl not found - likely fallback Evidence Upload path)"
            )

        return {"controls_updated": controls_updated, "objectives_updated": objectives_updated}

    def _copy_control_reference_documents(self, session, module_frame_id, ssp_id, curr_user):
        """控制項層程序書 → SSP mappings 表（INSERT 新 row）"""
        # 步驟：
        # 1. 查模板 control 層 mappings：
        #    SELECT mfrd.file_id, mfrd.title, mfrd.description, mfcd.control_identifier
        #    FROM module_frame_reference_document_mappings mfrdm
        #    JOIN module_frame_reference_documents mfrd ON mfrd.id = mfrdm.reference_document_id
        #    JOIN module_frame_control_defaults mfcd ON mfcd.id = mfrdm.context_id
        #    WHERE mfrdm.context_type = 'control_default' AND mfrd.module_frame_id = :mf_id
        # 2. 對每筆，找對應的 ssp_control_impl.id (by control_identifier + ssp_id)
        # 3. INSERT INTO oscal.ssp_reference_documents (context_type='control_implementation',
        #       context_id=ssp_ci.id, file_id=mfrd.file_id, description=mfrd.description, ...)
        # 4. 取得新 ssp_ref_doc.id, INSERT INTO oscal.ssp_reference_document_mappings
        #    (context_type='control_implementation', context_id=ssp_ci.id, reference_document_id=新 ref_doc.id)
        pass  # 實作時填入

    def _copy_objective_reference_documents_to_jsonb(self, session, module_frame_id, ssp_id, ap_id, curr_user):
        """AO 層程序書 → ssp_control_implementation_objectives.reference_documents JSONB（UPDATE in place）"""
        # 步驟：
        # 1. 查模板 AO 層 mappings：
        #    SELECT mfod.statement_identifier (=ccai.uid), mfod.control_identifier,
        #           json_agg(json_build_object('file_id', mfrd.file_id, 'name', mfrd.title)) AS docs
        #    FROM module_frame_reference_document_mappings mfrdm
        #    JOIN module_frame_reference_documents mfrd ON mfrd.id = mfrdm.reference_document_id
        #    JOIN module_frame_control_objective_defaults mfod ON mfod.id = mfrdm.context_id
        #    WHERE mfrdm.context_type = 'objective_default' AND mfrd.module_frame_id = :mf_id
        #    GROUP BY mfod.id
        # 2. 對每筆 row，透過 ccai → ap_task → ssp_objective 對應（同 Step 2 的 join chain）
        # 3. UPDATE oscal.ssp_control_implementation_objectives obj
        #       SET reference_documents = :docs_jsonb
        #     WHERE obj.system_security_plan_id = :ssp_id
        #       AND obj.statement_identifier = COALESCE(ap_task.task_code, ap_task.title, ap_task.id::text)
        pass  # 實作時填入
```

- [ ] **Step 3: 跑測試 PASS**

```bash
pytest test/test_module_frame_template_copy_service.py -v
```
Expected: ALL PASSED

- [ ] **Step 4: Commit**

```bash
git add app/module_frame/service/module_frame_template_copy_service.py test/test_module_frame_template_copy_service.py
git commit -m "feat(app): module_frame template copy service (UPDATE in place + ref docs)"
```

---

## Task 10: Template Import Service（Excel 批次）

**Files:**
- Create: `app/module_frame/service/module_frame_template_import_service.py`
- Test: `test/test_module_frame_template_import_service.py`

- [ ] **Step 1: 參考既有 SSP import**

讀 `app/oscal/service/ssp_control_impl_import_service.py`，fork 邏輯。

- [ ] **Step 2: TDD test cases**

```python
def test_download_template_returns_xlsx_with_control_rows()
def test_verify_returns_diff_against_existing_defaults()
def test_import_bulk_upserts_control_defaults()
def test_import_bulk_upserts_objective_defaults_by_statement_identifier()
def test_import_validates_control_identifier_in_profile()
```

- [ ] **Step 3: 實作 service**

包括 5 個 method：`download_template / download_export / verify_template / verify_data / save_import`

- [ ] **Step 4: Commit**

```bash
git add app/module_frame/service/module_frame_template_import_service.py test/test_module_frame_template_import_service.py
git commit -m "feat(app): module_frame template Excel import service"
```

---

## Task 11: Serializers + Control Default Routes

**Files:**
- Create: `api/module_frame/serializers/module_frame_control_default.py`
- Create: `api/module_frame/routes/module_frame_control_default_route.py`
- Test: `test/test_api_module_frame_control_default_route.py`

- [ ] **Step 1: TDD 測試（route 層認證 + 權限）**

```python
def test_put_control_default_returns_401_without_jwt():
    # PUT without Authorization header → 401
def test_put_control_default_returns_403_for_non_manager():
    # PUT with viewer role → 403 (manager 角色檢查)
def test_put_control_default_returns_400_for_control_not_in_profile():
    # PUT with control_identifier 不在 profile → 400 MODULE_FRAME_CONTROL_NOT_IN_PROFILE
def test_put_control_default_returns_200_for_valid_request():
    # PUT manager + valid control → 200, body has data envelope
```

- [ ] **Step 2: Serializers**

按 CLAUDE.md API patterns（`<Resource>RequestSchema` / `<Resource>ResponseSchema`）。

- [ ] **Step 3: 4 個 Resource**

- `ListResource` — `GET /module-frame/<uid>/control-defaults`
- `DetailResource` — `GET /module-frame/<uid>/control-defaults/<control_identifier>`
- `UpsertResource` — `PUT /module-frame/<uid>/control-defaults/<control_identifier>`
- `DeleteResource` — `DELETE /module-frame/<uid>/control-defaults/<control_identifier>`

每個 method 註冊 webargs schemas + 呼叫對應 app service。

- [ ] **Step 4: 跑測試 PASS**

```bash
pytest test/test_api_module_frame_control_default_route.py -v
```
Expected: 4 PASSED

- [ ] **Step 5: Manual smoke test**

```bash
curl -X PUT http://localhost:8000/api/1.0/module-frame/<uid>/control-defaults/AC.L1-3.1.1 \
  -H "Authorization: Bearer ..." \
  -H "Content-Type: application/json" \
  -d '{"implementation_description": "test", "responsible_role": "CISO"}'
```
Expected: `{"code": 1, "data": {...}}`

- [ ] **Step 6: Commit**

```bash
git add api/module_frame/serializers/module_frame_control_default.py api/module_frame/routes/module_frame_control_default_route.py
git commit -m "feat(api): module_frame control default routes + serializers"
```

---

## Task 12: Objective Default Routes

**Files:**
- Create: `api/module_frame/serializers/module_frame_control_objective_default.py`
- Create: `api/module_frame/routes/module_frame_control_objective_default_route.py`

- [ ] **Step 1: Serializers + 4 routes**

- `GET /module-frame/<uid>/objective-defaults`
- `GET /module-frame/<uid>/objective-defaults/<objective_default_uid>`
- `PUT /module-frame/<uid>/objective-defaults` (body 含 control_identifier + statement_identifier)
- `DELETE /module-frame/<uid>/objective-defaults/<objective_default_uid>`

- [ ] **Step 2: Commit**

```bash
git add api/module_frame/serializers/module_frame_control_objective_default.py api/module_frame/routes/module_frame_control_objective_default_route.py
git commit -m "feat(api): module_frame objective default routes"
```

---

## Task 13: Reference Document + Mapping Routes

**Files:**
- Create: `api/module_frame/serializers/module_frame_reference_document.py`
- Create: `api/module_frame/routes/module_frame_reference_document_route.py`
- Create: `api/module_frame/routes/module_frame_reference_document_mapping_route.py`

- [ ] **Step 1: Pool 4 routes**

- `GET /module-frame/<uid>/reference-documents`
- `POST /module-frame/<uid>/reference-documents` (multipart)
- `PUT /module-frame/<uid>/reference-documents/<doc_uid>`
- `DELETE /module-frame/<uid>/reference-documents/<doc_uid>`

- [ ] **Step 2: Mapping 4 routes**

- `POST /module-frame/<uid>/control-defaults/<control_identifier>/reference-documents` (掛 control)
- `DELETE /module-frame/<uid>/control-defaults/<control_identifier>/reference-documents/<doc_uid>`
- `POST /module-frame/<uid>/objective-defaults/<objective_default_uid>/reference-documents`
- `DELETE /module-frame/<uid>/objective-defaults/<objective_default_uid>/reference-documents/<doc_uid>`

- [ ] **Step 3: Commit**

```bash
git add api/module_frame/serializers/module_frame_reference_document.py api/module_frame/routes/module_frame_reference_document_route.py api/module_frame/routes/module_frame_reference_document_mapping_route.py
git commit -m "feat(api): module_frame reference document routes + mappings"
```

---

## Task 14: Template Import/Export Routes

**Files:**
- Create: `api/module_frame/routes/module_frame_template_import_route.py`

- [ ] **Step 1: 5 個 routes**

- `GET /module-frame/<uid>/control-defaults/export`
- `GET /module-frame/<uid>/control-defaults/template/download`
- `POST /module-frame/<uid>/control-defaults/import/template`
- `POST /module-frame/<uid>/control-defaults/import/verify`
- `POST /module-frame/<uid>/control-defaults/import`

對齊既有 `SspImportRoute`。

- [ ] **Step 2: Commit**

```bash
git add api/module_frame/routes/module_frame_template_import_route.py
git commit -m "feat(api): module_frame template import/export routes"
```

---

## Task 15: Catalog AO 列舉 Endpoint

**Files:**
- Create: `api/oscal/routes/catalog_control_assessment_route.py`（如已有則跳過）

- [ ] **Step 1: 確認既有路徑**

```bash
grep -rn "catalog.control.assessment" api/oscal/ 2>/dev/null
```

- [ ] **Step 2: 若無則新增**

`GET /oscal/catalog-controls/<control_uid>/assessments` → 回 `[{uid, name, description, version}]`

底層用既有 `CatalogControlAssessmentRepoImpl`。

- [ ] **Step 3: Commit**

```bash
git add api/oscal/routes/catalog_control_assessment_route.py
git commit -m "feat(api): catalog control assessments list endpoint"
```

---

## Task 16: DI Containers Wiring

**Files:**
- Modify: `di_containers/module_frame/module_frame_containers.py`
- Modify: `config/app_modules.py`（如新 service 需註冊）
- Modify: `di_containers/project/...`（給 OscalProjectService inject template_copy_service）

- [ ] **Step 1: 註冊新 repos / services**

在 `module_frame_containers.py` 加：
- 4 個 repo providers（`module_frame_control_default_repo` 等）
- 4 個 domain service providers
- 4 個 app service providers（含 template_copy + import）

- [ ] **Step 2: OscalProjectService 加 dependency**

修改 `OscalProjectService.__init__`：加入 `module_frame_template_copy_service` 參數（建議放最後一個位置）+ `self.module_frame_template_copy_service = module_frame_template_copy_service`。

**所有 instantiate `OscalProjectService` 的位置都要更新：**

```bash
grep -rn "OscalProjectService(" /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be --include="*.py" | grep -v __pycache__
```

預期至少會找到 `di_containers/project/...` 內的 `providers.Singleton(OscalProjectService, ...)` 宣告。為每個都加上新 dependency。

**驗證**：執行 `python -c "from di_containers.containers import Container; c = Container(); print(c.project_container.oscal_project_service())"` 應無 DI 錯誤。

- [ ] **Step 3: 啟動 app 驗證**

```bash
ENV=DEVELOP_PREMISE python main_app.py &
sleep 3
curl http://localhost:8000/swagger-ui/ | head
```
Expected: Swagger 載入成功，沒有 DI 錯誤

- [ ] **Step 4: Commit**

```bash
git add di_containers/
git commit -m "feat(di): wire module_frame template default services"
```

---

## Task 17: Hook into start_oscal_project + Fix _clone_ssp_objectives ⭐ 關鍵

**Files:**
- Modify: `app/project/service/oscal_project_service.py`
- Test: `test/test_clone_ssp_objectives_reference_documents.py`

- [ ] **Step 1: TDD — _clone_ssp_objectives bug fix 失敗測試**

```python
def test_clone_ssp_objectives_preserves_reference_documents():
    # Arrange: ssp_control_implementation_objectives row with reference_documents = [{"file_id": 1, "name": "test.pdf"}]
    # Act: launch_new_round → _clone_ssp_objectives 被執行
    # Assert: 新 round 的 objective row 的 reference_documents 等於原 row（不為空）
```

執行：
```bash
pytest test/test_clone_ssp_objectives_reference_documents.py -v
```
Expected: FAIL（因為原 code 沒 copy）

- [ ] **Step 2: 修補 `_clone_ssp_objectives`**

`oscal_project_service.py` **line 623** 是 `remarks=getattr(source_obj, "remarks", None),`，**在 line 623 之後、line 624 (`created_user=...`) 之前** 插入一行：

```python
                reference_documents=getattr(source_obj, "reference_documents", None) or [],
```

修補後 line 619-628 應為：
```python
                control_identifier=source_obj.control_identifier,
                statement_identifier=new_uid,
                implementation_status=source_obj.implementation_status,
                implementation_description=source_obj.implementation_description,
                remarks=getattr(source_obj, "remarks", None),
                reference_documents=getattr(source_obj, "reference_documents", None) or [],  # ← 新增
                created_user=curr_user,
                updated_user=curr_user,
            )
```

- [ ] **Step 3: Re-run 測試 PASS**

```bash
pytest test/test_clone_ssp_objectives_reference_documents.py -v
```
Expected: PASSED

- [ ] **Step 4: Hook copy_service into start_oscal_project**

變數 `module_frame_id` 已存在於 `start_oscal_project` line 218（透過 `mf.id if mf else None` 取得）。`ssp` 跟 `ap` 物件也已在 scope 內。

在 `start_oscal_project` 的 **Step 7.5 except 區塊結束之後**（約 line 320 之後、Step 8 之前），新增：

```python
        # 7.6 從 module_frame template 對拷 defaults 至 SSP（UPDATE in place）
        # 注意：module_frame_id 已於 line 218 取得；ssp 跟 ap 已於 step 5/6 建立
        if module_frame_id is not None:
            # fail loud：任何失敗整 transaction rollback（不要 try/except 吞掉）
            self.module_frame_template_copy_service.copy(
                module_frame_id=module_frame_id,
                ssp_id=ssp.id,
                ap_id=ap.id,
                curr_user=curr_user,
            )
```

→ 不加 try/except，讓 `@transaction` 自動 rollback。Legacy projects 沒有 module_frame_id 時跳過（`if module_frame_id is not None`）。

- [ ] **Step 5: Commit**

```bash
git add app/project/service/oscal_project_service.py test/test_clone_ssp_objectives_reference_documents.py
git commit -m "feat(project): copy module_frame template defaults at start; fix AO ref_docs lost on round clone"
```

---

## Task 18: Backward Compat 整合測試

**Files:**
- Test: `test/test_oscal_project_service_template_copy_integration.py`

- [ ] **Step 1: 寫整合測試（按 design.md §10A.6 第 1, 2, 3, 5, 6, 9 條）**

```python
def test_start_project_with_empty_template_unchanged():
    # 既有行為驗證：模板無 defaults → SSP 全空殼 (status='unknown', desc=NULL)
    # 對照組：跑現有 start_oscal_project（不帶 copy_service） vs 帶 copy_service
    # 行為應 100% 一致

def test_step_7_5_no_duplicate_rows_after_template_copy():
    # 模板有 defaults → 啟動專案 → 確認 ssp_control_impl 行數 = ap.controls 數
    # 確認 ssp_objectives 行數 = ap.tasks 數
    # 沒有 duplicate

def test_existing_module_frame_dialog_create_unchanged():
    # POST /module-frame 跟現況一致

def test_existing_module_frame_excel_import_unchanged():
    # POST /module-frame/import/* 跟現況一致

def test_existing_ssp_update_api_unchanged():
    # PUT /ssp/.../control-implementation/... 跟現況一致

def test_module_frame_soft_delete_filters_template_defaults():
    # module_frame.is_delete=1 → list_control_defaults 不回傳該模板資料

def test_existing_ssp_reference_documents_unchanged():
    # 對應 §10A.6.4：既有 SSP 程序書 control 層 + AO 層 兩端流程不變
    # Arrange: 一個既有 SSP，已有 control 層程序書（mappings 表）+ AO 層程序書（JSONB）
    # Act: 跑舊版 PUT /ssp/<uid>/control-implementation/<ci>/reference-documents
    # Assert: response 與 db 狀態跟 feature 部署前完全相同（DB row 數、JSONB 內容）

def test_legacy_project_launch_new_round_unchanged():
    # 對應 §10A.6.6：既有專案（沒有模板 defaults）跑 launch_new_round → SSP objective clone 行為跟之前一致
    # Arrange: 啟動一個 module_frame 沒設 defaults 的專案 → close round 1 AP
    # Act: launch_new_round
    # Assert: 新 ssp_objective row 內容跟 round 1 一致；reference_documents 維持空（既有 user 沒填過）
    # 重點：非新 feature path 不受影響

def test_jedi_oscal_ssp_query_update_unchanged():
    # 對應 §10A.6.7：既有 jedi-oscal SSP query/update 流程不變
    # Arrange: 現有 SSP entity
    # Act: SspMapper.to_entity(ssp), SspMapper.to_model(entity)
    # Assert: 所有既有欄位 round-trip 不變；新加的 template_module_frame_id 為 None 時不影響
```

- [ ] **Step 2: 跑全部測試**

```bash
pytest test/test_oscal_project_service_template_copy_integration.py -v
```
Expected: ALL PASSED

- [ ] **Step 3: Commit**

```bash
git add test/test_oscal_project_service_template_copy_integration.py
git commit -m "test: backward compatibility integration for template defaults"
```

---

## Task 19: Golden Path + Round 2 Survival 整合測試

**Files:**
- Test: `test/test_oscal_project_service_template_copy_integration.py`（擴增）

- [ ] **Step 1: 加入 golden path + round 2 測試**

```python
def test_start_project_with_populated_template_pre_fills_ssp():
    # Arrange: module_frame 有 control_default + objective_default + reference_documents
    # Act: POST /oscal/projects/start
    # Assert:
    #   - ssp.template_module_frame_id == module_frame_id
    #   - ssp_control_impl 對應 control 的 description = template's
    #   - ssp_objective 對應 statement_identifier 的 description = template's
    #   - ssp_reference_documents 有 row 對應模板程序書
    #   - ssp_objective.reference_documents JSONB 有對應內容（AO 層）

def test_round_2_launch_preserves_template_descriptions():
    # Arrange: 啟動專案後 close round 1 AP → launch_new_round
    # Act: 開新 round
    # Assert: 新 ssp_objective row 的 description / status / remarks / reference_documents 都等於 round 1 的（_clone_ssp_objectives 接手）
    # 重點驗證 reference_documents JSONB 不被 drop（§5.5 fix）
```

- [ ] **Step 2: 跑測試**

```bash
pytest test/test_oscal_project_service_template_copy_integration.py -v
```
Expected: ALL PASSED

- [ ] **Step 3: Commit**

```bash
git add test/test_oscal_project_service_template_copy_integration.py
git commit -m "test: template defaults golden path + round 2 program docs survival"
```

---

## Task 20: FE Service + 路由 + i18n

**Files (frontend repo `~/Projects/Billows/Audit-Manager/compliance-manager-fe/`):**
- Create: `src/service/ModuleFrameTemplateService.js`
- Modify: `src/config/api/api.js`
- Modify: `src/config/router/index.js`
- Modify: `src/config/locales/i18n/zh-tw/module-frame.json`
- Modify: `src/config/locales/i18n/en/module-frame.json`

- [ ] **Step 1: API endpoints 常數**

`api.js` 新增（約照 SspService 結構）：
```javascript
MODULE_FRAME_CONTROL_DEFAULTS: '/module-frame/{uid}/control-defaults',
MODULE_FRAME_CONTROL_DEFAULT: '/module-frame/{uid}/control-defaults/{ci}',
MODULE_FRAME_OBJECTIVE_DEFAULTS: '/module-frame/{uid}/objective-defaults',
MODULE_FRAME_OBJECTIVE_DEFAULT: '/module-frame/{uid}/objective-defaults/{od_uid}',
MODULE_FRAME_REFERENCE_DOCS: '/module-frame/{uid}/reference-documents',
MODULE_FRAME_REFERENCE_DOC: '/module-frame/{uid}/reference-documents/{doc_uid}',
MODULE_FRAME_CONTROL_ATTACH_DOC: '/module-frame/{uid}/control-defaults/{ci}/reference-documents',
MODULE_FRAME_OBJECTIVE_ATTACH_DOC: '/module-frame/{uid}/objective-defaults/{od_uid}/reference-documents',
CATALOG_CONTROL_ASSESSMENTS: '/oscal/catalog-controls/{control_uid}/assessments',
MODULE_FRAME_TEMPLATE_EXPORT: '/module-frame/{uid}/control-defaults/export',
MODULE_FRAME_TEMPLATE_IMPORT_TEMPLATE: '/module-frame/{uid}/control-defaults/template/download',
MODULE_FRAME_TEMPLATE_IMPORT_VERIFY: '/module-frame/{uid}/control-defaults/import/verify',
MODULE_FRAME_TEMPLATE_IMPORT: '/module-frame/{uid}/control-defaults/import',
```

- [ ] **Step 2: ModuleFrameTemplateService.js**

按 design.md §6.3 mirror SspService 結構，全部 method 寫齊。

- [ ] **Step 3: Router 加新 route**

`router/index.js`：
```javascript
{
    path: '/module-frame/:uid/template-edit',
    name: 'module-frame-template-edit',
    component: () => import('@/views/module_frame/ModuleFrameTemplateEditView.vue'),
    meta: { breadcrumb: [{ parent: 'module-frame', label: 'module-frame-template-edit' }] }
}
```

- [ ] **Step 4: i18n 新 key（zh-tw + en）**

`module-frame.json` 中加：
- `template_edit_title`、`template_edit_breadcrumb`
- `stat_controls_filled`、`stat_aos_filled`
- `label_implementation_description`、`label_implementation_status`、`label_responsible_role`、`label_control_origination`、`label_remarks`
- `placeholder_unfilled`、`state_filled`、`state_unfilled`
- `btn_back_to_list`、`btn_batch_maintain`、`btn_document_pool`、`btn_edit_defaults`
- `toast_autosaved`、`toast_document_uploaded`、`toast_document_attached`
- `confirm_delete_document_with_mappings`
- `accordion_ao_header`

中英對齊。

- [ ] **Step 5: Commit**

```bash
git add src/service/ModuleFrameTemplateService.js src/config/api/api.js src/config/router/index.js src/config/locales/i18n/zh-tw/module-frame.json src/config/locales/i18n/en/module-frame.json
git commit -m "feat(fe): module_frame template service + route + i18n"
```

---

## Task 21: TemplateEditView 骨架 + Tree

**Files:**
- Create: `src/views/module_frame/ModuleFrameTemplateEditView.vue`

- [ ] **Step 1: 抄 ProjectPlanningView 結構**

複製 `ProjectPlanningView.vue` 的 layout：
- Header 區
- Split panel
- 左 Tree（Group → Control → AO 三層 + search + expand）

**砍掉**：launch button / readonly banner / participants panel / `canEditSsp` 邏輯。

- [ ] **Step 2: Mount 載入模板資料**

```javascript
onMounted(async () => {
    const moduleFrame = await fetchModuleFrame(route.params.uid)
    const profile = moduleFrame.oscal_profile  // 含 include_groups
    // 從 profile 建 groups + controls 樹狀結構
    // 為每個 control 預先 fetch CatalogControlAssessments → 建 AO 子節點
    const defaults = await ModuleFrameTemplateService.listControlDefaults(uid)
    // 建立 controlIdentifierToHasDefault map
})
```

- [ ] **Step 3: Tree 顯示狀態 icon**

✅ 已設定 default / ⬜ 未設定。Lazy 預填的 fallback。

- [ ] **Step 4: Manual smoke test**

啟動 FE dev server `npm run dev`，導航到 `/module-frame/<uid>/template-edit`，確認 Tree 渲染。

- [ ] **Step 5: Commit**

```bash
git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): TemplateEditView base layout + control tree"
```

---

## Task 22: 控制項詳情編輯 + 自動儲存

**Files:**
- Modify: `src/views/module_frame/ModuleFrameTemplateEditView.vue`

- [ ] **Step 1: 右側面板 — 控制項詳情**

新增區塊（仿 ProjectPlanningView control 編輯區）：
- `Textarea` for `implementation_description`
- `Dropdown` for `implementation_status`
- `InputText` for `responsible_role`
- `Dropdown` for `control_origination`
- `Textarea` for `remarks`

- [ ] **Step 2: Debounce 800ms 自動 PUT**

```javascript
import { debounce } from 'lodash-es'
const saveControlDefault = debounce(async (controlIdentifier, payload) => {
    await ModuleFrameTemplateService.upsertControlDefault(uid, controlIdentifier, payload)
    toast.add({ severity: 'success', summary: t('lang.module_frame.toast_autosaved'), life: 1500 })
}, 800)
// watch 各 ref → 觸發 saveControlDefault
```

- [ ] **Step 3: Manual test**

編輯後等 1 秒，確認 PUT 觸發、toast 顯示、reload 後資料保留。

- [ ] **Step 4: Commit**

```bash
git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): control default editor with debounce auto-save"
```

---

## Task 23: AO Accordion 編輯

**Files:**
- Modify: `src/views/module_frame/ModuleFrameTemplateEditView.vue`

- [ ] **Step 1: AO Accordion**

在 control 詳情下方加 PrimeVue `Accordion`，每個 AO header 顯示 `ccai.name` + 狀態 icon。

- [ ] **Step 2: 展開後編輯區**

跟控制項層編輯區相同 schema（description / status / remarks）。

- [ ] **Step 3: Debounce auto-save**

呼叫 `ModuleFrameTemplateService.upsertObjectiveDefault(uid, payload)`，body 含 `control_identifier + statement_identifier + 編輯欄位`。

- [ ] **Step 4: Manual test**

展開 AO、編輯、確認 PUT、reload 保留。

- [ ] **Step 5: Commit**

```bash
git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): AO accordion editor with debounce auto-save"
```

---

## Task 24: 程序書區塊（reuse Planning 元件）

**Files:**
- Modify: `src/views/module_frame/ModuleFrameTemplateEditView.vue`

- [ ] **Step 1: Import reuse 的元件**

```javascript
import ReferenceDocumentList from '@/components/grc/ReferenceDocumentList.vue'
import DocumentPoolPanel from '@/components/grc/DocumentPoolPanel.vue'
import DocumentLinkDialog from '@/components/grc/DocumentLinkDialog.vue'
```

- [ ] **Step 2: 控制項層程序書區塊**

塞到控制項編輯區下方。`ReferenceDocumentList` 接收 `controlReferenceDocuments[ctrl.id]` props。Service 改用 `ModuleFrameTemplateService` 的 attach/detach method。

- [ ] **Step 3: AO 層程序書區塊**

每個 AO 編輯區內也加 `ReferenceDocumentList`。

- [ ] **Step 4: 程序書池 Dialog**

Header 加「程序書池管理」按鈕，按了開 `DocumentPoolPanel` Dialog。底層 service 改打 module-frame endpoint。

- [ ] **Step 5: Manual test**

上傳檔案、掛到控制項、掛到 AO、確認跨控制項可重用同一檔案。

- [ ] **Step 6: Commit**

```bash
git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): reference document blocks + pool dialog (reuse Planning components)"
```

---

## Task 25: Stats Bar + Header

**Files:**
- Modify: `src/views/module_frame/ModuleFrameTemplateEditView.vue`

- [ ] **Step 1: Header 加 stats**

仿 ProjectPlanning 的 mini-stat：
- 「已設定 X / 總控制項 Y」(進度條)
- 「已設定 AO X / 總 AO Y」

- [ ] **Step 2: Computed properties**

```javascript
const filledControlsCount = computed(() => Object.keys(controlDefaultsMap.value).length)
const totalControls = computed(() => allControlsFlattened.length)
// AO 同理
```

- [ ] **Step 3: Commit**

```bash
git add src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): template edit header with stats + back button"
```

---

## Task 26: Fork SspImportDialog

**Files:**
- Create: `src/components/grc/ModuleFrameTemplateImportDialog.vue`

- [ ] **Step 1: 複製 SspImportDialog.vue**

```bash
cp src/components/grc/SspImportDialog.vue src/components/grc/ModuleFrameTemplateImportDialog.vue
```

- [ ] **Step 2: 改 service 層 endpoint**

把所有呼叫 `SspService.import*` 改成 `ModuleFrameTemplateService.import*`。

- [ ] **Step 3: Mount 到 TemplateEditView**

Header 加「批次維護」menu，含：
- 下載 Excel 範本
- 匯出當前
- 上傳 Excel（開 Dialog）

- [ ] **Step 4: Manual test**

下載範本 → 填寫 → 上傳 → 確認 defaults 被批次寫入。

- [ ] **Step 5: Commit**

```bash
git add src/components/grc/ModuleFrameTemplateImportDialog.vue src/views/module_frame/ModuleFrameTemplateEditView.vue
git commit -m "feat(fe): batch import dialog (forked from SspImportDialog)"
```

---

## Task 27: ModuleFrame 列表入口 + 跳轉

**Files:**
- Modify: `src/views/module_frame/ModuleFrame.vue`

- [ ] **Step 1: 列表加按鈕**

每筆模板加「編輯預設值」按鈕：
```vue
<Button label="編輯預設值" icon="pi pi-pencil"
        @click="$router.push({ name: 'module-frame-template-edit', params: { uid: row.uid } })" />
```

- [ ] **Step 2: 建立模板 Dialog 完成後跳轉**

在 `addFrameFromOscal` 成功後加：
```javascript
router.push({ name: 'module-frame-template-edit', params: { uid: newModuleFrame.uid } })
```

- [ ] **Step 3: Manual smoke test**

建立新模板 → 應自動跳到編輯頁。  
列表既有模板 → 點按鈕 → 跳到編輯頁。

- [ ] **Step 4: Commit**

```bash
git add src/views/module_frame/ModuleFrame.vue
git commit -m "feat(fe): module_frame list entry + create-redirect to template editor"
```

---

## Task 28: Manual E2E 驗證 + 文件交付

**Files:**
- Create: `docs/changelog/2026-04-25-module-frame-template-defaults.md`

- [ ] **Step 1: Manual E2E checklist（必跑）**

對照 design.md §10A.6 全部 9 項：

1. [ ] 沒設 defaults 的模板啟動專案 → SSP 全 NULL（與目前一致）
2. [ ] 既有 module_frame 列表 + Dialog 編輯 → 行為不變
3. [ ] 既有 SSP 編輯（ProjectPlanning）→ 完全不變
4. [ ] 既有 SSP 程序書上傳/掛接 → 完全不變
5. [ ] 既有 module_frame Excel import → 完全不變
6. [ ] launch_new_round Round 2 啟動 → SSP objective 跨 round clone 行為不變
7. [ ] jedi-oscal 既有 SSP query / update 流程 → 完全不變
8. [ ] Round 1 user 自填 AO 程序書 → launch_new_round → Round 2 仍保有程序書
9. [ ] Step 7.5 + `_copy_template_to_ssp` 共存：行數無 duplicate

**新功能 golden path：**
10. [ ] 建立模板 → 編輯預設值（control + AO + 程序書）→ 啟動專案 → 進 ProjectPlanning 看到預填內容
11. [ ] 批次匯出 → 編輯 Excel → 批次匯入 → 確認資料寫入
12. [ ] 程序書跨控制項重用

- [ ] **Step 2: 寫 changelog**

按 CLAUDE.md 格式：

```markdown
# 2026-04-25 — Module Frame Template Defaults（合規資源庫控制項預設值）

## 需求說明

把「現況說明 + 程序書」抽成 module_frame 層的模板預設值；啟動專案時自動帶入新建的 SSP，大幅減少每個專案的填寫工作量。

設計文件：`docs/features/FR-018-2604-module-frame-template-defaults/design.md`
實作計畫：`docs/features/FR-018-2604-module-frame-template-defaults/implementation-plan.md`

## 變更範圍

### Backend
| 檔案 | 動作 |
|------|------|
| scripts/sql/2026-04-25-module-frame-template-defaults.sql | 新增 — 4 表 + ALTER ssp + GRANT |
| domain/module_frame/entity/, repository/, service/ | 新增 — 4 sets entities + repos + domain services |
| infra/module_frame/models/, mapper/, repository/ | 新增 — 4 ORM models + mappers + impls |
| app/module_frame/dto/, service/ | 新增 — DTOs + 4 app services + template_copy + import |
| api/module_frame/serializers/, routes/ | 新增 — 4 sets serializers + routes |
| api/oscal/routes/catalog_control_assessment_route.py | 新增/驗證 |
| common/code/module_frame_error_code.py | 新增 |
| di_containers/module_frame/, project/ | 修改 — wire 新 services |
| app/project/service/oscal_project_service.py | 修改 — start_oscal_project 加 _copy_template_to_ssp 呼叫 + _clone_ssp_objectives 補 reference_documents copy fix |

### jedi-oscal
oscal.system_security_plans 加 template_module_frame_id 欄位；jedi-oscal minor bump 並發版

### Frontend
| 檔案 | 動作 |
|------|------|
| src/views/module_frame/ModuleFrameTemplateEditView.vue | 新增 |
| src/components/grc/ModuleFrameTemplateImportDialog.vue | 新增（fork SspImportDialog） |
| src/service/ModuleFrameTemplateService.js | 新增 |
| src/views/module_frame/ModuleFrame.vue | 修改 — 加按鈕 + 建立後跳轉 |
| src/config/api/api.js / router/index.js | 修改 — endpoints + route |
| src/config/locales/i18n/zh-tw/module-frame.json + en/ | 修改 — 新 key |

## API 變更

新增約 18 個 endpoints（control defaults / objective defaults / reference documents / mappings / catalog AO / batch import）。`POST /oscal/projects/start` request/response 不變，內部多一步對拷。

## 行為差異

| 情境 | 之前 | 現在 |
|------|------|------|
| 模板有 defaults 啟動專案 | SSP 全空殼，user 在 Planning 重填 | SSP 預填模板內容 |
| Round 2 launch 程序書 | AO 程序書消失（既有 bug） | AO 程序書保留 |
| 既有模板 / 既有專案 | — | 行為 100% 不變 |

## 不在本次 scope

詳見 design.md §9：SSP-per-round / SSP 版本控制 / AR snapshot / Re-sync from template / i18n trans table 等列入 Phase 2 backlog
```

- [ ] **Step 3: Commit changelog**

```bash
git add docs/changelog/2026-04-25-module-frame-template-defaults.md
git commit -m "docs(changelog): module_frame template defaults"
```

---

## 完成 / 後續

實作完成後：
1. 確認 `docs/features/FR-018-2604-module-frame-template-defaults/design.md` 仍與實作一致；如有偏差更新 design doc
2. 不執行 git push 或 PR creation（按 CLAUDE.md 規範）
3. 等使用者明確指示後才執行 jedi-oscal 進版發佈

## 風險提示（給實作者）

1. **Task 9 (Template Copy Service) 是整個 feature 的關鍵點** — UPDATE in place 邏輯一定要對齊 Step 7.5 的 statement_identifier convention（`COALESCE(task_code, title, id::text)`），否則會撞 unique constraint
2. **Task 17 中 `_clone_ssp_objectives` 那行 fix** — 純 additive 修補既有 bug，但若漏掉，本 feature 的程序書承諾會在 round 2 破功
3. **整合測試（Task 18-19）必須真實跑過** — 對拷邏輯複雜，unit test 不夠，要實際啟動 project 端到端驗證
4. **jedi-oscal 進版** — Task 0 完成但**不**自動發 Nexus；等使用者指示
5. **FE Task 21-25 高度 reuse Planning 元件** — 不要從零重寫；複製結構過來再砍多餘部分
