# 契约登记（解耦缝）

> 跨 agent 的契约——多 agent 靠它解耦并行。**契约先行**：定义方先在此登记并标 `稳定`，依赖方才动工；改契约必须在此记一笔 + 在 `PROGRESS.md` 通知依赖任务（前端要重生成 TS 客户端、依赖模块要重同步）。
> 状态：`待定义` → `草拟(@skill)` → `稳定` → `已变更(见日志)`。

---

## C1 · LLM 网关接口　owner @llm　状态: 稳定（T1.1, 2026-06-18）
- 来源：`ARCHITECTURE.md §4.1`（`LlmRequest` / `LlmResponse` / `Block` / `Usage`，**snake_case**）。
- 消费方：编排器、所有 Agent（经 `Gateway.run` / `Gateway.stream`）。
- 关键不变量：agent 只传 `tier`（writer/analyst/light），不传具体 model。
- **已实现（`packages/llm_gateway/ww_llm_gateway`）**：
  - 类型 `types.py`：`Block(text,cache=False)`、`Scope(user_id,project_id?)`、`LlmRequest(tier,input:str|list[Block],system:list[Block],stream,output_schema?,max_tokens?,scope)`、`Usage(provider,model,input_tokens,output_tokens,cache_read_tokens,cost_minor,currency)`、`ServedBy(provider,model,fell_back)`、`LlmResponse(text,parsed?,usage,served_by)`、`Delta(text)`、`Tier`。
  - `Gateway(adapters:dict[str,ProviderAdapter], ledger:LedgerSink, resolver=resolve_route)`：`async run(req)->LlmResponse`、`stream(req)->AsyncIterator[Delta]`。每次调用落 **1 条** `usage_ledger`（经 `LedgerSink`，可注入内存替身）。
  - 适配器 `ProviderAdapter`(Protocol)：`provider`、`capabilities()->Capabilities`、`async complete(req,model)->ProviderResult`、`stream(req,model)->AsyncIterator[StreamChunk]`。M1 实现 `OpenAICompatAdapter(provider, client:AsyncOpenAI)`（DeepSeek，注入 client 便于测试）。
  - 档位路由 `resolve_route(tier)->Route(provider,model)` 读 `config.tier_defaults`（M1 仅全局默认；回退/熔断属 M5/T5.4，**未实现**）。
  - 记账 `SqlAlchemyLedgerSink(session)` 写 `UsageLedger`（owner_id=scope.user_id 单用户 stub；列名 `cache_read`）。成本表 `pricing.py`（未知 provider/model→0）。

### C1 扩展（T5.4, 2026-06-19）· 多 provider 韧性（回退链 + 熔断 + 能力协商/降级）　owner @llm　状态: 稳定
- 来源：`ARCHITECTURE.md §4.4（能力协商/降级）/§4.5（回退/重试/熔断）`。**仅加字段/形参，不破既有调用**（旧 `Gateway(adapters, ledger, resolver=resolve_route)` 仍可用）。
- **`ServedBy` 加字段**（C1 形变，**仅加可选 bool，默认 False，向后兼容**）：`ServedBy(provider, model, fell_back=False, degraded=False)`。`degraded` 标能力降级（所选 provider 不支持原生结构化输出，改走 instructor JSON-提示路径）。→ **OpenAPI 表面**：`ServedBy` 不直接出 API（review/draft SSE 不回 served_by，settings/providers 也不含它）→ 经核 **本次无 OpenAPI 形变，前端无需 re-gen**；若后续把 `served_by` 暴露到响应体，再触发 gen:api。
- **`Gateway` 构造扩**（关键字，全可选）：`Gateway(adapters, ledger, *, chain_resolver: Callable[[Tier], list[Route]] | None=None, resolver: Callable[[Tier], Route] | None=None, max_retries=2, breaker: CircuitBreaker | None=None)`。`chain_resolver`（回退链）优先；`resolver`（单路由，M1 兼容）被自动包成单元素链；皆缺省回退到默认 `resolve_chain`。`run`/`stream` 行为：沿链逐 provider→瞬时失败退避重试 R 次→仍失败/熔断打开/无适配器则切下一个；首个成功者服务，非链首即 `served_by.fell_back=True`；链耗尽抛 `AppError(LLM_UNAVAILABLE)`。记账记**实际服务方** provider/model（回退后不记主模型）。流式仅在首块产出前可切（产出后中途失败上抛，不静默重连，§4.5）。
- **回退链解析（routing.py）**：`resolve_chain(tier)->list[Route]`（默认仅全局 `tier_defaults`，单元素）；`chain_from_routing(tier, primary:str, fallback:list[str])->list[Route]`（据 DB `tier_routing` 行去重保序构链——供 apps/api 包成 `ChainResolver` 注入网关，**apps/api 待接线**：`build_gateway_for_tier` 目前仍只注主 provider 适配器 + `resolver=resolve_route`，要启用回退须按 fallback 预备多个适配器 + 传 `chain_resolver`）。`ChainResolver = Callable[[Tier], list[Route]]` 类型别名导出。
- **熔断器**：`CircuitBreaker(*, threshold=5, reset_seconds=30.0, clock=time.monotonic)`：`is_open(provider)`/`record_failure`/`record_success`；连续失败超阈值→短时熔断（网关直接跳过该 provider 走回退）；成功清零；冷却窗口过半开放行。**默认每个 Gateway 实例自带一个 breaker**（进程内、非跨实例共享）——跨请求持久熔断需调用方注入共享 breaker。
- **瞬时错误契约**：适配器把厂商 429/超时/5xx/连接错误翻译为 `ww_llm_gateway.errors.TransientProviderError`（OpenAI 兼容/Anthropic/Gemini 适配器均已包装，按异常类名 + status_code 判定）；网关另把 `AppError(RATE_LIMITED)` 也视作可重试/可回退。非瞬时错误（内容策略拒绝等）原样上抛、不重试。
- **新适配器**（均经注入的客户端 Protocol，**测试不联网、不硬 import 厂商 SDK**）：
  - `AnthropicAdapter(provider, client: AnthropicClient, *, structured_client?)`：`capabilities()=(structured_output=True, prefix_cache=True, thinking=True)`；缓存断点经 system 块 `cache_control:{type:ephemeral}`（§4.6）；结构化经 `instructor.from_anthropic`（懒构建）。
  - `GeminiAdapter(provider, client: GeminiClient)`：`capabilities()=(structured_output=True, prefix_cache=False, thinking=True)`；结构化经 `config.response_mime_type=application/json + response_schema`，文本回 `model_validate_json`。
  - `OpenAICompatAdapter` 不变（新增瞬时错误包装，参与回退链）。
- **依赖**：`packages/llm_gateway/pyproject.toml` 加 `tenacity>=8.2`（已 `uv sync`；原为 instructor 传递依赖）。**`anthropic`/`google-genai` SDK 已由 @devops 加（已 `uv sync`）**——适配器仍懒导入/注入客户端，单测零依赖。

#### C1 扩（CR-H4, 2026-07-08）· `LlmRequest` 加可选 `request_id`　owner @llm　状态: 稳定
- **`LlmRequest` 加字段**（C1 形变，**仅加可选、默认 `None`，最后一位，向后兼容**）：`request_id: str | None = None`。调用方（apps/api/编排器）设置后，网关把它**条件透传**到每条 `llm_call` / `llm_provider_failed` 日志（`req.request_id is not None` 才发——避免 None 覆盖 `merge_contextvars` 在 sync/SSE 路径供的 id），贯通端到端追踪（ARCH §9.3）。
- **无 OpenAPI 形变、无需 gen:api**：`LlmRequest` 是网关内部类型，不出现在 `packages/shared` / `schema.d.ts`；前端无感。旧调用点全部无 request_id → 默认 `None`，行为不变。

#### C1 扩 follow-up #2（2026-06-19）· `build_adapter` provider→适配器工厂　owner @llm　状态: 稳定
- 位置：`packages/llm_gateway/ww_llm_gateway/factory.py`，经包 `__init__` 导出 `build_adapter`。
- **签名（稳定，apps/api `build_gateway_for_tier` 据此逐 provider 建适配器进 `adapters` dict）**：
  `def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter`。
- **分派**：`provider=="anthropic"` → `AnthropicAdapter`（懒 import `AsyncAnthropic(api_key=, base_url?)`）；`provider in {"gemini","google"}` → `GeminiAdapter`（懒 import `genai.Client(api_key=)`，**忽略 base_url**——SDK 无该形参）；其余（deepseek/kimi/qwen/glm/openai…）→ `OpenAICompatAdapter(provider, AsyncOpenAI(api_key=, base_url=))`。
- 工厂从 api_key 构**真实**客户端（适配器内部仍是注入客户端 Protocol，测试零联网）；故单测只断**按 provider 名选对适配器类 + provider 字段透传**（`tests/test_build_adapter_factory.py`，6 测），不联网。
- **@backend 行动**：`build_gateway_for_tier` 现可调 `build_adapter(provider, api_key=…, base_url=…)` 替换「一律 `OpenAICompatAdapter`」，使回退链中的 anthropic/gemini 走对应专用适配器。**无 OpenAPI 形变**（served_by 不出 API），前端无需 re-gen。

#### C1 扩（K1.2, 2026-06-19）· Kimi Code 订阅 plan 适配器（伪造头 + OAuth bearer）　owner @llm　状态: 稳定
- 位置：`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py`，经包 `__init__` 导出 `KimiCodeAdapter`/`build_kimi_code_client`/`kimi_code_headers`/`kimi_device_id`/`kimi_device_model`/`KIMI_CODE_BASE_URL`/`KIMI_CODE_USER_AGENT`/`KIMI_CLI_VERSION`/`KIMI_CODE_PLATFORM`/`KIMI_CODE_PROVIDER`。
- **provider 名**：`kimi-code`（与 API-key provider `kimi` 分离，便于档位切换）。`build_adapter("kimi-code", api_key=<access_token>, base_url=None)` 是 **K1.3 接线的缝**——`api_key` 即 OAuth **access token**（OpenAI SDK 自动发 `Authorization: Bearer <token>`），`base_url` 缺省 → `KIMI_CODE_BASE_URL`。
- **base URL**：`https://api.kimi.com/coding/v1`（OpenAI 兼容；`KimiCodeAdapter` 子类化 `OpenAICompatAdapter`，capabilities 继承：structured_output/prefix_cache=True）。
- **model**：`kimi-for-coding` 是**路由/档位关注点**，经 `.complete(req, model)` 传入，**不**在适配器硬编码。
- **必需伪造头 = 完整 7 头（opencode 规范）**：`User-Agent` + 6 个 `X-Msh-*`。`build_kimi_code_client` 把 `kimi_code_headers()` 作为 `AsyncOpenAI(default_headers=…)`，每次请求随客户端发出。
- **头集真源校正（2026-06-19）= `github.com/ooojustin/opencode-kimi`（`src/headers.ts` `kimiHeaders()` + `src/constants.ts`，1:1 镜像 kimi-cli v1.37.0）**。早先曾误信 `picassio/pi-kimi-coder`（仅 UA、不带 X-Msh-*）把头集裁成 UA-only——那是分歧/错误参考，已**回退到 opencode 完整 7 头**。coding API 校验全部 7 头，偏差→Moonshot 后端 `access_terminated_error: only available for Coding Agents`（403）。7 头精确值：
  - `User-Agent` = `KimiCLI/1.37.0`（`f"KimiCLI/{KIMI_CLI_VERSION}"`；UA 前缀 = `KimiCLI/<version>`，version 必须 == `X-Msh-Version`）。
  - `X-Msh-Platform` = `kimi_cli`（字面常量字符串，**非** OS 名）。
  - `X-Msh-Version` = `1.37.0`（= UA 里的 CLI 版本）。
  - `X-Msh-Device-Name` = 主机名 ASCII 化（`socket.gethostname()`/`platform.node()`，裁非 ASCII）。
  - `X-Msh-Device-Model` = `kimi_device_model()`：macOS `f"macOS {platform.mac_ver()[0]} {platform.machine()}"`（如 `"macOS 14.5 arm64"`）；Windows `f"Windows {release} {machine}"`；其它 `f"{system} {release} {machine}"`（`platform.machine()` 原样，返回 `arm64`/`x86_64`，不归一化）。
  - `X-Msh-Os-Version` = `platform.version()`（OS 内核版本串，≈ Node `os.version()`）。
  - `X-Msh-Device-Id` = **稳定** UUID4 hex 无连字符（32 位小写）。`kimi_device_id()`：env `KIMI_DEVICE_ID` 优先 → 否则读/建 `~/.kimi/device_id`（首次写一次 `uuid.uuid4().hex`、之后复用；与 kimi-cli/opencode 共享路径）。**跨调用/进程稳定，绝不每次随机**。
- 单测（不联网，断构造出的客户端 base_url + default_headers（全 7 头键 + 固定字面值 + device-id 32 位小写 hex 稳定 + host 派生值非空 ASCII） + bearer + 工厂分派；env `KIMI_DEVICE_ID` 注入保证 CI 确定）：`tests/test_kimi_code_adapter.py` + `tests/test_kimi_code_factory.py`。
- **@backend 行动（K1.3）**：OAuth device 服务刷新 token 后，`_build_provider_adapter`（`build_gateway_for_tier`）对 provider `kimi-code` 调 `build_adapter("kimi-code", api_key=<当前 access_token>, base_url=…)` 即得带伪造头的适配器；token 刷新/获取归 K1.3，本适配器只收当前 access token。**无 OpenAPI 形变**，前端无需 re-gen。

