# 实现决策记录（append-only）

> 实现期做出、而规格未覆盖的决策 + 理由。一条一决策，最新在最上。
> 与规格冲突的不要写这里——去改规格（见 CLAUDE.md「Conventions」）。重大架构选择见 `ARCHITECTURE.md §1.2 ADR`。

格式：
```
## [YYYY-MM-DD] <决策标题> — @skill
- 背景：为什么要决策
- 选择：选了什么
- 理由 / 取舍：
- 影响：动到哪些契约/模块/任务
```

---

## [2026-06-22] 限流（rate limiting）原型期**延后**，记触发条件 — @backend (P2 code-review)
- 背景：代码审查指出触 LLM 的端点（draft/review/accept/outline/style/refine 等）+ `/oauth/start` + `/settings/providers/test` 缺速率限制；审查约定「若接受延后，记入 memory/decisions.md」。
- 选择：本期**不加**任何限流代码/依赖（不引 `slowapi` 等）。
- 理由 / 取舍：原型显式为**单用户**（CLAUDE.md：auth/多租户化已 deferred，`users`/`owner_id` 是 stub），无公网暴露、无多调用方滥用面；KISS/YAGNI——为零真实风险的场景引入限流中间件 + 新依赖属过早工程。代价：若误暴露到公网，LLM 端点可被滥用刷成本（已记触发条件兜底）。
- 触发（revisit when）：**引入多租户/鉴权** 或 **应用对外公开暴露** 时，须补轻量限流——按 IP/token 对 LLM 端点 + `/oauth/start` + `/settings/providers/test` 加每窗口配额（届时再评估 `slowapi` 或反代层限流）。
- 影响：无契约/代码改动；仅本条记录决策与重启条件。