#### C1 扩（kimi-code-key, 2026-06-20）· Kimi Code 订阅 plan **静态 Console Key**（ToS 合规变体）　owner @llm　状态: 稳定
- 位置：`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code_key.py`，经包 `__init__` 导出 `KimiCodeKeyAdapter`/`build_kimi_code_key_client`/`KIMI_CODE_KEY_PROVIDER`。
- **provider 名**：`kimi-code-key`（与 OAuth 的 `kimi-code`、moonshot 的 `kimi` 均分离）。`build_adapter("kimi-code-key", api_key=<console key>, base_url=None)` 走此分支。
- **与 OAuth `kimi-code` 的关键区别**：`build_kimi_code_key_client` 构造**纯** `AsyncOpenAI(api_key=, base_url=KIMI_CODE_BASE_URL)`——**不设 `default_headers`**，即**不伪造 UA、不带 `X-Msh-*` 头**。Kimi Console（`kimi.com/code/console`）签发的 Key 走订阅额度、命中同一 coding 端点（`https://api.kimi.com/coding/v1`）+ 同 model `kimi-for-coding`，但 plain `Authorization: Bearer <key>` 即可（openclaw + models.dev 确认，ToS 允许第三方 Key，仅 UA 篡改违规）→ 故这是**干净/合规的默认路径**（无封号风险，对照 `KimiCodeAdapter` 的伪造头变体）。
- **结构化输出**：coding 端点 `kimi-for-coding` thinking 开启，与强制 `tool_choice` 互斥（live 400）。`KimiCodeKeyAdapter` **复用** `kimi_code.build_kimi_code_structured_client`（instructor `Mode.JSON`）——同 OAuth 变体的 JSON-mode 修复。
- 单测（不联网）：`tests/test_kimi_code_key_factory.py`（5 测：工厂分派 + coding base + bearer + **无 X-Msh-*/无 KimiCLI UA** + JSON-mode 结构化 + 显式 base_url 覆盖）。
- **@backend 接线**：`kimi-code-key` 是**普通 api_key provider**（auth_type="api_key"，存 `api_key_enc`，Fernet）——经既有 `build_gateway_for_tier`/`_build_provider_adapter`/probe 路径**无改动**即可工作（只需 `provider_deps._PROVIDER_BASE_URLS` 注册 base_url）。**不**触发 `_build_provider_adapter` 的 OAuth 刷新分支（其判据 `auth_type==oauth or provider=="kimi-code"` 不命中本 provider）。**无 OpenAPI 形变**（用既有 `PUT /settings/providers`），前端无需 re-gen。

## C2 · DB schema（SQLAlchemy 模型）　owner @db　状态: 待定义
- 来源：`ARCHITECTURE.md §3.1` DDL（11 创作表 + chapter_reviews + 运营表；无向量列；users stub）。
- 消费方：`@backend`(Repository/记忆服务/验收)、`@llm`(Agent reads/writes)。

### C2 扩展（K1.1, 2026-06-19）· `provider_credentials` 加 OAuth 列　owner @db　状态: 稳定
- 背景：K1 Kimi Code OAuth（订阅 plan device-flow）。`provider_credentials` 表新增两列 + 改 `api_key_enc` 可空，使一行可表「api_key 凭据」或「oauth 凭据」二选一。
- **模型变更（`packages/db/ww_db/models.py` `ProviderCredential`）**：
  - 新增 `auth_type: Mapped[str]` = `Text, nullable=False, server_default="'api_key'"`（值域 `"api_key"` | `"oauth"`）。既有行迁移后默认 `'api_key'`，向后兼容。
  - 新增 `oauth_enc: Mapped[bytes | None]` = `LargeBinary, nullable=True`。**持 Fernet 加密的 JSON 包** `{access_token, refresh_token, expires_at}`（@backend K1.3 写/读：`encrypt`/`decrypt`，key 取 `settings.credential_enc_key`，同 `api_key_enc` 的 Fernet）。
  - `api_key_enc` 由 `nullable=False` 改 **`nullable=True`**（`Mapped[bytes | None]`）——OAuth-only 行无 api_key；既有 api_key 行迁移后仍保留其值。
  - `UniqueConstraint("owner_id","project_id","provider")` **不变**（OAuth provider 用独立 provider 名 `kimi-code`，与 api_key 的 `kimi` 分离，不撞约束）。
- **迁移**：`packages/db/migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py`（down_revision=`220ca2e3d53f`）。up: add_column auth_type/oauth_enc + alter_column api_key_enc→nullable；down 逆向。`alembic check` 无漂移。
- **⚠️ K1.3 @backend 必读**：`api_key_enc` 现为 `bytes | None`，`apps/api/ww_api/services/credentials.py` 的 `StoredCredential.api_key_enc: bytes`（dataclass 字段 line 28）与 store 的 `r.api_key_enc` 赋值（line 75/97）mypy 报 `arg-type`（`bytes | None` ↳ `bytes`）。**K1.1 不动 @backend 文件（目录所有权）**；K1.3 接 OAuth store 时须把 `StoredCredential.api_key_enc` 改成 `bytes | None`（并加 `auth_type`/`oauth_enc` 字段/读写路径），届时 mypy 即绿。当前全仓 mypy 唯余此 2 处错（都在 credentials.py），其余门禁绿（ruff/format/alembic check/pytest 400 passed）。

## C3 · API / OpenAPI 端点　owner @backend　状态: 稳定（M1 端点全落：T1.7 settings + T1.4 projects/chapters, 2026-06-18）
- 来源：`ARCHITECTURE.md §7.2` 端点清单（章节端点统一 `/projects/:id/chapters/:no/...`；含 `PUT .../draft`(自动保存)、`/refine`、`/jobs/:id`、`/reviews`）。
- 消费方：`@frontend`（经 OpenAPI→TS 客户端）。**改端点/字段 → 前端必须 `pnpm gen:api` 重生成客户端。**
- **已落（T1.7, 2026-06-18，snake_case，响应仅脱敏 key）**：
  - `GET /settings/providers` → `ProvidersResponse{ providers:[ProviderView{provider, masked_key}], tier_routing:[TierRoutingView{tier, provider, model, fallback:list[str]}] }`
  - `PUT /settings/providers` ← `ProvidersUpsertRequest{ credentials:[ProviderCredentialInput{provider, api_key}], tier_routing:[TierRoutingInput{tier, provider, model, fallback}] }` → `ProvidersResponse`(脱敏)
  - `POST /settings/providers/test` ← `TestConnectionRequest{provider}` → `TestConnectionResponse{provider, ok, capabilities:CapabilitiesView{structured_output, prefix_cache, thinking}}`
  - **明文 key 永不过边界**；probe 经可注入 `ProviderProbe`（测试用 fake，不联网）。
- **已落（T1.4, 2026-06-18，snake_case）**：
  - `POST /projects` ← `ProjectCreateRequest{title, genre?, logline?, premise?, theme?, selling_points:list, structure?}` → 201 `ProjectResponse{id, title, genre?, logline?, premise?, theme?, selling_points, structure?}`
  - `GET /projects` → `ProjectListResponse{projects:[ProjectResponse]}`；`GET /projects/{id}` → `ProjectResponse`（404 `NOT_FOUND` 信封）
  - `POST /projects/{id}/chapters/{no}/draft` → **SSE** `text/event-stream`，帧 `event:<token|done|error>\ndata:<json>\n\n`（`token{text}`/`done{length}`/`error{code,message,request_id}`）；无凭据 → 流前 `LLM_UNAVAILABLE`(503) JSON 信封（非帧）。
  - `PUT /projects/{id}/chapters/{no}/draft` ← `DraftSaveRequest{text}` → 200 `DraftResponse{project_id, chapter_no, volume, status, version, length}`（幂等：version 固定 1、status='draft'）。
  - 网关注入缝 `get_writer_gateway`（从凭据构 `OpenAICompatAdapter`+`SqlAlchemyLedgerSink`）；测试经 `app.dependency_overrides` 注 mock 网关（只需 `.stream(req)`）。
  - **@frontend 行动**：M1 端点已全在 OpenAPI → Wave D 前先 `cd apps/web && pnpm gen:api` 重生成 `lib/api/schema.d.ts`。

### C3 扩展（项目列表元数据, 2026-06-28）· `ProjectResponse` 增加最近编辑与待审稿统计　owner @backend　状态: 稳定
- `ProjectResponse` 追加字段：`updated_at: datetime | null`、`pending_review_count: int >= 0`。创建、列表、详情响应共用同一 schema；前端已 `pnpm gen:api`。
- `updated_at` 口径：取项目自身 `projects.updated_at` 与该项目下章节 `chapters.updated_at`、审稿留痕 `chapter_reviews.created_at` 的最大值，用于作品库“最近编辑”排序。为支持草稿重复保存后的真实编辑时间，`chapters` 表新增 `updated_at` 列（迁移 `8c1d2e3f4a5b`，既有行以 `created_at` 回填）。
- `pending_review_count` 口径：按章节草稿行计数；当草稿 `updated_at` 晚于该章最近 accepted 版本和最近审稿留痕时，视为待审稿。已验收且草稿未再更新的章节不计入。
- **@frontend 已消费**：作品库增加“待审稿”筛选、“最近编辑”排序，作品卡显示最近编辑时间和待审稿徽标。
- **已落（T2.4+T2.5, 2026-06-18，snake_case，全部挂 `projects.router`、已在 OpenAPI）— M2 审/裁/验收三端点**：
  - `POST /projects/{id}/chapters/{no}/review` ← `ReviewRequest{draft?}`（空/缺→回退已存草稿；无草稿→404 `NOT_FOUND`）→ **SSE** `text/event-stream`，帧：`section{name,status:"done"|"incomplete"}`(每审一条，M2 仅 `continuity`) / `conflict{type,where,refs:list,suggestion}`(每冲突一条，形=C6 `Conflict` 五类) / `done{length=审项数}` / `error{code,message,request_id}`。无凭据→流前 `LLM_UNAVAILABLE`(503 JSON 信封)。续审网关 tier=analyst（`get_review_gateway`）。**端点流耗尽后 `session.commit()`**（网关 ledger + collect 均只 flush）。
  - `GET /projects/{id}/chapters/{no}/reviews` → `ReviewHistoryResponse{reviews:[ReviewHistoryItem{id,project_id,chapter_no,chapter_version?,conflicts:list,foreshadow_sug:list,style?,pace?,health_score?,decisions?}]}`（新→旧）。
  - `POST /projects/{id}/chapters/{no}/accept` ← `AcceptRequest{final_text(min1), decisions:[ConflictDecision{conflict_index:int>=0, verdict:"accept"|"ignore"|"manual", note?}]}` → `AcceptResponse{project_id,chapter_no,accepted_version:int,digest_added:bool,decisions_recorded:int,review_id?:uuid}`。**冲突 gate**：裁决的 `conflict_index` 集合须覆盖 `range(len(最近一条 review.conflicts))`，缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`；无留痕或零冲突→直通。digest 提炼 tier=light（`get_digest_gateway`）在事务外（R2），单事务 promote(R4)+digest.append(#4)+set_decisions 末尾一次 commit（R3）。
  - 注入缝：`build_gateway_for_tier(session, store, tier)`(原 `build_writer_gateway` 退化为 writer 特例) + `get_review_gateway`/`get_digest_gateway`/`get_review_repo`/`get_digest_append_repo`；测试经 `app.dependency_overrides` 注 mock。
  - **@frontend 行动**：`cd apps/web && pnpm gen:api` 重生成客户端含此三端点（T2.6）。
- **已落（T3.2, 2026-06-18，snake_case）— 伏笔登记/状态端点 + 验收后到期扫描**（挂 `foreshadow.router`，prefix `/projects`，tag `foreshadow`，已注册）：
  - `POST /projects/{id}/foreshadow` ← `ForeshadowRegisterRequest{code(min1),title(min1),planted_at?,content?,expected_close_from?,expected_close_to?,importance?}` → 201 `ForeshadowView{code,title,status,planted_at?,content?,expected_close_from?,expected_close_to?,importance?,links:list,progress:list}`(status=OPEN)。重复 code(DB 唯一 `(project_id,code)`)→ **422** `VALIDATION`+`details{field:"code",code,reason:"duplicate"}`。
  - `PATCH /projects/{id}/foreshadow/{code}` ← `ForeshadowTransitionRequest{to_status?,progress_entry?}` → 200 `ForeshadowView`。非法转移→422 `VALIDATION`+`details{reason:"invalid_transition"}`；二者都缺→422 `empty_update`；code 不存在→404 `NOT_FOUND`。先转移后追加进展，端点写后 `commit()`(repo 只 flush)。
  - **错误码取舍**：重复/非法转移映射 `VALIDATION`(422) 非 409——现有唯一 409 码 `CONFLICT_UNRESOLVED` 语义专指未决冲突禁验收，不复用；未动 `packages/shared/errors.py` 契约。若 T3.5/前端需真 409 须先加专用码。
  - **验收后到期扫描**：`POST .../accept` 单事务 commit 成功后经 `BackgroundTasks` 登记 `run_overdue_scan(session_factory, project_id, chapter_no=刚验收章号)`——**自建独立 session**(请求 session 已关闭)、应用 `current_ch>expected_close_to AND status≠CLOSED→OVERDUE`、有变更才 commit；日志 `foreshadow_overdue_scan`(带 request_id/overdue_count/codes)，失败吞不冒泡。§7.4 持久性局限(进程内/重启丢任务)原型接受、不引 jobs 表。
  - **T3.5 续接**：`GET /projects/{id}/foreshadow?status=` 看板加到本 `foreshadow.router`（注入 `get_foreshadow_repo`→`list_by_status`，建议响应包 `{foreshadow:[...]}`）；`POST .../outline` 落**独立** `routers/outline.py`(别混进 foreshadow.py)，调 `run_outline`(C6 扩)+`build_gateway_for_tier(...,"analyst")`，逐章 upsert `outline` 表、端点末尾 commit。
  - **@frontend 行动**：T3.6 前 `pnpm gen:api` 纳入伏笔登记/状态端点（+ T3.5 看板/outline）。
- **已落（T3.5, 2026-06-18，snake_case）— 伏笔看板 + 大纲生成端点**：
  - `GET /projects/{id}/foreshadow?status=`（tag `foreshadow`）：query `status?` ∈ `OPEN|PARTIAL|CLOSED|OVERDUE`(缺省=全部，非法→422 `VALIDATION`+`details{field:"status",reason:"invalid_status"}`) → 200 `ForeshadowBoardResponse{foreshadow:list[ForeshadowView]}`(按 code 升序)。
  - `POST /projects/{id}/outline`（tag `outline`，独立 `routers/outline.py`）← `OutlineGenerateRequest{volume:int=1,ge=1}`（M3 简单分卷：整批同卷）→ 200 `OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView{code,plant_chapter?,expected_close_from?,expected_close_to?}]}]}`；项目不存在→404 `NOT_FOUND`；无凭据/网关失败→503 `LLM_UNAVAILABLE`(流前 JSON 信封)。调 `run_outline`(analyst 网关 `get_outline_gateway`)→逐章 upsert `outline` 表(写侧 `SqlOutlineWriteRepo`，只 flush)→端点末尾 `commit()`（含 1 条 usage_ledger）。
  - **beats 存储形**：DB `outline.beats` 是 JSONB dict→写侧包成 `{"beats":[...]}` 落库，API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`。
  - **@frontend 行动**：T3.6 `pnpm gen:api` 纳入看板/大纲端点。

### C3 扩展（T4.3, 2026-06-19）· 文风端点：学文风(jobs 异步) + 回炉 + 最新指纹（独立 `routers/style.py`，已注册）
- snake_case；改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`** 重生成 TS 客户端（T4.4 前置）。
- `POST /projects/{id}/style` ← `StyleLearnRequest{samples:list[str](min_length=1), mode:Literal["create","update"]="create"}` → **202** `StyleLearnResponse{job_id:uuid}`。项目不存在→404 `NOT_FOUND`；无凭据→**503** `LLM_UNAVAILABLE`（在 `get_style_extract_gateway` dep 解析阶段拦下，**调度 job 之前**，不凭空写注定失败的 job）。流程：`job_repo.create(project_id,"style_learn")` → `session.commit()`（job 行在 202 前持久化供轮询）→ `background_tasks.add_task(run_job, session_factory, job.id, work)`。`work` 在 `run_job` **自建独立 session** 上自造 analyst 网关（`build_gateway_for_tier(session, SqlCredentialStore(session), "analyst")`）+ 写侧 repo → `run_style_extraction(style_extract_spec, samples_text="\n\n".join(samples), ...)` → 拆 `dimensions_json={name:value}`/`evidence_json={name:[evidence]}` → `SqlStyleFingerprintWriteRepo.append`（version+1）→ 返回 `{version, dims_count}`（落 `jobs.result`）。`run_job` 拥有 session 生命周期/commit（业务写 + job done 同一事务）。`mode` 不改落库逻辑（写侧始终 append 新版本），仅供前端区分首学/更新语义。前端经 `GET /jobs/{job_id}` 轮询。
- `GET /projects/{id}/style` → 200 `StyleFingerprintResponse{dimensions:dict, evidence:dict, version:int}`（最新版本完整指纹，UX §6.9）。项目不存在→404；无指纹→404 `NOT_FOUND`。**新增端点**（ARCH §7.2 原缺读指纹端点，见 decisions 2026-06-19）。读侧用写侧 repo 的 `latest`（含 evidence/version），**不动 C5 读侧** `SqlStyleRepo.latest`/`StyleView`（只 dimensions，assemble 用），故 assemble/C5 行为不变。
- `POST /projects/{id}/chapters/{no}/refine` ← `RefineRequest{segment:str(min_length=1), instruction:str|None=None}` → 200 `RefineResponse{original:str, refined:str}`。同步回炉：writer 网关（`get_refine_gateway`）`run(refiner_spec)`，`output_schema=None` → 取 `resp.text` 为 refined；输入 = `【待重写段落】{segment}` + 可选 `【改写指令】{instruction}`。**不写库**（不变量 #3，作者采纳经既有 draft 自动保存合入）；**末尾 `session.commit()`**（网关 ledger add-only，否则 usage_ledger 静默丢失，同 draft/review 纪律）。项目不存在→404；无凭据→503。
- 注入缝（`services/project_deps.py`）：`get_style_write_repo`（`SqlStyleFingerprintWriteRepo`，服务 `GET /style` 读侧）、`get_style_extract_gateway`(analyst，学文风的凭据探测缝)、`get_refine_gateway`(writer)。测试经 `app.dependency_overrides` override；学文风后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo`（`run_job` 自建 repo，不走 dep）。
- 写侧 repo（`packages/core/ww_core/domain/style_repo.py`，经 `ww_core.domain` 导出）：`StyleFingerprintWriteRepo`/`SqlStyleFingerprintWriteRepo`（加 `Write` 避撞 C5 读侧 `SqlStyleRepo`，仿 `DigestAppendRepo` 先例）：`append(project_id, *, dimensions_json, evidence_json)->int`（version=当前 max+1，首次 1；**只 flush 不 commit**）+ `latest(project_id)->StyleFingerprintView|None`（含 dimensions/evidence/version，供 `GET /style`）。
- **@frontend 行动**：T4.4 前 `pnpm gen:api` 纳入 style/refine/GET style + jobs 轮询类型。

### C3 扩展（T5.5, 2026-06-19）· 规则端点 `POST /projects/:id/rules`（独立 `routers/rules.py`，已注册）
- snake_case；改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**（T5.6 前置）。
- `POST /projects/{project_id}/rules` ← `RuleCreateRequest{level:Literal["global","genre","style","project"], content:str(min_length=1)}` → **201** `RuleView{level:str, content:str}`。非法 `level` / 空 `content` → **FastAPI 422**（Pydantic Literal/min_length 校验，非 AppError 信封）。
- 加规则是**作者显式动作**（不变量 #3：不经 AI 静默写库）。写一行 `rules`（绑 `project_id`），喂给 assemble 的四级合并 `merge_rules`（global→genre→style→project）。
- 提交边界：`RuleWriteRepo.create` 只 `flush()`，端点写后 `await session.commit()`（仿 foreshadow/outline 写侧）。
- 写侧 repo（`packages/core/ww_core/domain/rule_repo.py`，经 `ww_core.domain` 导出）：`RuleWriteRepo`(Protocol)/`SqlRuleWriteRepo`/`RuleWriteView{project_id?,level,content}`（加 `Write` 避撞 C5 读侧 `domain.repositories.RuleView{level,content}`，仿 `OutlineWriteRepo` 先例）。
- 注入缝（`services/project_deps.py`）：`get_rule_write_repo`（`SqlRuleWriteRepo`）。测试经 `app.dependency_overrides` override 注 fake repo + fake session（断 commit 计数）。
- **@frontend 行动**：T5.6 前 `pnpm gen:api` 纳入 `POST /rules`（规则页用）。

### C3 扩展（T5.2, 2026-06-19）· 生成/入库 + 读端点（独立 `routers/generation.py`，已注册）　owner @backend　状态: 稳定
- snake_case；改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**（T5.6 前置）。生成走**即时返回**（预览→作者确认→入库，M5-d，非 jobs）。
- `POST /projects/{project_id}/world/generate` ← `WorldGenerateRequest{brief:str(min1)}` → **200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**（worldbuilder writer 网关 `run_worldbuilder`）；末尾 `commit()` 仅落网关 ledger。项目不存在→404；无凭据→**503** `LLM_UNAVAILABLE`（`get_worldbuilder_gateway` dep 解析阶段拦下）。
- `POST /projects/{project_id}/characters/generate` ← `CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None, role_mix:dict[str,int]|None}` → **200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**（character-gen writer 网关，注入已有角色防雷同，generated_so_far=[]）；末尾 commit 落 ledger。404/503 同上。**⑦ 群像按定位配比**：`role_mix`（{定位:数量}）在场时 `count` 取各值之和（忽略客户端 count），每项≥1、总和≤12（`model_validator` 归一化）；缺省退回单一 `role`+`count`。
- `POST /projects/{project_id}/characters`（入库）← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}` → **201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**：① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false` → **409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`（仿 accept gate，不静默入库，不变量#3；`acknowledge_conflicts=true` 即作者裁决放行）。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤（越权表→`rejected_tables` 审计 log+丢弃，character-gen 只声明 writes=characters 故正常为空）。③ 写 `characters` 行（schema list/str → DB JSONB **dict** 形变，见写侧 repo）。404/503 同上。提交边界：precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`（含冲突 409 路径也 commit 落 precheck usage）。
- `GET /projects/{project_id}/rules` → **200** `RuleListResponse{rules:[RuleView{level,content}]}`（规则页；复用 C5 读侧 `SqlRulesRepo.all_for_project`，不动 assemble）。
- `GET /skills`（独立 `skills_router`，prefix `/skills`）→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`（技能库 UI；经 `get_skill_registry` 读 registry，按 name 升序）。
- 写侧 repo（`packages/core/ww_core/domain/`，经 `ww_core.domain` 导出）：`CharacterWriteRepo`/`SqlCharacterWriteRepo`/`CharacterWriteView{id,name,role}`（`character_repo.py`）+ `WorldEntityWriteRepo`/`SqlWorldEntityWriteRepo`/`WorldEntityWriteView{id,type,name}`（`world_entity_repo.py`，预留对称入库）。**形变**：`traits`/`speech_tics`(list)→`{"items":[...]}`、`arc`(str)→`{"text":...}`、`rules`(list)→`{"rules":[...]}`；`tags`/`relations`→直落 JSONB list（仅 character 入库端点已落地；world ingest 端点本期未建，仅 worldbuilder 走预览）。
- 注入缝（`services/project_deps.py`）：`get_worldbuilder_gateway`(writer)/`get_character_gen_gateway`(writer)/`get_precheck_gateway`(analyst)/`get_character_write_repo`/`get_world_entity_write_repo`/`get_rules_read_repo`。测试经 `app.dependency_overrides` 注 fake repo + schema-routing fake 网关（按 `req.output_schema` 返 WorldGenResult/CharacterGenResult/ContinuityReview）+ fake session（断 commit 计数）。
- **`build_gateway_for_tier` 多 provider 接线（T5.4 follow-up，已完成）**：据 DB `tier_routing` 取该 tier 的 `provider:model` + `fallback`，为每个可建适配器的 provider 预备适配器，注入 `chain_resolver=chain_from_routing(...)` 启用回退链。无 DB 路由行 → 退回全局 `resolve_route`（单 provider，M1 兼容）；无任何可用凭据 → 503。单 provider 配置 = 单元素链（行为不变）。
- **`build_adapter` per-provider 接线（M5 R1 follow-up #2，2026-06-19，已完成）**：`_build_provider_adapter`（原 `_build_openai_compat_adapter`）现调 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`（C1 扩 follow-up #2 工厂）替代「一律 `OpenAICompatAdapter`」——OpenAI 兼容 provider（deepseek/kimi/qwen/glm/openai）经 `_PROVIDER_BASE_URLS` 传 base_url；Anthropic/Gemini 传 `base_url=None` 走原生适配器。这样 `tier_routing` 链里配置的 Anthropic/Gemini provider 也能拿到**真实适配器**参与回退（不再被 base_url 缺失跳过）。未配凭据的 provider 返 None（回退链跳过）。**无 OpenAPI 形变**（served_by 不出 API）。真实 Anthropic/Gemini 跑通仍需 @devops 加 `anthropic`/`google-genai` SDK 依赖（适配器懒导入/注入客户端）。
- **@frontend 行动**：T5.6 前 `pnpm gen:api` 纳入 world/characters generate + characters ingest + GET rules + GET skills。

### C3 扩展（M5 R1 follow-up, 2026-06-19）· 设定库 Codex 读端点（GET characters / world_entities）　owner @backend　状态: 稳定
- 补 PROGRESS「Codex 读端点缺口」余项：设定库 Codex 现可展示跨会话**全量**已入库角色/世界观（前端先前只能「生成+本次入库会话内回显」）。snake_case；新增端点 → **@frontend 必须 `cd apps/web && pnpm gen:api`** 重生成 TS 客户端 + CodexPage 初始列表去掉「会话内」局限（见 gotcha 2026-06-19 @frontend Codex 缺口条）。
- `GET /projects/{project_id}/characters` → **200** `CharacterListResponse{characters:[CharacterCardView]}`（已入库角色全量；复用 C5 读侧 `SqlCharacterRepo`，经 `get_memory_repos` 的 `memory.character.list_for_project`，**不动 assemble**）。无行 → 空列表（非 404）。
- `GET /projects/{project_id}/world_entities` → **200** `WorldEntityListResponse{world_entities:[WorldEntityCardView]}`（已入库世界观实体全量；复用 C5 读侧 `SqlWorldEntityRepo`）。
- **形变（DB JSONB→API，T5.2 ingest 形变的逆向）**：`characters.traits`/`speech_tics` dict `{"items":[...]}`→裸 list、`arc` dict `{"text":...}`→str、`tags`/`relations`→直落 list（复用既有 `_existing_characters` helper）；`world_entities.rules` dict `{"rules":[...]}`→裸 list。
- **路由**：挂既有 `generation.router`（prefix `/projects`）；GET `/{project_id}/characters` 与 POST 同路径，FastAPI 按 method 区分，无冲突。注入缝复用 `get_memory_repos`（无新 dep）。测试经 `app.dependency_overrides[get_memory_repos]` 注 fake `MemoryRepos`。
- **@frontend 行动**：`pnpm gen:api` 纳入 `GET /projects/:id/characters` + `GET /projects/:id/world_entities`；`lib/api/server` 加 `fetchCharacters`/`fetchWorldEntities` + CodexPage 初始列表拉全量。