## [2026-06-19] Kimi Code OAuth：token 存储包 + 刷新启发式 + 省略 scope + 后台轮询走 jobs — @backend (K1.3)
- 背景：Kimi 订阅 plan 走 device-flow OAuth；需定 token 持久化形、何时刷新、device authorization 是否带 scope、后台轮询如何挂。
- 选择：
  1. **存储包**：`TokenSet{access_token, refresh_token, expires_at}` 序列化为 JSON 串经 Fernet（复用 `encrypt_api_key`，工作在 str 上）加密入 `provider_credentials.oauth_enc`（C2 扩 K1.1 列）。`expires_at` 存**服务端驱动的绝对 UTC 时刻**（从 `expires_in` 计算入库时刻）。明文 token 绝不进日志/响应/job 结果。
  2. **刷新启发式**：建网关时若 access token 剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS=300`（300s 缓冲，对 ~15min access 足够），调 `refresh` 换新包并 `upsert_oauth_credential` 持久化（下次复用）。任务文档原提 `max(300, 0.5*expires_in)`——本实现采固定 300s 缓冲（不存原始 expires_in，KISS；对 15min access 等价于 0.5*expires_in=450s 略激进但安全）。
  3. **省略 scope**：device authorization 不带 `scope`（匹配研究确认的 kimi-cli v1.41.0+；server 拒绝时再加 `scope=kimi-code` 兜底——本期先不带）。client_id env `KIMI_CLIENT_ID` 可覆盖默认。
  4. **后台轮询走 jobs**：`POST .../start` 创建 `jobs(kind="kimi_oauth")` 返 202 + user_code，复用 K1.1/M4 `run_job` 在独立 session 上**循环** `poll_token`（每 interval 秒，`authorization_pending` 续轮询、`slow_down` 增大 interval、过期/拒绝抛 AppError 置 job failed），成功落加密 token + job done（结果只 `{connected, provider}`）。前端轮询 `GET /jobs/{id}`。
- 理由 / 取舍：复用既有 jobs/run_job 基建（无新长任务机制）；固定 300s 缓冲省去存 expires_in（YAGNI）。代价：refresh-on-build 每次建网关多一次解密 + 可能一次刷新网络调用（仅临近过期时）。
- 影响：C3 扩（OAuth 3 端点）；C2 扩 K1.1 的 `StoredCredential` 收口（api_key_enc→nullable + auth_type/oauth_enc）；`_build_provider_adapter` 加 OAuth 分支。K1.4 前端连接流、K1.5 E2E（真账号联网验证含封号风险，用户自担）。

## [2026-06-19] Kimi Code OAuth：device authorization **重新带 `scope=kimi-code`**（实测 401 后修正，超越上条第 3 点）— @backend (K1.3 fix)
- 背景：K1.5 真账号联网实测——device-flow 登录成功拿到真 JWT，但调 `api.kimi.com/coding/v1` 被拒 `401 Invalid Authentication`。根因：device_authorization 省略了 `scope`，拿到的 token 缺 coding entitlement。
- 选择：`start_device_authorization` 的请求体在 `client_id` 之外加 `scope=kimi-code`（模块常量 `KIMI_CODE_SCOPE`）。**修正上条决策第 3 点的「省略 scope」**——`scope=kimi-code` 是 coding-agent 文档化 OAuth scope（`ooojustin/opencode-kimi` `constants.ts` 发送之；kimi-cli v1.41.0 已不发但服务端仍接受）。
- 范围：scope **只**放 device_authorization 请求；token 交换（`poll_token`）/刷新（`refresh`）**不带** scope（OAuth device flow 惯例，参考实现亦然）。
- 影响：device-auth 请求体现为 `{"client_id": <id>, "scope": "kimi-code"}`；K1.3 单测 `test_start_device_authorization_parses_response_and_sends_scope` 断 `data["scope"]=="kimi-code"`。无 OpenAPI 形变，前端无需 re-gen。

## [2026-06-19] Skill registry + 表权限沙箱迁回 `ww_skills` 包（@devops 已纳入 workspace）— @backend (M5 R3)
- 背景：T5.5 曾因 `packages/skills` 非 uv workspace member 把 `skill_registry`/`skill_permissions` 暂落 `ww_core.domain`、`ww_skills/__init__` 做再导出门面（见上 T5.5 决策）。@devops 现已把 `packages/skills` 做成真 workspace member（`ww_skills` 可 import、有自己的 pyproject、`uv sync` 完成）——前提消除。
- 选择：把两个模块**物理迁入** `packages/skills/ww_skills/{skill_registry,skill_permissions}.py`（内部相对 import 从 `ww_core.domain.skill_permissions` 改为 `ww_skills.skill_permissions`），`ww_skills/__init__` 改为**直接导出**（不再 re-export ww_core）；删除 `ww_core.domain` 的两个模块文件 + `__init__` 里的 skill 导出。所有 importer（`apps/api` `generation.py`/`project_deps.py` + `test_generation.py`）改 `from ww_skills import …`；skill 单测从 `packages/core/tests/` 移到 `packages/skills/tests/`（import 也改 `ww_skills`）。
- 理由 / 取舍：恢复任务文档原定位（skill 逻辑归 skills 包），物理位置与 §5.6 措辞一致；ww_core 不再承载 skill 符号（关注点分离）。代价：`packages/skills/pyproject.toml` deps 需校准——`ww_skills` 直接 import `ww_db`/`ww_agents`/`ww_shared`/`ww_llm_gateway`(`Tier`)，故去掉不再用的 `ww-core`、补 `ww-llm-gateway` + `sqlalchemy`（`SqlSkillRepo` 用 `select`/`AsyncSession`）；改后 `uv sync` 重建 ww-skills。
- 影响：C8 实现位置更新（contracts.md C8 状态加「M5 R3 迁入 ww_skills 包」）；消费方 import 路径从 `ww_core.domain`/`ww_core.domain.skill_registry` → `ww_skills`。`ww_core.domain.skill_*` 导入路径**已失效**（凡 grep 到旧路径须改）。`packages/skills/pyproject.toml` 改了 deps（在 @backend 可编辑的 skills 目录内，但属打包元数据）——已 PROGRESS 通知 @devops 复核根锁。

## [2026-06-19] `build_gateway_for_tier` 改用 `build_adapter` 工厂（per-provider 适配器）— @backend (M5 R1 #2)
- 背景：T5.4 接线只为回退链里的每个 provider 一律建 `OpenAICompatAdapter`——Anthropic/Gemini 即便配进 `tier_routing` 链也因 `_PROVIDER_BASE_URLS` 无 base_url 被跳过、拿不到真实适配器。@llm 加了 `build_adapter(provider, *, api_key, base_url=None)` 工厂（C1 扩 follow-up #2）按 provider 选适配器类。
- 选择：`_build_openai_compat_adapter` 改名 `_build_provider_adapter`，去掉「base_url 缺失即返 None」分支，改调 `build_adapter(provider, api_key=…, base_url=_PROVIDER_BASE_URLS.get(provider))`——OpenAI 兼容 provider 传 base_url，Anthropic/Gemini 传 None 走原生适配器。仅在凭据缺失时返 None（回退链跳过）。保留既有 503/凭据/单 provider 兼容行为。
- 理由 / 取舍：最小改动让回退链支持非 OpenAI-兼容 provider；adapter 选择逻辑收敛到网关包的工厂（单一真源），apps/api 不再硬编码适配器类。代价：真实 Anthropic/Gemini 仍需 @devops 加 SDK 依赖（适配器懒导入）。
- 影响：C3 扩（build_gateway_for_tier 接线段更新）；`apps/api` 去掉 `AsyncOpenAI`/`OpenAICompatAdapter` 直接 import。T5.7 切 provider E2E 可扩 Anthropic/Gemini 路径。

## [2026-06-19] 设定库 Codex 读端点复用 C5 读侧 + 反向 JSONB 解包 — @backend (M5 R1)
- 背景：PROGRESS 余项①——Codex 设定库本应展示 characters/world_entities **读/管理**视图，但后端只有生成/入库写端点，前端只能「生成+本次入库会话内回显」。
- 选择：加 `GET /projects/:id/characters` + `GET /projects/:id/world_entities`，**复用 C5 读侧** `SqlCharacterRepo`/`SqlWorldEntityRepo`（经既有 `get_memory_repos` dep，不新增 repo、不动 assemble）；响应做 T5.2 ingest 形变的**逆向**解包（`{"items":[...]}`/`{"text":...}`/`{"rules":[...]}`→裸 list/str），复用既有 `_existing_characters` helper。无行返空列表（非 404，列表语义）。
- 理由 / 取舍：KISS——读端点是写入形变的镜像，复用已有读侧 + helper 零新依赖；GET 与 POST 同路径靠 method 区分无冲突。代价：读侧 CharacterView 仍是裸 dict，端点层做解包（与写侧 repo 的包裹对称）。
- 影响：C3 扩（两读端点）；@frontend `pnpm gen:api` + CodexPage 初始列表拉全量（去掉「会话内」局限，解 gotcha 2026-06-19 @frontend Codex 缺口条）。

## [2026-06-19] 角色入库 continuity gate：有冲突→409 CONFLICT_UNRESOLVED，作者 acknowledge 后放行 — @backend (T5.2)
- 背景：ARCH §6.5 要求生成角色入库前过 continuity 校验（`precheck_generated_cards`），但任务文档留了两条路线（block / 返回冲突供作者裁决）。要选最简单正确、且守不变量 #3「无 AI 静默写库」。
- 选择：入库端点跑 precheck；检出冲突且请求未带 `acknowledge_conflicts=true` → **409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[...], conflict_count}`（不写库）。作者在前端查看冲突、裁决后重发带 `acknowledge_conflicts=true` → 放行写库。零冲突 → 直接写库 201。
- 理由 / 取舍：复用既有 `CONFLICT_UNRESOLVED`(409) 码（语义=未决冲突禁写入，与 accept gate 一致），无需新增错误码；显式 `acknowledge` 标志=作者裁决的 HITL gate（不静默入库，守不变量 #3），比「自动返回冲突 + 另开裁决端点」更省（无新端点、无新状态表，原型 KISS）。代价：作者确认 = 重发一次请求（带 flag），可接受。
- 影响：C3 扩（POST /characters）；`partition_writes` 在 gate 后再过写白名单（character-gen 只声明 writes=characters）。T5.6 前端入库流：预览→点入库→若 409 展示冲突→裁决勾选→带 acknowledge 重发。T5.7 E2E 覆盖两条路径。