### C3 扩展（大纲读端点 follow-up, 2026-06-20）· `GET /projects/{id}/outline`（挂既有 `routers/outline.py`，已注册）　owner @backend　状态: 稳定
- 补缺口：大纲页先前**只有** `POST .../outline`（生成+持久化），无读端点 → 重访页面已落库的大纲不回显，看似「未保存」。仿 Codex 读端点（GET characters/world_entities）补对称读侧。
- `GET /projects/{project_id}/outline`（tag `outline`）→ **200** `OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView]}]}`（与 `POST .../outline` 响应**同形**，前端类型对齐），按 `chapter_no` 升序。项目存在但无大纲 → **200 空列表**（非 404）；项目不存在 → **404 `NOT_FOUND`**（仿 POST 的项目存在性检查）。**只读不写库**。
- **复用 C5 读侧**：扩 `OutlineRepo`(domain protocol) + `SqlOutlineRepo`(C5 assemble 读侧 repo) 加 `list_for_project(project_id)->list[OutlineView]`（仿 `CharacterRepo`/`WorldEntityRepo` 命名，order_by chapter_no）；既有 `get(project_id, chapter_no)` 不动，assemble 行为不变。新注入缝 `get_outline_read_repo`(`services/project_deps.py`，仿 `get_rules_read_repo`)。
- **beats 形变**：DB `outline.beats` JSONB dict `{"beats":[...]}` → 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`（同 POST 路径 / `OutlineWriteView._to_view`）。
- **@frontend 行动**：`pnpm gen:api` 纳入 `GET /projects/:id/outline`；大纲页初次加载拉已持久化大纲（去掉「生成后才显示」局限）。

### C3 扩展（草稿读端点 follow-up, 2026-06-20）· `GET /projects/{id}/chapters/{no}/draft`（挂既有 `routers/projects.py`，已注册）　owner @backend　状态: 稳定
- 补缺口：写作工作台先前**只有** `POST .../draft`(SSE 流写) + `PUT .../draft`(自动保存) 无读端点 → 重访工作台时编辑器空白、看似「未保存」（其实 `chapters` 行在库）。仿 `GET .../outline` 补对称读侧。
- `GET /projects/{project_id}/chapters/{chapter_no}/draft` → **200** `DraftView{project_id, chapter_no, volume, status, version, content, length}`（**比 PUT 的 `DraftResponse` 多 `content`**——读侧需正文重建编辑器；`length`=content 字符数派生）。无草稿行**或正文空白** → **404 `NOT_FOUND`**（工作台据此呈现空编辑器，与其它「缺资源」端点一致）。**只读不写库**。
- **复用读 seam**：直接调既有 `chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None`（同续审 `_resolve_review_draft` 的读缝）；`ChapterDraftView` 已含全部字段，无需新 repo 方法/新 view。多版本时固定返草稿版次（`DRAFT_VERSION=1`）那条可编辑工作副本，与 PUT 保存的同一行。
- **@frontend 行动**：`pnpm gen:api` 纳入 `GET /projects/:id/chapters/:no/draft`（新 schema `DraftView`）；工作台初次加载拉已保存草稿回灌编辑器（去掉「重访看似未保存」局限）。

### C3 扩展（T4-b, 2026-06-20）· `POST .../draft` 接收可选本章指令 `directive`（挂既有 `routers/projects.py`）　owner @backend　状态: 稳定
- **仅加可选 body，向后兼容**：`POST /projects/:id/chapters/:no/draft` 现接收可选 JSON body `DraftStreamRequest{directive: str|None=None}`（无 body 的旧调用方不变，仍按大纲生成）。`directive`=作者本章指令，**临时输入、不持久化、无迁移**。
- 直通 `assemble(..., directive=…)`→`_build_volatile` 以「本章指令」段领衔 volatile（断点后），**绝不入 stable_core**（守缓存前缀不变量 #9）。`draft_stream_start` 日志加 `directive_len`（不记原文，脱敏）。
- **@frontend 行动**：`pnpm gen:api` 纳入 `DraftStreamRequest`；`useDraftStream.start(projectId, chapterNo, directive?)` 非空时 POST JSON body 传 directive。

### C3 扩展（K1.3, 2026-06-19）· Kimi Code OAuth device-flow 端点（独立 `routers/kimi_oauth.py`，已注册）　owner @backend　状态: 稳定
- snake_case；新增 3 端点 + 3 schema → **@frontend 必须 `cd apps/web && pnpm gen:api`**（K1.4 前置）。**响应/job 结果/日志绝不含 access/refresh token**（只 user_code/connected/expires_at 等非密信息）；token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
- `POST /settings/providers/kimi-code/oauth/start` → **202** `OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}`。流程：`start_device_authorization(http)` → `job_repo.create(None,"kimi_oauth")` + `session.commit()`（202 前持久化供轮询）→ `background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))`。后台 `work` 在 `run_job` 自建独立 session 上**循环 `poll_token`**（`authorization_pending`→续轮询、`slow_down`→增大 interval、`expired`/`denied`→抛 AppError 置 job failed）；成功 → `upsert_oauth_credential`（加密包）+ job done（结果只 `{connected:true, provider}`）。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`。
- `POST /settings/providers/kimi-code/oauth/disconnect` → **200** `OAuthDisconnectResponse{disconnected:bool}`（`delete_credential` 清 OAuth 凭据行）。
- `GET /settings/providers/kimi-code/oauth/status` → **200** `OAuthStatusResponse{connected:bool, expires_at:str|None}`（解密 `oauth_enc` 取过期时刻；**无 token 本体**；解密失败/无凭据→`connected:false`）。
- **store OAuth 读写**（`services/credentials.py`，C2 扩 K1.1 收口）：`StoredCredential` 加 `auth_type:str`/`oauth_enc:bytes|None`，`api_key_enc` 改 `bytes|None`（K1.1 的 2 处 mypy red 已消）。新方法：`upsert_oauth_credential(owner,provider,oauth_enc)`（auth_type="oauth"+清 api_key_enc，read-modify-write 守可空 project_id 约束）、`delete_credential(owner,provider)->bool`。`upsert_credential`（api_key 路径）现显式 set auth_type="api_key"+清 oauth_enc。
- **`_build_provider_adapter` Kimi Code OAuth 接线**（`services/project_deps.py`）：provider `kimi-code` 或 `auth_type="oauth"` → `_resolve_kimi_code_token`：解密 `oauth_enc` → `needs_refresh`（剩余寿命 < 300s）则经临时 `httpx.AsyncClient` 调 `kimi_oauth.refresh` + `upsert_oauth_credential` 持久化新包 → 用（刷新后的）access token 调 `build_adapter("kimi-code", api_key=<token>, base_url=…)`（K1.2 工厂构带伪造头 + coding base 的 `KimiCodeAdapter`）。无 `oauth_enc`→`LLM_UNAVAILABLE`。`_PROVIDER_BASE_URLS` 加 `"kimi-code": "https://api.kimi.com/coding/v1"`。
- **服务缝**（`services/kimi_oauth.py`，注入 `AsyncHttpClient` Protocol = `httpx.AsyncClient.post` 子集，测试零联网）：`start_device_authorization(http)->DeviceAuth`、`poll_token(http, device_code)->TokenSet`（一次尝试，`AuthorizationPending`/`SlowDown` 异常供调用方循环）、`refresh(http, refresh_token)->TokenSet`；`encrypt_oauth_bundle`/`decrypt_oauth_bundle`（Fernet 复用 `security/credentials` 的 `encrypt_api_key`/`decrypt_api_key`，工作在 JSON 串上）；`needs_refresh(token)`。client_id env `KIMI_CLIENT_ID` 可覆盖默认 `17e5f671-...`；device authorization **带 `scope=kimi-code`**（coding entitlement；实测省略 scope→`api.kimi.com/coding/v1` 回 401，故重新带上。token 交换/刷新不带 scope）。
- **@frontend 行动（K1.4）**：`pnpm gen:api` 纳入 OAuth start/disconnect/status + 3 schema；连接流 = start 拿 user_code + 开 verification_uri → 轮询 `GET /jobs/{job_id}` 到 done（已连接）/failed（过期/拒绝）；status 端点查连接态；档位路由到 `kimi-code:kimi-for-coding`。

### C3 扩展（T6.2/T6.3, 2026-06-22）· 创作工具箱通用端点（独立 `routers/toolbox.py`，已注册）　owner @backend　状态: 稳定
- 通用生成器框架：声明驱动、一条执行路径——「加一个生成器」= 在 `ww_skills.TOOLBOX` 加一份 `GeneratorTool` 声明（descriptor 在代码、**无 DB 迁移**），无需新端点。snake_case；**新增 3 端点 + 新 schema → @frontend 必须 `cd apps/web && pnpm gen:api`**（T6 前端前置）。
- `GET /skills/toolbox` → **200** `ToolboxListResponse{tools:[ToolDescriptorView{key,title,subtitle,genre?,is_legacy:bool,ingestable:bool,input_fields:[ToolInputFieldView{name,label,type,required,default?,help?}],legacy_route?:str}]}`（legacy 3 + 新 8，按 key 升序）。legacy 工具（worldbuilding/character/outline，`is_legacy=true`）携 `legacy_route`（`/projects/{id}/codex?gen=world` · `?gen=character` · `/projects/{id}/outline`）供前端跳现有页面，不回归既测。
- `POST /projects/{project_id}/skills/{tool_key}/generate` ← `ToolGenerateRequest{brief:str="", chapter_no:int|None(ge1), count:int|None(1..12), kind:str|None}` → **200** `ToolGeneratePreviewResponse{tool_key, output_kind:str(产物 schema 名), preview:dict(各工具 output_schema 的 model_dump)}`。**预览不入库**（按 `spec.tier` 经 `get_tier_gateway_builder` 建网关 → `run_generator`）；末尾 `commit()` 仅落 ledger。context 派发（PURE `services/toolbox_context.build_toolbox_context`）：brief_only/with_project→`build_brief_context`、with_world→ + world_entities 硬规则卡、with_outline_chapter→ + 指定章 outline beats（**缺章/缺大纲→空节拍不报错**）。未知 tool_key→404；legacy 工具（spec=None）→**404**（前端走 legacy_route）；项目不存在→404；无凭据→**503** `LLM_UNAVAILABLE`。
- `POST /projects/{project_id}/skills/{tool_key}/ingest` ← `ToolIngestRequest{world_entities:[WorldEntityCardView], chapter_no:int|None, scenes:[OutlineSceneIngestView{idx,beat,purpose,conflict,hook}], acknowledge_conflicts:bool=false}` → **201** `ToolIngestResponse{table:str, created:list[str], rejected_tables:list[str]}`。**仅 `ingest!=None` 的工具**（golden-finger/glossary→world_entities，fine-outline→outline）；其余→**422 VALIDATION**。入库 gate（仿 `POST /characters`）：world_entities 走 **continuity 预检（analyst 网关）** 比对待入库实体 vs 既有世界观真相源——有冲突且 `acknowledge_conflicts=false`→**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}],conflict_count}`；`partition_writes(spec,{table:items})` 白名单过滤（越权→`rejected_tables` 审计）；schema→DB JSONB 形变写库（rules→`{"rules":[...]}`；细纲场景按 idx 升序拼成该章 beats，`outline_write_repo.upsert_chapter`）。outline（细纲）不做 continuity 预检（场景是粗节拍展开，非设定/角色比对），无 chapter_no→422。未知/legacy→404。提交边界：ledger + 写侧只 flush → 端点末尾一次 `commit()`（含 409 路径也 commit 落 precheck usage）。
- **注册表**（`packages/skills/ww_skills/toolbox_registry.py`，经 `ww_skills` 导出）：`TOOLBOX:dict[str,GeneratorTool]`（11 条）+ `get_tool(key)->GeneratorTool|None`。新工具的 `key==spec.name`、`output_schema is spec.output_schema`。
- **注入缝**（`services/project_deps.py`）：`get_tier_gateway_builder`→`TierGatewayBuilder=Callable[[Tier],Awaitable[Gateway]]`（按工具 tier 动态建网关）；测试经 `app.dependency_overrides[get_tier_gateway_builder]` 注返 mock 网关的 builder + `get_world_entity_write_repo`/`get_outline_write_repo`/`get_memory_repos`/`get_project_repo` 注 fake（schema-routing fake 网关按 `req.output_schema` 返 IdeaListResult/ContinuityReview/…）。
- **守不变量**：#2（spec 只声明 tier）/#3（预览不写库；入库经 continuity gate + 白名单）/#9（system_prompt 进缓存前块、注入材料进 input）。**无新迁移**（descriptor 在代码，复用 world_entities/outline 表）。
- **@frontend 行动**：`pnpm gen:api` 纳入 `GET /skills/toolbox` + `POST .../skills/{tool_key}/generate` + `POST .../skills/{tool_key}/ingest`（新 schema `ToolboxListResponse`/`ToolDescriptorView`/`ToolInputFieldView`/`ToolGenerateRequest`/`ToolGeneratePreviewResponse`/`ToolIngestRequest`/`ToolIngestResponse`/`OutlineSceneIngestView`）；工具箱落地页据 `GET /skills/toolbox` 渲染卡片栅格（legacy 走 `legacy_route` 跳页，新工具据 `input_fields` 声明驱动表单 → generate 预览 → 可入库者 ingest + 复用 409 冲突裁决）。

## C8 · Skill registry + 表权限沙箱　owner @backend　状态: 稳定（T5.5, 2026-06-19；M5 R3 迁入 ww_skills 包, 2026-06-19）
- 来源：`ARCHITECTURE.md §5.6` / `PRODUCT_SPEC §5.5`（声明式 Skill = AgentSpec；「声明式权限 + apply 层白名单」，**非进程沙箱**）。消费方：M5 编排/生成入库层、技能库 UI（T5.6，经 C3）。
- **实现位置（M5 R3 已迁移）**：实现现落 `packages/skills/ww_skills/{skill_registry,skill_permissions}.py`（`packages/skills` 已是 uv workspace member）。`ww_skills/__init__.py` **直接导出**（不再是再导出 ww_core 的门面）。消费方 `from ww_skills import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES`。**`ww_core.domain` 已删除这两个模块 + 其 `__init__` 导出**（不再经 ww_core.domain 暴露 skill 符号）。历史：T5.5 曾因 skills 非 workspace member 暂落 ww_core.domain（见 decisions），现已迁回。
- **registry**：`SkillRecord{name,scope,tier:Tier,system_prompt,reads:list[str],writes:list[str],genre?}`（frozen，`skills` 行的声明式快照；input/output JSON Schema 暂不携带 Python 类型——纯声明执行）。`SkillRepo`(Protocol)`.list_all()->list[SkillRecord]`；`SqlSkillRepo(session)` 读 `skills` 表（裸 `tier` 非法 → VALIDATION）。`SkillRegistry.load(repo)`（classmethod，async）→ 每条跑 `validate_declaration`（越权声明 → **AppError VALIDATION**，加载中断）→ frozen `dict[name,AgentSpec]`；`get(name)->AgentSpec`（缺 → NOT_FOUND）、`names()->list[str]`、`list_scope(scope)->list[AgentSpec]`。不变量 #2：只带 `tier`，不解析 model（model 解析在网关）。
- **表权限沙箱**（`skill_permissions.py`，纯函数，不可变）：`KNOWN_TABLES`（10 创作表白名单：projects/characters/world_entities/outline/chapter_digests/foreshadow/style_fingerprint/rules/chapters/chapter_reviews；系统/运营表不在列）。`filter_reads(spec, available)->dict`（注入只喂声明 `reads` 的表）；`partition_writes(spec, produced)->(allowed:dict, rejected:list[str])`（写库只应用声明 `writes`，越权表名进 rejected 供审计 log + 丢弃）；`validate_declaration(spec)`（reads/writes 越 `KNOWN_TABLES` → VALIDATION）。不变量 #3：`allowed` 仍须过验收 gate 才真入库，本层只裁剪白名单、不开写后门。
- 注入缝：`get_skill_registry`（`SkillRegistry.load(SqlSkillRepo(session))`，async dep）。T5.2 生成入库层应用 `partition_writes` 落自定义 skill 产出（仅声明 writes 表，越权审计），仍经验收。
- **@llm 注意**：自定义 skill 经网关执行时复用 §5.1 机制（registry 给 spec，网关按 tier 路由）；apply 层（编排/入库）须调 `filter_reads`/`partition_writes` 守白名单。

## C3.5 · jobs 长任务基建（写/进度/回收 + run_job）　owner @backend　状态: 稳定（T4.1, 2026-06-18）
- 来源：ARCH §7.4（`jobs` 表 + `GET /jobs/:id` 轮询；BackgroundTasks 无专用队列）。`jobs` 表 + `Job` 模型 + `GET /jobs/{id}` 读端点已 T0.3 建齐；T4.1 补**写/进度/回收**层，供 **T4.3「学文风走 jobs」** 复用。
- 位置：`packages/core/ww_core/domain/job_repo.py`（经 `ww_core.domain` 导出 `JobRepo`/`JobView`/`SqlJobRepo`）+ `apps/api/ww_api/services/job_runner.py`（`run_job`）+ `services/project_deps.get_job_repo` + lifespan reaper。
- **`JobView`**(frozen Pydantic, snake_case)：`id:uuid`、`kind:str`、`status:str`、`progress:int=0`、`result:dict|None=None`、`error:str|None=None`（镜像 `GET /jobs/{id}` 出参）。
- **`JobRepo`** Protocol（写方法**只 flush** 不 commit，提交归 `run_job`/端点；唯 `reap_zombies` 自 commit）：
  - `async create(project_id: uuid|None, kind: str) -> JobView`（status=`queued`, progress=0）
  - `async set_running(job_id) -> JobView`（status=`running`）
  - `async set_progress(job_id, pct: int) -> JobView`（pct 夹取到 0..100）
  - `async complete(job_id, result: dict) -> JobView`（status=`done`, progress=100, result=<dict>）
  - `async fail(job_id, error: str) -> JobView`（status=`failed`, error=<str>）
  - `async get(job_id) -> JobView | None`
  - `async reap_zombies() -> int`（bulk UPDATE `running`→`failed`(+error 文案)，返回被改条数，**自 commit**）
  - 状态写方法对不存在的 job 抛 `LookupError`。状态常量导出：`STATUS_QUEUED/RUNNING/DONE/FAILED`、`PROGRESS_COMPLETE=100`（`job_repo` 模块级）。
- **`run_job` 缝**（`services/job_runner.py`）：`async def run_job(session_factory: SessionFactory, job_id: uuid, work: JobWork, *, request_id: str|None=None, repo_factory=_default_repo_factory) -> None`，其中 `JobWork = Callable[[AsyncSession], Awaitable[dict]]`。语义：**自建独立 session**（`session_factory`，仿 `run_overdue_scan`，请求 session 已关闭）→ `set_running` → `await work(session)`（业务写与 job 状态写同一 session）→ `complete(job_id, work 返回值)` → `commit`；异常路在**全新** session 里 `fail(job_id, str(exc))` + `commit`，**异常被吞不冒泡**（后台任务边界）。`SessionFactory` 复用 `services/foreshadow_scan.SessionFactory`（= `get_session_factory`/`get_sessionmaker()`）。`repo_factory: Callable[[AsyncSession], JobLifecycleRepo]`（最小 Protocol：仅 `set_running`/`complete`/`fail`）是可注入缝，单测注 fake。
- **lifespan reaper**（M4-d）：`main.py` `_lifespan` 在 `seed_stub_user` 之后 `SqlJobRepo(session).reap_zombies()`（幂等、自 commit）——进程重启丢 BackgroundTask 的残留 `running` 行标 `failed`，用户可见可重试。
- **T4.3 行动**：`POST /style` → `job_repo.create(project_id,"style_learn")` + commit 返 202 `{job_id}`；`background_tasks.add_task(run_job, session_factory, job.id, work=<部分应用 run_style_extraction→StyleFingerprintWriteRepo.append 并返回 {version,dims_count} 摘要>)`。`work` 内自建 gateway（analyst）+ repo（用 `run_job` 传入的独立 session）。注入缝 `get_job_repo`/`get_session_factory` 测试经 `app.dependency_overrides` override。

## C4 · 编排器接口（LangGraph 写章图）　owner @llm　状态: 稳定（M1/T1.3, 2026-06-18；M2 扩四审/验收）
- 来源：`ARCHITECTURE.md §5.2`（图）/ §7.3（SSE）。**M1 仅单 `write` 节点**；并行四审/collect/interrupt(accept) 属 M2。
- 位置：`packages/core/ww_core/orchestrator/`。
- 图状态 `ChapterState`(TypedDict, snake_case)：`{project_id:UUID, chapter_no:int, user_id:UUID, stable_core:str, volatile:str, draft:str}`——仅控制流+组装上下文+累积草稿（不变量#5）；resume 从领域表重读。
- 节点缝：
  - `build_write_request(*, stable_core, volatile, user_id, project_id) -> LlmRequest`（纯函数；`tier="writer"`，`system=[Block(stable_core,cache=True)]`，`input=volatile`，`stream=True`，`scope=Scope(user_id,project_id)`）。
  - `async stream_chapter_draft(gateway, *, stable_core, volatile, user_id, project_id) -> AsyncIterator[Delta]`——**T1.4 拿来喂 `normalize_deltas` 的底层流缝**。
  - `async write_node(state, *, gateway) -> {"draft":str}`——可直接单测（注入 mock 网关）。`GatewayStream` Protocol = 节点对网关最小依赖（只需 `.stream(req)`）。
- **SSE 归一缝（T1.4 消费）**：`async normalize_deltas(deltas, *, request_id=None) -> AsyncIterator[SseEvent]`；`SseEvent{event:str, data:dict}`；事件 `token{text}`/`done{length}`/`error{code,message,request_id}`（ARCH §7.3 子集，M2 加 section/conflict）。底层异常→发 `error` 事件后收尾（不上抛）；`AppError.code` 透传，未知→`INTERNAL`。HTTP event-stream 编码归 T1.4。
- 图工厂：`build_write_graph(gateway, *, checkpointer=None) -> CompiledStateGraph`（START→write→END）；单测传 `MemorySaver`。
- checkpointer setup 入口：`async setup_checkpointer(conn_string) -> None`——**只在 migrations/CI 调**（内部 `AsyncPostgresSaver.from_conn_string` 跑一次 `setup()` DDL；懒 import，绝不在 app-runtime 跑）。
- 不变量：agent 只传 tier；DB 唯一 agent 间通道；不可变更新；瞬时重试在网关不在节点。

### C4 扩展（T2.2, 2026-06-18）· 审稿子图 + review SSE
- 图工厂 `build_review_graph(gateway, review_repo, *, review_specs=(continuity_spec,), checkpointer=None) -> CompiledStateGraph`：`START →`各 review spec 并行节点`→ collect → END`。M2 默认仅 continuity，`review_specs` 可扩（M3/M4 加 foreshadow/style/pace）。**`build_write_graph` 保留不动**（draft 端点仍用它）。
- `ChapterState`(state.py) 新增 `review_context:str`、`reviews:Annotated[dict, merge_reviews]`（并行分支浅合并 reducer，返回新 dict）；TypedDict 改 `total=False`（按节点逐步填充）；仍仅控制流+组装上下文+产物句柄（不变量#5）。
- 节点缝：`run_review(spec, state, *, gateway)->{"reviews":{spec.name:{status,result}}}`（裸函数可单测）；`make_review_node(spec, gateway)`（绑 gateway 的公共缝，供 T2.5 跑单审）；审稿用 `gateway.run()`（非 stream），**只读不写库**（不变量#3）；任一审网关失败被隔离为 `{status:"incomplete",result:None}`（§5.2），不上抛、不毁图。`GatewayRun` Protocol=节点对网关最小依赖（`.run(req)->LlmResponse`）。
- collect 缝：`collect_reviews(state, *, review_repo)->{}`：抽 continuity 冲突 → `review_repo.record(project_id, chapter_no, chapter_version=None, conflicts=[...])` 落 `chapter_reviews` 留痕；**只 flush 不 commit**（提交归端点/T2.4 事务）。`ReviewRecorder` Protocol 形对齐 `domain.review_repo.ReviewRepo.record`。
- SSE 新增事件：`section{name,status}`（status ∈ started/done/incomplete）、`conflict{type,where,refs,suggestion}`（对齐 C6 `Conflict`）；新归一缝 `normalize_review(reviews, *, request_id=None)->AsyncIterator[SseEvent]`（每审一条 section + 每冲突一条 conflict + `done{length=审项数}`；异常→`error` 不上抛，同 `normalize_deltas` 纪律）。HTTP event-stream 编码归 T2.5。
- 一键采纳改法（方案1）：`conflict` SSE 事件 + `ReviewConflictView`（留痕）+ C6 `Conflict` schema 新增可选 `original?:str|null`/`replacement?:str|null`（一键采纳补丁对，最小句段；审稿可局部修复时提议，前端「采纳改法」把 original→replacement find-replace 进终稿，找不到则不动正文提示手改）。**向后兼容**：缺省 null；审稿仍只读（只提议不改稿，不变量#3）。
- **accept 不在本图**：`interrupt_before=["accept"]`/accept 节点属 T2.4（确定性事务代码，从领域表重读，R3/不变量#5）。
- 消费方行动：T2.5 跑 review 子图 + `normalize_review` + 端点**流耗尽后 `await session.commit()`**（网关 ledger + collect 均只 flush，不提交则记账/留痕静默丢失，同 M1 draft 坑）；`GET .../reviews` 用 `review_repo.list_for_chapter`(新→旧)。T2.4 从 `review_repo.list_for_chapter` 读 collect 落的行（`decisions=None`）裁决。

### C4 扩展（T3.3, 2026-06-18）· 三审齐（foreshadow + pace 并入）
- `REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec)`；`build_review_graph(..., review_specs=REVIEW_SPECS)` 默认三审齐（文风第四审留 M4）。`run_review`/`make_review_node` 对 spec 泛型（按 `req.output_schema` 产 parsed）。
- collect 列映射（一次 `review_repo.record(...)` 落齐）：continuity→`conflicts`(list)；foreshadow→`foreshadow_sug`(list，把 `planted`/`resolved` 扁平成单 list、每条加 `kind:"planted"|"resolved"`)；pace→`pace`(dict，整 `{water,hook,beat_map}` 入列)；style 留 M4。任一审 incomplete→该列空/None，不阻塞其余（§5.2）。
- `normalize_review` SSE（向后兼容，`section`/`conflict` 不变）新增：`foreshadow{kind,code?,title,where?,note?}`（每条建议一条）、`pace{water:[{where,reason}],hook:bool,beat_map:[int]}`（一条）。键按字典序确定性遍历（continuity<foreshadow<pace）。incomplete 审仅发 `section` 不发结果。
- 消费方：前端 T3.6（冲突就地标注 / 伏笔看板联动 / 节拍图 ▁▃▅）、E2E T3.7。

### C4 扩展（T4.2, 2026-06-19）· 四审齐（style 漂移并入）+ style SSE
- `REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec, style_drift_spec)`；`build_review_graph(...)` 默认四审齐。图工厂/`run_review`/`make_review_node` 对 spec 泛型，**无改动**（按 `req.output_schema` 产 parsed，style 天然套循环）。
- collect 列映射新增：`extract_style(reviews)->dict|None`（取 style 审 ok 结果整 dict）；`collect_reviews` 末尾 `style=extract_style(reviews)` 传入 `review_repo.record(...)`（`chapter_reviews.style` JSONB 列已存在）。无指纹降级（score=100/空段）仍是 `ok` 结果、照常落库（区别于 incomplete→None）。
- `normalize_review` SSE（向后兼容）新增：`style{score:int, segments:[{idx:int,score:int,label:str|None}]}`（一条）。键按字典序遍历 → continuity<foreshadow<pace<style，确定性顺序。incomplete 审仅发 `section{status:incomplete}` 不发结果。
- 常量/工厂导出（经 orchestrator `__init__`）：`STYLE="style"`、`extract_style`（collect）；`EVENT_STYLE="style"`、`style_event(*, score, segments)`（sse）。
- 消费方：前端 T4.4（StylePanel ◔ 相似度 + 漂移段一键回炉，`section{name:"style"}`/`style{...}` reduce）、E2E T4.5（断言事件真发 + `chapter_reviews.style` 真填）。

### C6 扩展（T4.2, 2026-06-19）· style-auditor 双轨 + refiner spec/schema
- 位置：`packages/agents/ww_agents/{schemas.py,specs.py}`（经 `ww_agents` 导出）；提取节点在 `packages/core/ww_core/orchestrator/style_extract_node.py`（经 orchestrator 导出）。
- **提取轨 schema**：`StyleDimension{name:str, value:str, evidence:list[str]=[]}`、`StyleFingerprintResult{dimensions:list[StyleDimension]=[]}`（16 维 = 9 通用 + 7 中文网文，每维带原文证据）。
- **漂移轨 schema**：`StyleDriftSegment{idx:int, score:int, label:str|None=None}`、`StyleDriftReview{score:int=100, segments:list[StyleDriftSegment]=[]}`（**默认值即无指纹降级态**：score=100/空段）。
- **`style_extract_spec`**(frozen `AgentSpec`)：`name="style_extract"`, `tier="analyst"`, `output_schema=StyleFingerprintResult`, `reads=["style_fingerprint"]`, `writes=["style_fingerprint"]`（声明式，真写库经 T4.3）。独立生成（仿 outliner），**不进 review 图**。
- **`style_drift_spec`**：`name="style"`(=section/列名), `tier="light"`, `output_schema=StyleDriftReview`, `reads=["style_fingerprint"]`, `writes=[]`（只读，第四审）。system_prompt 含「材料无指纹→返回 score=100/空段」降级指令。
- **`refiner_spec`**：`name="refiner"`, `tier="writer"`, `output_schema=None`（纯文本重写段）, `reads=[]`, `writes=[]`（非持久；端点同步返回 `{original,refined}` 不写库，作者采纳经既有 draft 自动保存合入，不变量 #3）。
- 提取节点缝（**模块内自有 `GatewayRun` Protocol**，不跨模块复用、不在 `__init__` 重复导出，见 gotcha）：`build_style_extract_request(spec, *, samples_text, user_id, project_id)->LlmRequest`(`output_schema=StyleFingerprintResult`,`stream=False`,system 缓存断点前块) + `async run_style_extraction(spec, *, samples_text, gateway, user_id, project_id)->StyleFingerprintResult`(`gateway.run().parsed`；**只读不写库**；网关失败**直接上抛**不隔离；parsed 违约抛 `ValueError`，仿 run_outline)。
- 消费方：T4.3 端点 `POST /style` 经 BackgroundTask 调 `run_style_extraction` 拿 `StyleFingerprintResult` → 拆 `dimensions_json`/`evidence_json` 写 `style_fingerprint` 表；`POST .../refine` 调 `refiner_spec`(writer 网关) `run()` 重写段返回 `{original,refined}`。**@frontend 行动**：T4.4 前 `pnpm gen:api` 纳入 style/refine/jobs 端点。

### C6 扩展（T5.1, 2026-06-19）· worldbuilder + character-gen spec/schema + 入库前 continuity 校验缝
- 状态：**稳定**。位置：`packages/agents/ww_agents/{schemas.py,specs.py}`（经 `ww_agents` 导出）；生成节点在 `packages/core/ww_core/orchestrator/generation_node.py`（经 orchestrator 导出）。**T5.2 入库端点据此构建**（field 名已对齐 `world_entities`/`characters` DB 列，T5.2 ingest 直接映射）。
- **worldbuilder schema**：`WorldEntityCard{type:str, name:str, rules:list[str]=[]}`（rules = **显式硬规则清单**，供 continuity 引用）、`WorldGenResult{entities:list[WorldEntityCard]=[]}`。映射 `world_entities` 表：type/name 列 + rules 入 JSONB（DB 列 `rules` 是 JSONB；入库可包成 `{"rules":[...]}` 或裸 list，由 T5.2 定夺）。
- **character-gen schema**：`CharacterRelation{name:str, kind:str, note:str|None=None}`、`CharacterCard{name:str, role:str, traits:list[str]=[], backstory:str, arc:str, speech_tics:list[str]=[], tags:list[str]=[], relations:list[CharacterRelation]=[]}`（**必填**：name/role/backstory/arc；其余集合默认空）、`CharacterGenResult{cards:list[CharacterCard]=[]}`。映射 `characters` 表：name/role/backstory 列 + traits/arc/speech_tics/tags/relations 入各自 JSONB（DB `traits`/`arc`/`speech_tics` 是 JSONB dict 列、`tags`/`relations` 是 JSONB list——T5.2 ingest 须把 `list[str]` 的 traits/speech_tics 包进 dict 或调整，按 DB 列形落库；`role` = 角色定位 主角/CP/对手/导师/工具人）。
- **`worldbuilder_spec`**(frozen `AgentSpec`)：`name="worldbuilder"`, `tier="writer"`, `output_schema=WorldGenResult`, `reads=["projects"]`, `writes=["world_entities"]`（声明式，真写库经 T5.2）。独立生成（仿 outliner），**不进 review 图**。system_prompt 强调硬规则显式可校验。
- **`character_gen_spec`**：`name="character-gen"`, `tier="writer"`, `output_schema=CharacterGenResult`, `reads=["world_entities","characters"]`, `writes=["characters"]`。system_prompt **注入「已生成卡 + 已有角色」差异化要求（M5-a 防雷同）**——含「已有角色」「已生成卡」「防雷同」字样。
- 生成节点缝（**模块内自有 `GatewayRun` Protocol**，不跨模块复用、不在 `__init__` 重复导出，见 gotcha；导出公共函数 + 三个 context builder）：
  - `async run_worldbuilder(spec, *, brief, project_context, gateway, user_id, project_id)->WorldGenResult`（+ `build_worldbuilder_context(*, brief, project_context)->str`）。
  - `async run_character_gen(spec, *, brief, count:int, role:str|None, world_context:str, existing_chars:Sequence[CharacterCard], generated_so_far:Sequence[CharacterCard], gateway, user_id, project_id)->CharacterGenResult`（+ `build_character_gen_context(*, brief, count, role, world_context, existing_chars, generated_so_far)->str`——**防雷同上下文**：注入已有+已生成卡的 `name(role)：traits` 简表，要求差异化；空时给「（暂无…）」占位不崩）。
  - **入库前 continuity 校验缝（ARCH §6.5，T5.2 调）**：`async precheck_generated_cards(spec, *, cards:Sequence[CharacterCard], world_context:str, characters_context:str, gateway, user_id, project_id)->list[Conflict]`（+ `build_precheck_context(*, cards, world_context, characters_context)->str`）。**复用 `continuity_spec`**（analyst 档，只读）跑生成卡 vs 世界观/已有角色真相源比对、返回 `list[Conflict]`（C6 `Conflict` 五类）。**编排器追加的检查，非 character-gen 直接互调**（守 §5.4 数据流/不变量 #1）。T5.2 入库端点用返回的 conflicts 做 gate（有冲突→提示作者裁决/调整，不静默入库）。
  - 三函数均：生成用 `run()` 非 `stream()`；`output_schema=spec.output_schema`；system 缓存断点前块（cache=True，#9）；网关失败**直接上抛**（端点处理，独立生成不做失败隔离）；parsed 违约抛 `ValueError`（仿 run_outline/run_style_extraction）；**只读不写库**（#3）。
- 测试替身：复用 `packages/core/tests/fakes_orchestrator.py` 的 `FakeRunGateway`/`SchemaRoutingRunGateway`/`FailingRunGateway`（按 `req.output_schema` 路由 parsed）。
- 消费方：**T5.2 端点** `POST /world/generate` 调 `run_worldbuilder`、`POST /characters/generate` 调 `run_character_gen`（批量循环时把已产卡传 `generated_so_far` 防雷同）、`POST /characters`(入库) 前调 `precheck_generated_cards` gate；analyst 网关复用 `build_gateway_for_tier(session,store,"analyst")`，writer 用 `"writer"`。记账闭环同既有（端点末尾 commit）。

## C5 · 记忆服务 `assemble` / `select_relevant_entities`　owner @backend　状态: 稳定（T1.2, 2026-06-18）
- 来源：`ARCHITECTURE.md §3.4 / §5.3`（确定性选择：显式+主角+近况；渲染卡片；缓存断点）。
- 位置：`packages/core/ww_core/memory/`（+ `domain/repositories.py`）。
- 输出（决策：中性文本，非 `LlmRequest`，见 decisions 2026-06-18）：
  - `AssembledContext{ stable_core:str, volatile:str, selection:SelectionTrace }`
    - `stable_core`：断点前（**作品蓝本(title/logline/premise/theme)**+世界硬规则+定型主角(无 latest_state)+文风指纹+合并规则），已排序/无时间戳/无 UUID。「作品蓝本」是书级 spec、定型 → 入缓存前缀首段。
    - `volatile`：断点后（**写作指令「请创作第 {chapter_no} 章的正文。」始终在场**+注入卡片(含 latest_state)+伏笔窗口+近况摘要+本章 beats）。
  - **空-prompt 保证（2026-06-19 修，bugfix）**：哪怕世界观/角色/大纲全空，只要项目有 premise/title，`stable_core`(含作品蓝本) 与 `volatile`(含写作指令) **均非空** → writer 的 user message 永不为空（修 LLM `400 "message at position 0 ... must not be empty"`）。
  - `SelectionTrace{ selected:list[SelectedEntity] }`；`SelectedEntity{ kind:"character"|"world_entity", name:str, reasons:list[SelectionReason] }`；`SelectionReason="explicit_beat"|"main_character"|"recent_digest"|"foreshadow_window"`。
- 入口：`async assemble(repos:MemoryRepos, project_id, chapter_no, recent_k=5)->AssembledContext`；纯函数 `select_relevant_entities(*, outline, characters, world_entities, recent_digests)->SelectionTrace`；`render_cards(selection, characters, world_entities)->str`；`merge_rules(rules)->list[RuleView]`（global→genre→style→project）。
- 依赖注入：`MemoryRepos` 捆绑 **8** 个 Protocol（Outline/Character/WorldEntity/Digest/Foreshadow/Style/Rules/**Project**）；单测注入内存 fake，运行时 `sql_memory_repos(AsyncSession)`（**T1.4 注入点**）。`ProjectSpecRepo.spec(project_id)->ProjectSpecView|None`（`ProjectSpecView{title, logline?, premise?, theme?}`，按 project_id 读 `projects` 行；2026-06-19 新增，**消费方须在 `MemoryRepos(...)` 补 `project=` 字段**）。
- 不变量：确定性选择(无向量 #6)；统一 project_id 过滤(§3.5)；latest_state 严格归 volatile(#9)。
- 消费方：**T1.3 write 节点** → 构造 `LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)`。

## C6 · Agent 声明（AgentSpec）+ 续审 I/O schema　owner @llm　状态: 稳定（T2.1, 2026-06-18）
- 来源：`ARCHITECTURE.md §5.1 / §5.4 / §6.1`。位置：`packages/agents/ww_agents/`（`specs.py` / `schemas.py`，经 `ww_agents` 导出）。
- **`AgentSpec`**（frozen Pydantic，不可变）：`name:str`、`tier:Tier`(writer/analyst/light，复用 `ww_llm_gateway.types.Tier`——只声明档位不写 model，不变量#2)、`system_prompt:str`、`input_schema:type[BaseModel]|None`、`output_schema:type[BaseModel]|None`(writer 为 None=纯文本)、`reads:list[str]`、`writes:list[str]`、`genre:str|None=None`、`scope:str="builtin"`。
- **`continuity_spec`**：`tier="analyst"`、`reads=["chapter_digests","characters","world_entities"]`、`writes=[]`(只读，不变量#3)、`input_schema=None`(注入材料为序列化文本)、`output_schema=ContinuityReview`。
- **`ContinuityReview{ conflicts: list[Conflict] }`**（仅冲突；digest 不在审稿期产，不变量#4）。
- **`Conflict{ type: ConflictType, where:str, refs:list[str]=[], suggestion:str }`**；`ConflictType = Literal["性格漂移","能力不符","设定违例","地理矛盾","时间线倒错"]`（ARCH §6.1 五类）。
- 续审节点调法：`req = LlmRequest(tier="analyst", system=[Block(system_prompt, cache=True)], input=审稿上下文文本, output_schema=ContinuityReview, scope=...)` → `resp = await gateway.run(req)` → `resp.parsed` 为 `ContinuityReview` 实例（带 schema 时必非 None）。续审用 `run()` 非 `stream()`；只读+产冲突，不写库（写在 T2.4 验收事务）。
- 消费方：编排器(T2.2 续审/collect 节点)、技能运行时(M5)、前端审稿页(经 C3)。foreshadow/style/pace 三审 spec 待 M3-T3.3/M4 补入本契约。

### C6 扩展（T3.4, 2026-06-18）· outliner Agent + 大纲/伏笔窗口 schema
- 位置：`packages/agents/ww_agents/{schemas.py,specs.py}`（经 `ww_agents` 导出）；节点在 `packages/core/ww_core/orchestrator/outline_node.py`（经 orchestrator 导出）。
- **`outliner_spec`**(frozen `AgentSpec`)：`name="outliner"`、`tier="analyst"`、`reads=["projects","foreshadow","characters","world_entities"]`、`writes=["outline"]`(声明式，真写库经 T3.5，不变量#3)、`input_schema=None`、`output_schema=OutlineResult`。
- **`OutlineResult{chapters:list[OutlineChapter]}`**；**`OutlineChapter{no:int, beats:list[str], foreshadow_windows:list[ForeshadowWindow]}`**；**`ForeshadowWindow{code:str, plant_chapter?:int, expected_close_from?:int, expected_close_to?:int}`**（§6.2 关联章与伏笔）。
- 节点缝（沿用 review_node 的 `GatewayRun` Protocol `.run(req)->LlmResponse`）：`build_outline_request(spec, *, context, user_id, project_id)->LlmRequest`(`output_schema=OutlineResult`,`stream=False`) + `async run_outline(spec, *, context, gateway, user_id, project_id)->OutlineResult`(`gateway.run().parsed`；**只读不写库**；大纲独立生成、非并行审，网关失败**直接上抛**不隔离，parsed 违约抛 `ValueError`)。
- 消费方：T3.5 端点调 `run_outline` 拿 `OutlineResult` → 逐章 upsert `outline` 表（`beats`/`foreshadow_windows` 入 JSONB，`volume` 由端点分卷逻辑提供，schema 不含 volume）；analyst 网关复用 `build_gateway_for_tier(session,store,"analyst")`/`get_review_gateway`；记账闭环同 M2（端点末尾 `commit()`）。T3.6 前端大纲编辑器消费窗口。

### C6 扩展（T3.3, 2026-06-18）· foreshadow-analyst + pace-checker spec
- `foreshadow_spec`(name="foreshadow", tier="analyst", reads=["foreshadow"], writes=[]只读, output=`ForeshadowReview{planted:[ForeshadowSuggestion],resolved:[ForeshadowSuggestion]}`；`ForeshadowSuggestion{code?,title,where?,note?}`)。
- `pace_spec`(name="pace", tier="light", reads=["rules"], writes=[]只读, output=`PaceReview{water:[PaceIssue{where,reason}],hook:bool,beat_map:[int]}`)。genre 模板 DSL = pace system_prompt 内描述黄金三章/章末钩子/爽点密度，genre 级模板经 review_context 规则合并(`rules`)注入，**未新建表/repo**。
- 三审只读，留痕经 collect→review_repo（写库 commit 归端点）。详细图/SSE 见上「C4 扩展（T3.3）」。

## C7 · 前端 ↔ 后端类型契约　owner @backend(产出 OpenAPI) / @frontend(生成)　状态: 待定义
- 机制：FastAPI OpenAPI → `apps/web/lib/api` TS 类型（openapi-typescript/orval）。
- 规则：后端 schema 任何变更 → 跑 `gen:api` 重生成；不手写共享类型。

---

## C-Chain · 多章工作流链端点 + 服务层　owner @backend / @llm(图)　状态: 稳定（C2, 2026-06-23）
> 设计真源：`docs/design/chain-workflow.md`。C1(@llm) 图签名 §3.3；C2(@backend) 端点/schema/服务接线。
- **3 端点**（`routers/chain.py`，挂 `/projects` 前缀）：
  - `POST /projects/{pid}/chains/{chain_key}/run` ← `ChainRunRequest{start_chapter_no:int>=1, count:int 1..50}` → 202 `ChainRunAccepted{job_id, chain_key, start_chapter_no, count}`。写一行 `jobs(queued, kind="chain")` 返 202，链经 BackgroundTask `run_chain_job` 跑（自建独立 session）。未知 chain_key→404（仅 `draft_volume`）；count 越界→422（schema Field）；项目不存在→404；无凭据→503。
  - `GET /jobs/{job_id}`（**复用**现有）：轮询进度/awaiting。链 job `result = {chain_key, written:[int], completed:bool, awaiting_chapter:int|null}`；interrupt 命中时 `status="awaiting_input"` + `result.awaiting_chapter`。
  - `POST /projects/{pid}/chains/runs/{job_id}/resume` ← `ChainResumeRequest{decisions:[ConflictDecision]}` → 202 `ChainRunAccepted`。仅当 job=`awaiting_input`；非该态→**409 `CONFLICT`**；job 不存在→404。带裁决 `Command(resume=...)` 续跑。
- **schema**（`schemas/chain.py`）：`ChainRunRequest`/`ChainRunAccepted`/`ChainResumeRequest`；`ConflictDecision` 复用 `schemas/projects.py`（conflict_index/verdict/note）。
- **零迁移**（§7）：复用 `jobs`（kind/status/progress/result 全已存）；新增 `status` 值 `"awaiting_input"`（jobs.status 自由 Text 列，无 DDL）；awaiting 章经 `result.awaiting_chapter` 表达。`JobRepo` 加 `set_awaiting(job_id, result)`。**唯一新增 DDL = langgraph 检查点表（C3 迁移建，本任务未引入）。**
- **新增错误码**（`ww_shared/errors.py`）：`ErrorCode.CONFLICT`→409（资源状态冲突，如对非 awaiting 链 job 续跑；与 `CONFLICT_UNRESOLVED` 区分）。
- **新网关缝**（`project_deps.py`）：`get_chain_gateway`（`build_chain_gateway`：按请求 tier writer/analyst/light 分派、union 三档适配器+回退链——单档网关 chain_resolver 恒返该档会错路由 review/digest，故链需多档分派）；`get_digest_gateway_builder`（accept 节点自建短事务建 light 档 digest 网关）。`get_checkpointer_factory`（`services/chain_deps.py`：运行时 `AsyncPostgresSaver.from_conn_string(database_url_sync)` 上下文；测试注 MemorySaver 工厂）。
- **token 纪律**：job result/status/日志只记章号/计数/标志，绝不含 prompt/正文/token（§5，已断言）。
- → 影响 @frontend（链发起页/进度/裁决续跑面板，**非目标本期不做**，契约稳定后 follow-up `pnpm gen:api`）；@db/@devops C3（检查点建表迁移 + 可选 jobs 注释）。

## C6-ext · Prompt 外置 + SpecResolver　owner @llm(SPECS/loader/catalog) + @backend(resolver/守卫)　状态: 稳定（方案A，2026-06-24）
> 设计真源：`docs/design/prompt-management.md`。纯重构、零功能/schema 变更、零迁移（前端无需 `pnpm gen:api`）。
- **`load_prompt(name) -> str`**（`packages/agents/ww_agents/prompt_loader.py`，@llm）：按 `prompts/<spec.name>.md` import 期读盘 → 去 BOM(`utf-8-sig`)/CRLF·CR→LF/NFC/`rstrip('\n')` → 内存缓存 → 返回完整 UTF-8 文本；缺文件 `PromptNotFoundError`（fail-fast，不 fallback、不插值）。
- **`SPECS: Final[dict[name, AgentSpec]]`**（`specs.py`，@llm）：21 内置 spec 集中名册，`assert len(SPECS)==21`；`system_prompt=load_prompt(name)`、`output_schema=SCHEMA_CATALOG[name]`。**不变量**：`SPECS[name] is *_spec`（兼容期同一实例，旧 `from ww_agents import *_spec` 不破）；`prompts/<name>.md`/`SPECS[name]`/`SCHEMA_CATALOG[name]` 三集合按 name 恒等。
- **`SCHEMA_CATALOG: Final[dict[name, type[BaseModel]|None]]`**（`schema_catalog.py`，@llm）：name→output type **唯一真相源**（`refiner=None`）；`output_schema_for(name)`。input 全 None，本波不建 input 槽（YAGNI）。
- **`REVIEW_RESERVED_NAMES: Final[frozenset]={continuity,foreshadow,style,pace}`**（@llm）：四审受信名显式白名单（非派生集合），安全边界锚此。
- **`SpecResolver`**（`packages/skills/ww_skills/spec_resolver.py`，@backend）：`build(skills)` 纯合并 `dict(SPECS)`+`SkillRegistry`（读路径无冲突校验）；`get(name)` 先查内置 `SPECS`（**纯内存、零 DB**）未命中才查 registry，都无→`NOT_FOUND`；`output_schema_for`/`names`/`list_scope`。name **精确字符串相等**（无大小写/连字符归一）。
- **守卫前移**：用户 skill 同名内置（`REVIEW_RESERVED_NAMES ∪ set(SPECS)`）在 **`SkillRegistry` 入库/加载校验期**即拒（`AppError(VALIDATION)`，与 `validate_declaration` 同处），**不在 resolver 读路径**——内置 `get` 确定性、零运行时分叉，守不变量 #3。
- **打包契约**（@devops）：`.gitattributes` 锁 `packages/agents/ww_agents/prompts/*.md text eol=lf`；`packages/agents/pyproject.toml` hatchling `artifacts` 纳入 `prompts/*.md` 随 wheel/sdist 分发；CI `agents-wheel-smoke`（build→裸装→`import ww_agents; assert ww_agents.SPECS`）防 `.md` 漏带（源码树 pytest 测不出）。`packages/{llm_gateway,core}` 补声明 `structlog`（原直接 import 未声明，靠 apps/api 传递）。
- 消费方：`toolbox_registry.GeneratorTool.spec` 走 `SPECS["<name>"]`（不再直接 import 生成器 `*_spec`）。**本波不改**编排器（`REVIEW_SPECS`/节点/§6.5 precheck/chain）与 apps/api 3 路由（`toolbox/outline/style`）——兼容期续用 `*_spec`（因同一实例不变量无回归），切 resolver 列后续波次。

## 契约变更日志（append-only）
> 格式：`- [date] @skill 改 Cx：<改了什么> → 影响 <依赖方/任务>`

- [2026-07-06] @llm/@db/@backend/@frontend 改 C3+C4（③ 人物塑造 advisory 审）：新增第五审 **人物塑造**（advisory 单维——仅建议、**不阻断验收**：不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**，D2）。**DB**：`chapter_reviews` 加 1 列 `characterization`（**JSONB nullable**，迁移 `f6a7b8c9d0e1`，独立于恒 None 的 health_score，nullable 免 backfill）。**契约变更**：`ReviewHistoryItem`（`schemas/projects.py`）+ `ReviewView`（`domain/review_repo.py`，末位默认 None 优雅降级）各加 `characterization: dict|None`；`list_reviews` 路由透传。新 SPEC `characterization_spec`（`tier="analyst"` 不写 model，`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点，`writes=()` 只读，`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度）+ schema `CharacterizationReview{issues:[CharacterizationIssue{character,aspect,where,quote,diagnosis,suggestion,confidence}]}`（全字段默认值守解析韧性）+ 注册 `SCHEMA_CATALOG["characterization"]`；**计数三处 +1（22→23）**：`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring；**regen `prompt_hashes.json`**（仅加 characterization 一条）。接线链：`graph.py REVIEW_SPECS` 末位追加（并行扇出自动汇入 collect）；`chain/nodes.py ReviewRecordRepo.record` + `collect.py {CHARACTERIZATION 常量, extract_characterization, ReviewRecorder Protocol, collect_reviews}` + `review_repo.py {Protocol.record, SqlReviewRepo.record, _to_view}` 均加 `characterization` kwarg/字段；`sse.py {EVENT_CHARACTERIZATION, characterization_event, _section_result_events elif}`（normalize 按 sorted 键自动纳入——字典序 characterization 排最前，不打乱既有四审断言）。前端 `lib/review/sse.ts`（`CharacterizationIssue`/`CharacterizationReport`/`CharacterizationEvent` + KNOWN_EVENTS + `asCharacterizationIssues` 守卫 + reducer + state）+ `lib/review/history.ts normalizeCharacterization` + `useReviewStream ReviewSeed` + 新 `CharacterizationPanel.tsx`（advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算）+ `ReviewReport.tsx` 挂面板 + 双 seed 点。**codegen**：`characterization` `= None` → openapi-typescript 渲染为可选。**测试**：`test_characterization_does_not_block_accept`（零 continuity 冲突 + 有 advisory 问题 → 验收直通）+ 计数/golden + collect 映射 + normalize 事件 + 前端 reducer/normalize/parse 纯函数。→ 影响 @frontend（已 `pnpm gen:api`）。依赖 ⑧（motive/appearance 已入 CharacterCard 契约，作客观锚点）。

- [2026-07-06] @llm/@backend/@frontend 立 C3 新端点（⑤ AI 立项方案生成）：**`POST /skills/project-plan/generate`**（**不带 project 前缀**——立项前 project 未建，绕开 toolbox 的 `project_repo.get→404`）← `ProjectPlanGenerateRequest{genre?,logline?,title?,premise?,theme?,structure?,tone?,ending_type?,narrative_pov?,selling_points[],brief?}`（全字段 `max_length` 上界 CR-H9；`brief` 用 `str|None` 令 codegen 渲染为可选）→ `ProjectPlanView{title_candidates[str], setting, narrative_structure, story_core, ending_design, tone}`（全字段默认值守解析韧性）。**种子门控**：缺 genre 或 logline（strip 后空）→ **422 VALIDATION**（空向导不出方案，防 slop）；无凭据→503。新 SPEC `project_plan_spec`（`tier="analyst"` 不写 model，`reads=()`/`writes=()` 纯预览，`prompts/project-plan.md`）+ schema `ProjectPlanResult`（`ww_agents.schemas`）+ 注册 `SCHEMA_CATALOG["project-plan"]`；**计数三处 +1（21→22）**：`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring；**regen `prompt_hashes.json`**（仅加 project-plan 一条）。端点复用 `build_brief_context`+`run_generator`（**`run_generator`/`_build_request` 的 `project_id` 放宽为 `uuid.UUID|None`**——向导阶段无 project，`usage_ledger.project_id` 本就 nullable）；新网关缝 `get_project_plan_gateway`（analyst 档，`project_deps.py`）。**零迁移**（纯预览不写业务表，仅落 usage ledger）。前端 `wizard.ts` 加 `mapPlanResultToWizardForm`（书名候选→title、叙事结构→structure、基调→tone；时空背景/结局设计无独立向导字段→折进 premise 带标签**不静默丢**；结局取向/叙事视角预设枚举不臆造）+ `applyPlanPatch`（**仅回填空字段、保护已编辑**）+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook（renderHook 测试）+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`（生成→预览→按字段回填）。→ 影响 @frontend（已 `pnpm gen:api`）。依赖 ④（tone/ending_type/narrative_pov 已入 project 契约与向导）。

- [2026-07-06] @db/@backend/@frontend 改 C2+C3（④ 立项基调/结局/视角）：`projects` 表加 3 列 `tone`/`ending_type`/`narrative_pov`（**Text nullable、无 CHECK**，同 genre/structure；迁移 `e5f6a7b8c9d0`，nullable 免 backfill）。`ProjectCreateRequest`+`ProjectResponse`（`schemas/projects.py`）各加 3 字段 `str|None`（**无 Literal**，Literal 只留 Verdict）；domain `ProjectCreate`/`ProjectView`/`_to_view`/`SqlProjectRepo.create` + `FakeProjectRepo` 同步透传。完整 stable_core 链路：`ProjectSpecView`（`repositories.py`，frozen）加 3 字段 + `SqlProjectSpecRepo.spec` 查询构造带上 + `_build_spec_section`（`assemble.py`）渲染进「作品蓝本」块（**书级常量→缓存前缀，字节稳定守 #9**，绝不入 volatile）。前端 `wizard.ts` `WizardForm`/`emptyWizardForm`/`toCreateRequest` + 预设数组 `TONES`/`ENDING_TYPES`/`NARRATIVE_POVS`（仿 GENRES/STRUCTURES）+ `ProjectWizard.tsx` 第 3 步控件（tone→Select，pov/ending→SegmentedControl）+ 确认页回显。**codegen**：3 字段皆 `= None` 无默认工厂 → openapi-typescript 渲染为 `?: string | null` 可选。→ 影响 @frontend（已 `pnpm gen:api`）；未来 ⑤ AI 立项方案回填这 3 字段。

- [2026-07-06] @llm/@backend 改 C3（⑧ appearance/motive 穿线）：`CharacterCardView`（`schemas/generation.py`）+ `ww_agents.CharacterCard` 各加 `appearance:str=""` + `motive:str=""`（**均给默认值守解析韧性**，DB 列 `characters.appearance/motive` 早已在初始迁移、此前一直 NULL——**无新迁移**）。写侧 `CharacterWriteRepo.create`/`SqlCharacterWriteRepo`（create+**update backfill 分支**）/`CharacterWriteFields` 同步加两列；`routers/generation.py` `_card_to_view`/`_view_to_card`/`_existing_characters` 双向形变带上两字段。同批**重切 motive/traits/arc 口径**：动机唯一落 `motive`，`traits[]` 只留核心/表层/阴影三层，`arc` 引用 motive（改 `character-gen.md` → 已 regen `prompt_hashes.json` 金标准）。**codegen 注意**：因带 `default`，openapi-typescript 把两字段渲染为**必填**（响应恒有值），前端 `CharacterCardView` 字面量构造点须显式给两字段。→ 影响 @frontend（已 `pnpm gen:api` + `CharacterCardItem` 展示/编辑两字段）；未来 ③ 人物塑造审查以 motive/appearance 为客观锚点。

- [2026-07-06] @backend/@llm/@frontend 改 C3（⑦ 群像按定位配比·缩减版）：`CharacterGenerateRequest`（`schemas/generation.py`）加 `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 在场时校验（非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12）并把 `count` 归一为各值之和（构造期归一化，忽略客户端 count）；缺省退回单一 `role`+`count`。**归属纠偏**：`build_character_gen_context`+`run_character_gen`（`ww_core/orchestrator/generation_node.py`）=**@llm 域**，加末位默认 `role_mix=None` 关键字参，在场时注入「角色定位配比」块（保插入序、确定性、无时间戳，守 #6/#7；覆盖单一 role 行）；`routers/generation.py` 穿 `role_mix=body.role_mix`。`character-gen.md` 加「按配比分配 role」纪律 → 已 regen `prompt_hashes.json`（仅 character-gen 哈希变，**不进 SPECS/不 bump 计数**）。前端 `cards.ts` 加 `sanitizeRoleMix`/`roleMixTotal` + `buildCharacterGenerateRequest` 第 4 参 roleMix（在场时 count=Σ、发 role_mix、丢 role）；`useCharacterGen` `CharacterGenInput.roleMix` 穿参；新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**codegen**：`role_mix` 渲染 `?: {[k:string]:number} | null` 可选。→ 影响 @frontend（已 `pnpm gen:api`）。**无迁移**。

- [2026-06-24] @llm/@backend 立 C6-ext（Prompt 外置方案A）：21 prompt 散文外置 `prompts/<name>.md` + `load_prompt`/`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`（@llm）+ `SpecResolver` + SkillRegistry 入库守卫前移 + toolbox 桥接 `SPECS`（@backend）+ `.gitattributes`/wheel package-data/CI 冒烟（@devops）。随后全量重写 21 prompt 内容（任务对齐，schema 契约/不变量保持，金标准 fixture 重生成）。门禁绿：ruff/format/mypy(209)/pytest(744)。→ 影响：后续波次可将编排器 + apps/api 路由切 `SpecResolver` 并删 `*_spec` 导出；前端零影响（GeneratorTool 描述符字段不变）。

- [2026-06-23] @backend 立 C-Chain（C2）：3 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用（加 `status="awaiting_input"` + `JobRepo.set_awaiting`）+ 新 `ErrorCode.CONFLICT`(409) + `project_deps` 加 `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`。OpenAPI 含 2 新 POST（GET /jobs 复用）。门禁绿：ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontend（follow-up `pnpm gen:api`）、@db/@devops C3（检查点迁移）。

- [2026-06-20] @backend 扩 C3：新增 **`GET /projects/{project_id}/chapters/{chapter_no}/injection`**（本章注入透明，B0 读端点）→ `InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}`（`schemas/injection.py`，snake_case）。实现仅调既有 `assemble()` 回放确定性 `SelectionTrace`——**无 LLM、无 commit、无 DDL**；项目不存在→404，无大纲→`selected:[]`。reasons 取值同 `SelectionReason`（explicit_beat/main_character/recent_digest/foreshadow_window）。→ 影响 @frontend（已 `pnpm gen:api` + `ChapterAssistant` 消费）。**B0 可控版（PUT override + `select_relevant_entities` 加 pinned/excluded/recent_n + draft 端点同读 override + 持久化）尚未实现，届时再扩本契约。**

- [2026-06-20] @backend 再扩 C3（B0 **可控版**，接上条）：新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** ← `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` → 返回 `InjectionResponse`（同上，**新增回显字段 `pinned`/`excluded`**，`selected[].reasons` 可含新值 **`author_pin`**）。语义：pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除（**优先于 pin**）/ recent_n 覆盖近况回看章数。GET injection 与 **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`，故「看到的=写章用的」（不变量 #6 作者兜底）。持久化：**新表 `chapter_injection`**（迁移 `ad2c4c663daf`，唯一 `(project_id,chapter_no)`，复用 outline 行已否决）；`select_relevant_entities` 加 `pinned/excluded` frozenset 入参、`assemble` 加 `override` 关键字参；新 `domain/injection_repo.py`（`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`，upsert 只 flush 端点 commit）。→ 影响 @frontend（已 `pnpm gen:api`；F1 加 pin/排除控件 + recent_n 步进器）。

- [2026-06-18] @llm 改 C1：`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` 在 schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed`；无 schema 时 `parsed is None`、纯文本路径不变；记账仍 **1 条 usage_ledger**/调用（usage 从 raw completion 提取）。`ProviderResult` 新增 `parsed` 字段。结构化路径经可注入 `StructuredClient` Protocol 注 fake（测试不联网）。→ 影响 T2.2（续审节点可直接 `gateway.run(req).parsed`）、未来所有结构化输出 Agent。