## [2026-06-19] 角色卡 schema↔DB 形变落写侧 repo（list→{"items"}、arc str→{"text"}、rules→{"rules"}）— @backend (T5.2)
- 背景：T5.1 gotcha 指出 `CharacterCard.traits`/`speech_tics`(list)/`arc`(str) 与 DB `characters` JSONB **dict** 列不同形；`WorldEntityCard.rules`(list) 与 `world_entities.rules` JSONB dict 列不同形。需定一个固定包裹形。
- 选择：写侧 repo 做转换层——`traits`/`speech_tics` 包成 `{"items":[...]}`、`arc` 包成 `{"text":...}`、`rules` 包成 `{"rules":[...]}`；`tags`/`relations` 是 JSONB list 直落。读回（喂防雷同/precheck）反向解包同形（`_existing_characters`/`_world_context`）。
- 理由 / 取舍：固定 wrapper key 让 round-trip 确定（写=读互逆），不改 @db 列类型（@db owned）。代价：assemble/C5 读侧 `CharacterView.traits` 仍是裸 dict（本期 assemble 不消费 items 内层，无影响）；若后续 assemble 要渲染 traits 文本，按同 wrapper 解包即可。
- 影响：C3 扩（写侧 repo 形变契约）；ww_core.domain 新增 `character_repo`/`world_entity_repo`。@db 若日后规整列类型为 list，可去 wrapper（迁移由届时 owner 执行）。