- [2026-07-06] @frontend 前端依赖新增（灵感⑥ 关系图谱缩减版 / D4，**无后端契约变更、无 gen:api、无迁移**）：`apps/web/package.json` 加 `@xyflow/react@^12.11.2`（React Flow，MIT，peer react>=17 兼容 R19）+ `@dagrejs/dagre@^3.0.0`（一次性初始布局，自带 TS 类型）。`pnpm install` 后 `pnpm-workspace.yaml` 被 pnpm 自动追加 `minimumReleaseAgeExclude`（@xyflow/react/@xyflow/system 供应链豁免）+ `pnpm-lock.yaml` 更新。新增 `lib/characters/graphLayout.ts`（纯逻辑：角色+relations→节点/边+dagre 布局，仅用现有 `CharacterCardView.relations={name,kind,note}`——**边按 name-join、kind 作中文标签、节点按 role 上色，无 closeness/阵营**，悬空边跳过并计数）+ `components/characters/RelationshipGraph.tsx`（`'use client'`，经 CodexPage `dynamic(ssr:false)` 加载，显式容器高度 + import RF CSS + prefers-reduced-motion 关 fitView 动画 + 纸感配色）。消费方：无（叶子特性，挂在设定库人物 tab）。

- [2026-07-07] @backend/@llm/@frontend 立 C-Rewrite（WFW-8 整章再沟通/重写）：新增 **`POST /projects/{project_id}/chapters/{chapter_no}/rewrite`**（SSE，text/event-stream，复用 token/done/error 帧契约）← `RewriteStreamRequest{feedback:str(1..4000), prior_draft:str(1..200000)}` → 流式重写整章一版。实现：`prompts/rewrite.md` 教条（非 SPEC，仿 write_craft 经 `load_prompt` 读盘，`test_prompt_loader` doctrine 排除集加 `rewrite`）+ `orchestrator/rewrite_node.py`（`build_rewrite_request` 纯函数 system=[rewrite教条,stable_core] cache 前缀 / input=近况+当前草稿+作者意见 断点后，守不变量 #9；`stream_chapter_rewrite`）+ 端点复用 `assemble()` 记忆注入 + `normalize_deltas`。**只读不写库**（HITL：新版停前端，接受才落，不变量 #3）、**工具非写节点**（不破坏 章=f(outline,state) 纯函数，不变量 #7）、项目不存在触网关前 404。**无 DDL/无迁移**。门禁绿：ruff/mypy(224)/pytest(+test_rewrite_node 5 例)/alembic 无漂移。→ 影响 @frontend（已 `pnpm gen:api`；`useChapterRewrite` SSE hook + `ChapterRewritePanel` 版本栈 + Workbench「整章重写」入口）。