## [2026-06-19] 顺带补 `GET /projects/:id/style` 读指纹 + 写侧 repo 独立读视图 — @backend (T4.3)
- 背景：UX §6.9 要展示完整 16 维指纹 + 证据，但 ARCH §7.2 端点清单**无**读指纹端点（只 `POST /style`）。C5 读侧 `SqlStyleRepo.latest` 已存在，但其 `StyleView` **只含 `dimensions`**（assemble 只需维度文本，不需证据/版本），扩它会牵动 C5 assemble 契约。
- 选择：(1) **加 `GET /projects/:id/style`** 返最新指纹 `{dimensions, evidence, version}`（无指纹→404 `NOT_FOUND`）。(2) 不动 C5 读侧——在写侧 `SqlStyleFingerprintWriteRepo` 上加一个 `latest(project_id)->StyleFingerprintView`（含 evidence_json + version 的更全视图），`GET /style` 用它；C5 `SqlStyleRepo.latest`/`StyleView`（只 dimensions，供 assemble stable_core）保持不变。
- 理由 / 取舍：读写分离 + 各自最小视图（同 `DigestAppendRepo` vs 读侧 `DigestRepo` 先例），不破坏 C5 assemble；前端无须靠 job `result` 拼凑摘要、可直接拉完整指纹。
- 影响：C3 扩（新增 `GET /style`）；**待回写规格**：ARCH §7.2 端点清单 + PRODUCT_SPEC §6 应补 `GET /projects/:id/style`（spec 一致性纪律，@docs 回写）。T4.4 前端 FingerprintView 消费此端点。