- [2026-07-08] @backend 改 C3（CR-H9 输入大小上界 + 请求体守卫）：给用户请求字符串字段补 `max_length`（`Field(...,max_length=)` / `StringConstraints(max_length=)`，保留既有 `min_length`，命名常量无魔数）——`providers.py`{provider≤64,model≤128,api_key≤512}、`generation.py`{brief≤10_000}、`projects.py`{title≤200,final_text/draft/text≤200_000}、`style.py`{segment≤20_000,samples 列表≤20 且每条≤20_000}、`foreshadow.py`{code≤100,title≤500}、`rules.py`{content≤10_000}、`templates.py`{`NonBlankStr`→`NonBlankTitle`(≤200)+`NonBlankBody`(≤20_000)}；`ProjectPlanGenerateRequest`/`RewriteStreamRequest`/`RefineClarifyRequest` 早前已有上界，未动。**新增错误码** `ErrorCode.PAYLOAD_TOO_LARGE`(413)（`ww_shared.errors`，`ErrorBody.code` 为裸 str → **OpenAPI schema 不变**）+ 应用级 `body_size_limit_middleware`（`apps/api/ww_api/middleware.py`，`MAX_BODY_BYTES=2 MiB`，按 `Content-Length` 拦超大 body 直接自建 413 信封——用户中间件跑在 AppError 处理器外；chunked 无 Content-Length 为原型可接受限制）+ `main.py` 在 `request_id_middleware` 后注册。**无 DDL/无迁移**。超界 → 422（字段 ValidationError）/ 413（整体 body）。→ 影响 @frontend：请求字段现有 `maxLength`，须 **`pnpm gen:api`** 重生成 TS 客户端（**本 cluster 不代跑**）。