## [2026-06-19] 学文风 `work` 自建网关+repo（不走 FastAPI dep），凭据探测前移到请求阶段 — @backend (T4.3)
- 背景：学文风走 jobs（M4-c）：`POST /style` 立即返 202，提取经 BackgroundTask `run_job` 在**独立 session** 上跑（请求 session 已关闭）。但「无凭据→503」需在请求阶段就让用户知道（不能 202 后再静默 fail job）。
- 选择：(1) `work(session)` 闭包内**自建** analyst 网关（`build_gateway_for_tier(session, SqlCredentialStore(session), "analyst")`）+ 写侧 repo（用 `run_job` 传入的独立 session），不依赖 FastAPI dep（dep 绑的是请求 session）。(2) 端点仍声明 `get_style_extract_gateway`(analyst) 依赖**仅作凭据探测**——无凭据时 dep 解析阶段抛 503，在 `job_repo.create` 之前拦下；其返回的网关本身不被复用（请求 session 即将关闭）。
- 理由 / 取舍：兼顾「BackgroundTask 必须自建 session」（gotcha）与「无凭据请求阶段即 503」（友好、不写注定失败的 job）。代价：凭据被探测两次（请求阶段 + 后台 work），原型可接受。
- 影响：C3 扩（POST /style）；测试后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo`（见 gotcha）。

## [2026-06-18] 网关结构化输出经可注入 `StructuredClient` 缝（instructor）— @llm
- 背景：`OpenAICompatAdapter` 需接 instructor 产结构化输出（C1 类型预留 `output_schema`/`parsed` 但 M1 未接线），但测试绝不可联网/碰真实 LLM。
- 选择：定义 `StructuredClient` Protocol（`create_with_completion(*, messages, response_model, **kw) -> (parsed, raw)`，即 `instructor.AsyncInstructor` 的形）；adapter 构造可选注入 `structured_client`，未注入时懒构建 `instructor.from_openai(self._client)`。`run()` 透传 `result.parsed → LlmResponse.parsed`。
- 理由 / 取舍：保留既有「注入 client 便于测试」风格，结构化路径同样可注 fake。`ProviderResult.text` 对结构化路径置为 `parsed.model_dump_json()`（便于日志/留痕），程序消费走 `parsed`。
- 影响：契约 C1（`run()` 填 `parsed`；见 contracts 变更日志）、`ProviderResult` 加 `parsed` 字段；T2.2 续审节点据此消费。

## [2026-06-18] 并行审记账并发安全选 add-only（去网关 ledger flush）— @llm (T3.8)
- 背景：三审同 superstep 并行共用请求 session，`SqlAlchemyLedgerSink.record` 的 `await flush()` 是唯一让步点→第二/三审 flush 重入「Session is already flushing」→被 `run_review` 吞成 incomplete、foreshadow/pace 静默丢失（T3.7 暴露）。
- 选择：`record` 改 **add-only**（去 `await flush()`，只 `session.add(row)`）。`session.add()` 同步不让步→并行协程不交错；持久化仍靠端点/事务末尾 `commit()`（自动 flush 待决行）。
- 理由 / 取舍：最小改动、单 owner（只动 `ledger.py`），不牵动 apps/（@backend）。前提已校验：①并行审区间无 DB 查询触发 autoflush（review_context 取自 state、适配器不碰 session）；②draft/review/accept/outline 端点末尾均已 commit。缓冲/独立 session 方案跨 owner、更重，不取。
- 影响：C1 边界语义不变（commit 责任方未变）；凡新增「并行用网关产 usage」路径，sink 必须保持 add-only、不得在并行段 await flush。新增并发回归测试 `test_ledger_concurrency.py`。

## [2026-06-18] 节拍图用定高 CSS 柱 + 文本 sparkline，不引图表库 — @frontend (T3.6)
- 选择：`beat_map`(int 序列) 按本序列 min..max 线性归一到 7 档 ▁..▇（全相等→居中档，最低柱给 12% 可见高度），渲染定高 CSS 柱 + `aria-label="爽点节拍图 ▁▃▅…"`。静态无动画→天然满足 prefers-reduced-motion。
- 理由：KISS，避免图表库依赖。影响：T3.7 节拍图 `role="img"` selector。

## [2026-06-18] 大纲 beats 存储形 + 写侧 repo 命名 — @backend (T3.5)
- 背景：`outline.beats` DB 列是 JSONB dict，但 outliner 产 `list[str]`；读侧 `OutlineRepo`(C5/memory)已占名。
- 选择：写侧 `SqlOutlineWriteRepo` 把 list 包成 `{"beats":[...]}` 落库（读侧同形 round-trip 一致），API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`。写侧命名加 `Write` 前缀避撞 C5 读侧（同 DigestAppendRepo/ForeshadowLedgerRepo 先例）。`volume` 由端点提供（schema 无逐章卷号，M3 默认全卷 1）。`get_outline_gateway` 复用 `build_gateway_for_tier(...,"analyst")`、独立缝便于测试 override。
- 影响：C3 扩(outline 端点)；T3.6 渲染 beats 裸 list + 窗口徽标；T3.7 断言 outline 行 beats 列形。

## [2026-06-18] 验收后伏笔到期扫描挂 BackgroundTask + 重复code/非法转移映射 VALIDATION(422) — @backend (T3.2)
- 背景：§5.5 步骤3 `TODO(M3)` 要验收后置 OVERDUE；登记/状态端点需映射 DB 唯一冲突与非法状态机转移到错误信封。
- 选择：(1) accept 端点 commit 成功后经 FastAPI BackgroundTasks 登记 `run_overdue_scan`（自建独立 session，因请求 session 已关闭）；扫描抽纯 async 函数 + 可注入 `repo_factory`/`session_factory` 缝，单测直接 await 注 fake、不起后台线程。(2) 重复 code(IntegrityError)/`InvalidTransition` 都映射 `ErrorCode.VALIDATION`(422) 非 409——现有 409 码 `CONFLICT_UNRESOLVED` 语义=未决冲突，复用不符；未新增 shared 错误码。
- 影响：C3 扩(foreshadow 登记/状态端点)；§7.4 持久性局限(进程内/重启丢任务)原型接受、不引 jobs 表；T3.5 看板/outline 续接本 router；T3.7 真 pg 断言 OVERDUE。