- [2026-07-08] @backend/@llm/@frontend 立 C-Clarify（WFW-9 M1 refine 侧 AI 反问澄清，路线A 两阶段）：新增 **`POST /projects/{project_id}/chapters/{chapter_no}/refine/clarify`**（**非流式** JSON 预检）← `RefineClarifyRequest{segment:str(1..20000), instruction:str(默认"",0..2000)}` → **`ClarifyDecision`**{need_clarification:bool, questions:[ClarifyQuestion{question, options:[ClarifyOption{label,value}](0..4), allow_free_text:bool}](0或1,v1硬上限1问), verification:str|null}。tier=**analyst**，结构化输出(instructor)，**只读不写库**(末尾 commit 仅记 usage_ledger，不变量 #3)；判别/校验失败**确定性回退 need_clarification=false**(放行)。`ClarifyDecision` 定义在 `ww_agents`(供 producer+端点共用)，注册 `clarify_refine_spec`(SPECS #24)+SCHEMA_CATALOG+金标准。**既有 `POST .../refine`(RefineRequest/RefineResponse)完全未改**。→ 影响 @frontend(已 gen:api；`useClarify` 映射 snake→VM + `ChoiceChips` 选项芯片 + `RefinePanel` 门控预检：意见<10字或点按钮→预检→needClarification 则渲染选项→答案 foldClarifications 折进 instruction→走既有 refine)。**无 DDL/无迁移**。门禁绿：后端 ruff/mypy227/pytest900/alembic；前端 tsc/lint/vitest654/build/cov95.36%。M2(rewrite 侧两阶段)/M3(UX+E2E) 待办。

- [2026-07-08] @llm/@backend 扩 C-Clarify（WFW-9 M2 **rewrite 侧** AI 反问澄清，镜像 M1）：新增 **`POST /projects/{project_id}/chapters/{chapter_no}/rewrite/clarify`**（**非流式** JSON 预检）← `RewriteClarifyRequest{feedback:str(1..4000), prior_draft:str|null(0..200000)}` → **`ClarifyDecision`**（复用 M1 同一 schema：{need_clarification, questions[0或1,硬上限1问], verification?}）。tier=**analyst**，结构化输出，**只读不写业务表**(末尾 commit 仅记 usage_ledger，不变量 #3)；判别/校验失败**确定性回退 need_clarification=false**(放行)。端点只把 `prior_draft` **开头 3000 字摘录**喂预检（不整章入 LLM），日志只记 `feedback_len`(脱敏)。注册 `clarify_rewrite_spec`(analyst,reads=()/writes=(),SPECS **#25**)+SCHEMA_CATALOG(复用 ClarifyDecision)+教条 `prompts/clarify_rewrite.md`(segment→整章)+金标准(仅新增 1 条哈希，无漂移)。**既有 `POST .../rewrite`(RewriteStreamRequest, SSE)完全未改**。→ 影响 @frontend：**须 `pnpm gen:api`** 纳入新端点，再仿 M1 `RefinePanel` 在 `ChapterRewritePanel` 接门控预检（意见含糊/极短→预检→needClarification 则选项芯片→折答案进 feedback→走既有 rewrite SSE）。**无 DDL/无迁移**。门禁绿：后端 ruff/format 干净·mypy **229**·pytest **948**·alembic 无漂移。M2 前端 UI 接线 + M3(UX/E2E) 待办。