## [2026-06-18] 伏笔写侧 Repository 加 `Ledger` 前缀避免与读侧同名 — @backend (T3.1)
- 背景：读侧 `ForeshadowRepo`/`ForeshadowView`/`SqlForeshadowRepo`（`domain/repositories.py`+`memory/`）已被 assemble(C5 稳定)占用且 View 最小（无 importance/links/progress）。M3 写侧账本需更全 View + 写方法。
- 选择：写侧命名 `ForeshadowLedgerRepo`/`SqlForeshadowLedgerRepo`/`ForeshadowLedgerView`，落 `domain/foreshadow_repo.py`；读侧不动。状态机纯函数落 `domain/foreshadow_state.py`（`ForeshadowStatus` StrEnum + `transition`/`is_overdue`/`apply_overdue_scan`/`InvalidTransition`）。
- 理由：同 T2.3 `DigestAppendRepo` vs 读侧 `DigestRepo` 先例，读写分离免歧义、不破坏 C5。写方法只 flush 不 commit（commit 归 T3.2 扫描任务 / T3.5 端点）。
- 影响：T3.2 验收后扫描 / T3.5 看板端点消费 `ForeshadowLedger*`。

## [2026-06-18] 大纲节点不接进 review 图、不做失败隔离 — @llm (T3.4)
- 背景：续审在 `run_review` 内把网关失败隔离为 `incomplete`（§5.2 任一审不阻塞其余）。
- 选择：大纲是**独立生成**（不在写章/审稿流水线），`run_outline` 裸函数、网关失败**直接上抛**由 T3.5 端点处理；带 `output_schema` 时 `parsed` 非 `OutlineResult` 抛 `ValueError`（C1 违约即显错，不返回空大纲）。
- 影响：C6 扩（outliner spec + OutlineResult + run_outline 缝）；T3.5 端点调 `run_outline` 持久化 `outline` 表。

## [2026-06-18] 审稿页终稿来源：页内可编辑 textarea（无 GET draft 端点）— @frontend (T2.6)
- 背景：审稿/验收都需「终稿」文本，但当前无 `GET .../draft` 端点拉取已存草稿。
- 选择：审稿页用可折叠 textarea 编辑终稿（`initialDraft=''`），重审传它为 `{draft}`（空→后端回退已存草稿），验收 `final_text`=它。
- 理由 / 取舍：对齐不变量#4（摘要从作者裁决/改稿后的终稿提炼）；作者可在审稿页改稿。代价：刷新页不自动回填草稿正文（M2 可接受，后续可加 GET draft）。
- 影响：仅前端；T2.7 E2E 用 `#final-text` 注入终稿。

## [2026-06-18] 验收事务边界：digest 提炼在事务外、单事务三写一次提交 — @backend (T2.4)
- 背景：§5.5 要求验收「单事务」含「从终稿提炼 digest（LLM 调用）」，但不能在持开 DB 事务里跨网络调 LLM（占锁）。
- 选择：digest 提炼 `extract_digest_facts`(tier=light，`ChapterDigestFacts` schema，经网关 `run().parsed`) 在 `run_accept_transaction` **之前**调用（R2），结果作 `digest_facts:dict` 传入；事务内只三次纯 DB 写（`promote_to_accepted`→`digest.append`→`set_decisions`）+ 末尾一次 `session.commit()`，任一步失败整体回滚。
- 理由 / 取舍：兼顾「digest 从终稿（不变量#4）」与「事务不跨网络」；提炼那次网关调用的 usage 随事务提交落 1 条 usage_ledger。digest 用 light、续审用 analyst 档位，统一经新 `build_gateway_for_tier(session, store, tier)`（`build_writer_gateway` 退化为 writer 特例）。§5.5 步骤 3（人物 latest_state/伏笔更新）留 `TODO(M3)` 占位，不引入 M3 表逻辑。
- 影响：契约 C3（accept/review/reviews 三端点）；T2.6 消费 `AcceptResponse`/`CONFLICT_UNRESOLVED`；T2.7 E2E 验证事务原子性+digest 来源+ledger。

## [2026-06-18] 验收-side 写侧 Repository 命名与提交边界 — @backend (T2.3)
- 背景：`chapter_digests` 已有读侧 repo（`memory/` 的 `DigestRepo.recent`，供 assemble）；M2 需写侧 append。同名易混。
- 选择：写侧命名 `DigestAppendRepo`/`SqlDigestAppendRepo`，落 `domain/digest_repo.py`，**共用 `DigestView`**；读侧仍在 `memory/`。新增 `review_repo.py`(`record`/`list_for_chapter` 新→旧/`set_decisions`) 与 `chapter_repo` 扩 `max_version`/`promote_to_accepted`/`latest_accepted`。
- 理由 / 取舍：读写分离避免同名歧义；写侧三 repo 的写方法**只 `flush()` 不 `commit()`**，提交归 T2.4 验收事务单次完成（对齐「写库副作用在编排/事务层」不变量）；`save_draft` 仍自 commit（M1 自动保存语义）。
- 影响：T2.4 在单事务里组合 `promote_to_accepted`→`digest.append`→`review.set_decisions` 后 `commit()`；R2/R3/R4/R5 决策落在这些接口点。
- 背景：ARCH §5.3 伪码里 `assemble` 直接返回 `LlmRequest`，会让 `packages/core/memory` 反向依赖 `packages/llm_gateway` 的类型，且使 M1 的 T1.2 强依赖 T1.1（无法并行）。
- 选择：`assemble(project_id, chapter_no) -> AssembledContext(stable_core: str, volatile: str, selection: SelectionTrace)`。稳定内核/易变各为**已确定性排序、无时间戳/UUID 的字符串**；write 节点(T1.3)再据此构造 `LlmRequest(system=[Block(text=stable_core, cache=True)], input=volatile)`。`SelectionTrace` 记录每个实体的入选理由，供 UX 注入透明面板(T1.6)。
- 理由 / 取舍：记忆服务只经 DB 通信、不应知道网关请求类型（对齐不变量①②）；解耦后 T1.1‖T1.2 可并行。§5.3 伪码视为示意，真正的缝是「确定性选择 → 序列化的 stable/volatile 文本」。
- 影响：契约 C5 输出形 = `AssembledContext`（非 `LlmRequest`）；C1 的 `Block`/`LlmRequest` 仅 gateway+orchestrator 使用。回写 ARCH §5.3 待 M1 落地后由 @docs 注一句。

## [2026-06-18] CLAUDE.md 不复制 spec 的枚举值，只重述跨文档规则 — @all
- 背景：CLAUDE.md 原先内联了 SSE 事件名、LLM 调用日志字段、错误码/envelope 形状等"会变的具体值"，ARCHITECTURE 一改即静默过期，使 CLAUDE.md 自身成为最大漂移源。
- 选择：CLAUDE.md 只保留跨文档、易错的**规则**（含 9 条架构不变量）；所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §（唯一真源）。新增"冲突裁决"段：ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC，矛盾时回写上游。
- 理由 / 取舍：消除文档间漂移面、降低每 session 上下文成本；代价是查具体值需多跳一层到 ARCHITECTURE（可接受）。
- 影响：编辑 CLAUDE.md 的任何 agent 遵循此纪律——契约/枚举值落在 spec 与 `memory/contracts.md`，不在 CLAUDE.md。

## [2026-06-19] T5.5 Skill registry + 表权限沙箱落 `ww_core.domain`，非 `packages/skills/ww_skills` — @backend
- 背景：T5.5 任务文档把 registry/沙箱定位在 `packages/skills/ww_skills/`，但该包当前**不是 uv workspace member**（根 `pyproject.toml` 的 `[tool.uv.workspace].members` 不含它、mypy_path/ruff src 也不含），无 pyproject → 仅靠 sys.path hack 才能 import，app/测试/mypy 都无法干净消费它。根 pyproject 由 @devops 持有，自己接线会越权 + 阻塞。
- 选择：把实现落在已是 workspace member、且承载「编排/写库 apply 层」的 `ww_core.domain`（`skill_registry.py` + `skill_permissions.py`，经 `ww_core.domain` 导出）。`ARCHITECTURE §5.6` 明确「强制点在编排/写库层」=ww_core，语义上也吻合。`packages/skills/ww_skills/__init__.py` 留**再导出门面**（import 自 ww_core），保留稳定「skills」导入名。
- 理由 / 取舍：非阻塞、门禁可干净覆盖（ruff/mypy/pytest 全跑）、不越权改根配置；代价是物理位置与任务文档措辞略偏，但功能/契约（C8）完整。待 @devops 把 `packages/skills` 纳入 workspace + mypy_path 后，可平滑迁移实现到 `ww_skills` 而不动调用方（只改门面方向）。
- 影响：消费方 import `ww_core.domain` 的 `SkillRegistry`/`filter_reads`/`partition_writes`/`validate_declaration`（或经 `ww_skills` 门面）。若 @devops 后续纳入 workspace，迁移由届时的 @backend/@llm 执行。

## [2026-06-23] @backend C2（多章链）服务/端点接线：零迁移表达 awaiting + 多档分派网关
- 决策 1（**零迁移表达 awaiting**）：链 interrupt 暂停态不新建 jobs 列/枚举——`jobs.status` 是自由 Text 列（无 DB CHECK），直接新增值 `"awaiting_input"` + `JobRepo.set_awaiting`；待裁决章号经 `result.awaiting_chapter` 表达。`jobs.kind="chain"` 同理零迁移。唯一新增 DDL（langgraph 检查点表）归 C3 迁移。理由：设计 §7 优先零迁移；result 已是 JSONB 足以承载非密进度。
- 决策 2（**多档分派网关 `build_chain_gateway`**）：一条链 run 跨 writer(write)/analyst(review)/light(digest) 三档，但 `build_gateway_for_tier(tier)` 装出的网关 `chain_resolver` 恒返该单档链、忽略 `req.tier`——会把 review/digest 错路由到 writer。故链需一个**按请求 tier 分派**的网关：union 三档所有 provider 适配器 + `chain_resolver` 据 `req.tier` 返回对应档链。digest 在 accept 节点自建短事务内按 session 现建 light 档网关（`get_digest_gateway_builder`）。
- 决策 3（**accept_op 在 apps/api 装配**）：链编排（图/节点）在 `ww_core`，但具体验收事务（`run_accept_transaction`/冲突 gate/digest 提炼/伏笔到期扫描）属 apps/api——经 `build_accept_op` 闭包注入图节点，守目录所有权 + 不变量 #3/#4。自动链无作者改稿，`final_text` = `chapters` 该章草稿正文（write 节点所落）。
- 决策 4（**新错误码 `CONFLICT`**）：resume 非 awaiting 态须 409，但既有 409 码 `CONFLICT_UNRESOLVED` 语义专指「未决冲突禁验收」，不宜复用。新增通用 `ErrorCode.CONFLICT`→409（资源状态冲突）。

## [2026-06-24] Prompt 外置方案A — @llm/@backend/@devops
- 背景：21 个内置 agent 的 `system_prompt` 散文内联在 `specs.py` 的 Python 三引号常量里，改稿要碰 Python、diff 脏、加 agent 要改 4 处。设计真源 `docs/design/prompt-management.md`。
- 选择：① 散文外置 `packages/agents/ww_agents/prompts/<spec.name>.md`，`system_prompt=load_prompt(name)` import 期读盘+内存缓存+确定性+fail-fast（去 BOM `utf-8-sig`/LF 归一/NFC/`rstrip('\n')`，**无运行期插值**——需插值的 prompt 不走此路径，属本波范围外）；② **金标准 fixture 取 AST/运行时 `system_prompt` 值算 sha256**（`tests/fixtures/prompt_hashes.json`），**不取源码文本**——因旧常量含反斜杠折行，源码物理换行≠运行时换行；导出时 `.md` 物理换行≡运行时换行；③ 尾换行方案 B：`.md` 允许 ≤1 尾 LF，loader `rstrip` 不补回，文件层断言守「尾 LF≤1」（与运行时金标准双层契约）；④ Pydantic 类型**永留 Python**，`SCHEMA_CATALOG[name]` 是 name→output 唯一真相源（收敛为 `dict[name,type|None]`，去恒 None 的 input 槽，YAGNI）；⑤ 内置 name（含 `REVIEW_RESERVED_NAMES`=continuity/foreshadow/style/pace）为保留命名空间，守卫**前移至 SkillRegistry 入库校验**（非 resolver 读路径），用户 skill 同名→`VALIDATION`（安全边界，呼应不变量 #3）；⑥ `*_spec` 兼容期保留且 `SPECS[name] is *_spec`（同一实例），编排器 + apps/api 3 路由本波不切 resolver。
- 理由 / 取舍：非工程改稿不碰 Python、缓存断点前块字节稳定（不变量 #9）；守卫前移让内置 `get` 纯内存零 DB、确定性、不把安全校验耦合进写章热路径。代价：`git log -p specs.py` 追不到旧 prompt 演进（迁移 commit body 注明），后续改 prompt 须同步重生成金标准 fixture。
- 影响：加 agent = 写 `.md` + 注册 `SPECS`/`SCHEMA_CATALOG`；wheel 必须 `force-include/artifacts` 带 `prompts/*.md`（@devops，CI 冒烟守）；`packages/{llm_gateway,core}` 补声明 `structlog`（原直接 import 未声明）。后续波次可将编排器/路由切 `SpecResolver` 后删 `*_spec`。
