docs(plans): W-ASSET-SRC 源工程存储执行计划(Opus 单评需返修→逐条修回·待创始人拍 §7)
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

承接《游戏资产管理范围决策》设计 §3① 切 SRC 一段(把 tier2 BackendStore 从
"真实现已写好却没通电"推到接生产/受治理/有真跑证据/半落库孤儿有修法)。
Opus 单评(Codex 结构性取不回、协议 #8 兜底单评)判需返修,三硬伤逐条修回:
R1 fetch 两分支补 status='committed' 过滤(否则 pending 半成品被当最新取回)、
R2 tier2 表不塞 db-schemas 根(撞 huijing Flyway 对账契约)改 contracts/README 治理指针、
R3 status 列补 ALTER 受治理迁移 + CREATE DATABASE 前置;M1-M3/L1-L2/docstring 整块亦修。
四工单(接线/治理+孤儿修法/取回重建 harness/清扫 job)六要素齐、contract-first。
§7.1 补全 manifest-first vs versionId-only 真实复杂度对照,留创始人拍。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-05 11:36:47 -07:00
parent e200bc4577
commit ae1f9a003d

View File

@ -0,0 +1,247 @@
---
date: 2026-07-05
topic: 资产管理-源工程存储(W-ASSET-SRC)执行计划
status: 二轮返修(Opus 单评,Codex 不可用) —— R1-R3 + M1-M3 + L1-L2 + docstring 整块 + §7.1 对照 已修,待创始人评审 §7
sot-impact: 不新建 canonical topic。本计划是《游戏资产管理范围决策》设计 §3 的执行拆段,落地牵动 `后端/数据模型`(tier2_source_project_version 从代码内自建 DDL 抬为受治理的 tier2 自有 schema)与 `架构/契约总览`(tier2 落库寻址契约点登记 + `contracts/README` 治理指针,不塞 huijing Flyway 授权目录 db-schemas/ 根);不改任何 canonical 命题,不碰便宜档 game_source_project 与 GamePackage 产物 schema。SRC 是源工程资产、服务生成主线可靠性,不在数据飞轮资产市场分期(阶段二/三/四)内,与素材线 ②③④ 正交。
上级: docs/agent-specs/2026-07-05-游戏资产管理范围决策-设计.md
关联: tier2/gen-worker/worker/store.py(BackendStore save:482 / fetch:574) · tier2/gen-worker/worker/run.py:904(persist_source_project) · tier2/gen-worker/worker/agent_loop/studio.py:515 · tier2/gen-worker/service/control_plane.py:407,452 · tier2/config/schema/tier2_source_project_version.sql · tier2/config/infra.yaml(database=tier2 / minio bucket=tier2-src) · contracts/agent-loop/tier2-source-project.schema.json(addressing §108-124) · game-cloud/huijing-server/src/main/resources/db/migration/V18.0.0__create_game_source_project.sql
图清单: [图1 SRC 段双面边界与四工单, 图2 manifest-first 落库时序, 图3 取回重建 harness 闭环]
---
# 源工程存储(W-ASSET-SRC)执行计划
资产面范围设计把源工程存储排在最优先,理由很实在:它不欠钱、不欠授权、不欠法务,一条端到端 harness 就能自证。这份计划承接那个已由创始人拍板的范围,只切 SRC 一段——把 tier2 富游戏这一面的源工程存储从「真实现已写好、却没通电」推到「接生产、有真跑证据、schema 受治理、半落库孤儿有修法」。便宜档那一面早已成熟(下面 §1 核实),本计划不动它。
## 0 一图看懂
SRC 只碰两套并存的源工程存储里的**富游戏那一面**(tier2 Python service 的 `BackendStore`)。便宜档那一面(Java 的 `game_source_project` + `SourceProjectApi`)已经在生产真用,划在范围外。四个工单把富游戏这一面通电、治理、验真、兜孤儿。
```mermaid
flowchart TB
subgraph OUT["范围外(已成熟 / 属他线)"]
C["便宜档 Java 面<br/>game_source_project + SourceProjectApi<br/>land/markBuilt/markOrphan/fetchByVersionId<br/>状态机 0草稿/1已构建/2孤儿/3已发布 · 单测在 · 生产真用"]
AC["assetContext 消费<br/>(素材线 ② W-ASSET-MAT-VERIFY,与源工程正交)"]
SL["fetch 生产消费方 = tier2 第二装载<br/>(后续线;SRC 只由 harness 验 fetch)"]
end
subgraph IN["SRC 段(本计划)"]
T1["T1 接线激活<br/>TIER2_STORE=backend 进部署配置"]
T2["T2 manifest 表治理 + 半落库孤儿修法<br/>受治理 schema + status 列 + 修陈旧注释"]
T3["T3 取回重建真跑 harness<br/>确定性重建/版本寻址/幂等/缺源显式失败"]
T4["T4 孤儿清扫 job<br/>触发/调度/owner/回收断言"]
T1 --> T3
T2 --> T3
T2 --> T4
end
classDef out fill:#eef2ff,stroke:#4f46e5,color:#312e81;
classDef now fill:#e6ffe6,stroke:#3a3,color:#14532d;
class C,AC,SL out
class T1,T2,T3,T4 now
```
三句话:**做什么** = 把 tier2 `BackendStore` 接生产、给它那张自建表定治理归属、用一条真 MySQL+MinIO 的 harness 坐实取回可重建、并堵掉半落库会留隐形对象的孤儿;**边界** = 只碰富游戏这一面,不动便宜档、不做素材、不接 fetch 的生产消费方、不合表;**怎么算成功** = `TIER2_STORE=backend` 在真基建上往返有日志,取回的源工程能确定性重建出同一个包,半落库不再留无 manifest 指向的残片。
## 1 现状核实(带行号证据)
设计初稿有两处过时前提,已在设计定稿里纠正过一轮;这份计划开工前又逐条在代码上复核了一遍,结论如下。
**BackendStore 是真实现,不是占位。** `tier2/gen-worker/worker/store.py:351` 起的 `BackendStore` 写完了:`save`(482 行)把 manifest 写进 MySQL、把源文件全文逐个 `put` 进 MinIO;`fetch`(574 行)按 `(game_id, versionId)` 从两边拼回一个可重建的源工程;惰性 import、连接失败响亮抛、路径安全校验都在。陈旧的不是一行而是模块头 docstring 一整块:第 8 行把这个面描述成「接口 + 本地实现 + 后端占位」,12-13 行说 `BackendStore` 是「后端落库占位:raise NotImplementedError……真实落库由 Java 后端按契约填」,16-19 行更进一步声称「tier2 内绝不实连 MySQL/OSS……那是 game-cloud Java 后端的活」——这些话与 351 行起的真实现(pymysql 真连、minio 真 put)整段矛盾。其中「forbidden-import 守着 tier2 不实连后端」还是一句误述:真守卫 `tier2/ci/check-forbidden-import.sh` 只禁 `wg1.*` 运行态 / SAA / L2 模型路由配置的 import,从不禁 `pymysql` / `minio`,拦不住也没打算拦 `BackendStore` 实连基建。这一整块 doc↔code 漂移随 T2 一并修掉(改整块 8-19 行、并纠正守卫范围的误述)。
**没接线,生产默认仍走本地盘。** `default_store()`(store.py:651-654)只有在环境变量 `TIER2_STORE=backend` 时才返回 `BackendStore`,否则返回 `LocalFsStore`。全仓 grep `TIER2_STORE`,命中只有四处:`docs/内网凭据与端点.md`(说明怎么切)、本范围决策设计档、`tier2/gen-worker/requirements.txt`(注释)、`store.py`(定义)——**没有任何一个 yaml / sh / env 部署配置真把它打开**。所以生产上这份真实现从未被调用过。
**源工程产物其实已经在落库,只是落的是本地盘。** 这一点要澄清,别和「未落库」混为一谈:tier2 收口有三处调用 `run.persist_source_project`——agent_loop 收口(studio.py:515)、Service 单 POST 门绿收口(control_plane.py:407)、Service resume 循环门绿收口(control_plane.py:452),三处都经 `default_store()` 取实现(run.py:931 `impl = store or default_store()`,933 `impl.save(...)`)。也就是说富游戏的 src/ 产物**已经经 persist_source_project 落进 LocalFsStore**,链路是通的、additive、best-effort(落库失败只告警、产物仍在 GEN_DIR workdir,不中断主链)。缺的不是「落库」,是「落到后端」。
**同源 DDL 已存在,但游离在治理之外。** 那张 manifest 表 `tier2_source_project_version` 有两份定义:store.py:334 的 `_DDL_SOURCE_PROJECT_VERSION`(首次 save 时 `CREATE TABLE IF NOT EXISTS` 自建)与 `tier2/config/schema/tier2_source_project_version.sql`(可审阅留档),两者字节对齐。但两份谁权威没说法、改 schema 也没有版本与回滚补偿的位置:它不是一条受 Flyway 治理的迁移,契约层也没有任何一处指针认领它。归属怎么落见 T2——据 R2 结论**不塞进 `contracts/db-schemas/` 根**那条 huijing Flyway 授权线(`contracts/README.md:45` 明写该目录 = huijing Flyway 授权源、须与 game-cloud 执行副本 diff 一致),tier2 这张不走 Flyway、无执行副本的表塞进去会直接撞该契约。
**部署事实已明确:tier2 是独立库。** `tier2/config/infra.yaml` 的 `mysql.database` 默认 `"tier2"`(注释明写「与 game-cloud 业务库隔离」),`minio.bucket` 默认 `"tier2-src"`。设计 §7② 把「manifest 表走 Flyway 还是走 tier2 自有 schema」的选择留给执行计划据「是否共库」拍——这里据实拍定:**独立逻辑库,不塞 game-cloud 的 Flyway**(game-cloud Flyway 治的是 huijing 库,跨库塞是类别错误),走「登记为受治理的 tier2 自有 schema」。这个结论不受「MySQL 实例是否物理同机」影响,后者只是运维部署细节。
**fetch 真实现完整,但没有一个生产消费方。** grep 业务侧 `.fetch(` 调用,tier2 内零命中;它设计上的消费入口「tier2 富游戏第二次装载(modify / 重建)」还没建。所以 SRC 交付的是 `save` 接生产 + `fetch` 由 T3 的 save→fetch→rebuild 闭环 harness 验证;`fetch` 的**生产消费方**是另立的后续线——锚点说清:就是 tier2 富游戏档的第二装载,即 A11 对话式调整回路(改源→按 base versionId 取回→重建)落到富游戏档时那次真实取回,排在 SRC 之后紧邻的一波。本计划只声明并锚定它、不在本线接;点明这个锚点是为了不让 `fetch` 久悬成「只被 harness 覆盖、无生产调用」的孤儿代码。别把 `fetch` 当成已有生产消费的接口。
**便宜档那一面已经很完整,不用 SRC 动。** 富游戏之外,便宜档 Java 侧不止一张 `game_source_project` 表(V18.0.0 已生产,V20.0.0 补唯一键),还有一整套 `SourceProjectApi`:`land`(落源 status=0,按 `(game_id, source_hash)` 幂等去重)、`markBuilt`(回填 version_id + status=1)、`markOrphanById`(按源行 ID 精确定位标 status=2 孤儿)、`fetchByVersionId`(A11 按 base 版本反查源注入),背后是 `SourceProjectServiceImpl` 全实现 + `SourceProjectStatusEnum`(0 草稿/1 已构建/2 孤儿/3 已发布)+ 单测。它的孤儿用 `status=2` 显式建模、事务边界化解「源已落、包未建」中间态,都是成熟做法。SRC 不碰它。
顺带点出一处两面并非完全对齐的细节,免得「统一寻址三元组」被误读:便宜档的 `version_id` 是 `Long`(引用 `game_version` 产物版本 id),而 tier2 的 `versionId` 是字符串 `v{时间戳}-{hash12}`(源版本标识)。设计 §3.1 讲的「两面用同一套 `{ id, versionId, sourceHash }` 寻址」是**字段名**层面的对齐,`versionId` 的类型与语义两面其实不同。SRC 只碰 tier2 一面,不强行统一;记下这条,防止后续有人以为跨档 versionId 可互换。
## 2 范围边界(SRC only)
**做(in scope):**
- 把 `TIER2_STORE=backend` 接进 tier2 service 部署配置,让生产走 `BackendStore`。
- 给 `tier2_source_project_version` 定治理归属(受治理 tier2 自有 schema),消除 in-code DDL 与留档 .sql 的双源漂移,立一条**机器可跑**的 doc↔code 对账断言(单测规范化比对、挂 tier2 CI,非人肉纪律)。
- 修掉半落库会留隐形孤儿的写入顺序缺陷(§4 T2 定方案)。
- 一条真 MySQL + 真 MinIO 的 save→fetch→rebuild harness,坐实确定性重建、版本寻址、幂等、缺源显式失败、半落库不留孤儿。
- 孤儿清扫 job(触发条件、调度、owner、一条真跑过的回收断言)。
- 修 store.py 模块头陈旧注释(「占位」→ 真实现)。
**不做(out of scope):**
- **不碰便宜档那一面**(`game_source_project` + `SourceProjectApi`),它已生产成熟。
- **不做素材、不补 assetContext 消费**——那是 ② W-ASSET-MAT-VERIFY 的活,与源工程存储正交(见 §3)。
- **不接 fetch 的生产消费方**(tier2 第二装载),后续线;SRC 只由 harness 验 fetch。
- **不加 `lineage` / `assetRefs` 归因字段**——那是 ③ 只读半 + 溯源链阶段一的跨线活,与源工程骨架落库无关。
- **不合表、不造跨档统一资产表**,只对齐寻址语义(设计 §3.1,避免孤儿抽象)。
- **不改 GamePackage 产物 schema、不改便宜档 source-project.schema.json**——「改源不改产物」。
## 3 开工第一步:两条「断链」核实结论
任务里把「assetContext / 生成 src/ 产物当前可能未被生成 worker 真正消费落库」列为 SRC 开工前必须核实的硬伤线索。核实后要把这句话里并列的两件事拆开,结论不同:
**「生成 src/ 产物未落库」——不成立。** 上面 §1 已坐实:tier2 的 src/ 产物经 `persist_source_project` 三处收口调用(studio.py:515 / control_plane.py:407,452)落进 `default_store()`,链路是通的。缺的是后端未接线(默认落 LocalFsStore),这正是 T1 要收的口,不是断链。
**「assetContext 未被生成 worker 消费」——成立,但不属于 SRC。** grep 生成 worker 侧(`tier2/` 与 `game-runtime/` 的 Python)`assetContext` / `materialId`,**零命中**;它只存在于 Java 编排层(studio 与 aigc 模块的 DTO/VO/Service)。也就是说创作者选的素材大概率还没真流进生成、更没回填归因。但这条链路讲的是**素材**怎么进生成上下文,和**源工程 src/**怎么落库取回是两件正交的事——SRC 存的是源工程骨架,从不碰 assetContext。
所以 SRC 的开工第一步**不需要**先补 assetContext 断链;这条缺口显式记在这里、明确划归 ② W-ASSET-MAT-VERIFY(对应设计 §4.3 与 §7④ 的核实待办),避免与「src/ 落库」混淆而误扩 SRC 范围。SRC 真正的第一步是 §1 已完成的现状复核(BackendStore 未接线、三处落库调用经 default_store、独立库事实),据此直接进 T1。
## 4 实施工单(六要素)
四个工单,T1 与 T2 可并行起步,T3 依赖两者,T4 依赖 T2 的 schema 决定。真跑步骤都在 mini-infra / mini-desktop 的真 MySQL + MinIO 窗口做,6c6g 只做本地 LocalFsStore 自检与代码编辑。
### T1 接线激活
**背景/目标。** `BackendStore` 真实现已写好却没通电,生产默认走本地盘。目标是让 `default_store()` 在生产返回 `BackendStore`,且 MySQL / MinIO 连接参数从 `infra.yaml` 现取现连。
**范围边界。** 只动部署配置这一处开关,不动 `default_store()` 的工厂形态(它已经是「一处切换、主链与测试都经它取实现」的正确形态)。`LocalFsStore` 保持默认回落,6c6g 本地自检仍用它。
**前置 checklist(接线前先在真基建上核两件底账,免得开关一开就撞空库)。** ① `infra.yaml:48` 明写「建表前先 CREATE DATABASE」,故先确认 / 创建 tier2 逻辑库(`CREATE DATABASE IF NOT EXISTS tier2`),否则 `BackendStore` 首次 `save` 连库即失败。② 确认 `tier2_source_project_version` 表当前状态:`BackendStore` 靠 `CREATE TABLE IF NOT EXISTS` 自建,若之前的测试窗口已把这张表建成了**没有 `status` 列**的旧形态,`IF NOT EXISTS` 不会补列,须按 T2 的 `ALTER TABLE ... ADD COLUMN status` 补齐,或在确认无存量数据时 DROP 重建,并把这次处置记进接线日志。
**方案(WHAT+HOW)。** 按 AGENTS §6 条款 9(内网决策进配置、不散落硬编码),把 `TIER2_STORE=backend` 放进 tier2 service 的部署配置单点——落在部署 env 或 `infra.yaml` 的 store 段(择一,与现有 service 起栈方式一致,执行时对齐 staging-ops 的起栈脚本)。切换只在这一个开关,不在别处硬编码 `BackendStore` 类。接线后先在真基建上跑一次「生成一款富游戏 → 收口落库」,确认日志出现 `BackendStore.save 落库成功`(store.py:539)而非 `LocalFsStore` 的落库行。
**contract-first。** 无契约变更——`TIER2_STORE` 已是既有开关,`tier2-source-project.schema.json` 的 addressing 契约(store='mysql+oss'、sourceUrl)已由 `BackendStore.save` 兑现(store.py:526-527)。
**验收。** `TIER2_STORE=backend` 在部署配置里可查;真 MySQL + 真 MinIO 往返有 `BackendStore` 落库日志(不是 LocalFsStore 假通过);未设该开关时仍回落 LocalFsStore(非目标稳)。
**风险与回滚。** 风险:切后端后落库失败率若上升,会触发 best-effort 告警但不中断主链(已有兜底)。回滚:部署配置里移除该开关即整体回落 LocalFsStore,一处可逆、无数据迁移。
### T2 manifest 表治理 + 半落库孤儿修法
**背景/目标。** 这张 manifest 表游离在迁移治理之外(in-code DDL + 留档 .sql 双源),且当前 `save` 的写入顺序会在半落库时留下无 manifest 指向的隐形对象。目标:给表定受治理归属、消除双源漂移、堵掉隐形孤儿。
**范围边界。** 只动 tier2 这一面的 manifest 表定义与 `BackendStore.save` 的写入顺序;不动 `LocalFsStore`、不动便宜档表、不改 `derive_version_id` 的对外语义(除非采备选方案,见下)。
**方案(WHAT+HOW)。** 分两件。
治理归属据 §1 的独立库事实拍定:**登记为受治理的 tier2 自有 schema,不塞 game-cloud Flyway**。落法取 R2 优先方案:`.sql` 留在 `tier2/config/schema/tier2_source_project_version.sql` 原地(tier2 owner、不进 huijing Flyway 序列),只在 `contracts/README.md` 加一条治理指针认领它——标明 owner=tier2、明说这张表不参与 huijing Flyway 对账(不与 game-cloud 执行副本 diff)。不塞进 `contracts/db-schemas/` 根的理由在 §1 已点:那目录被 `contracts/README.md:45` 定义为 huijing Flyway 授权源、须与 game-cloud 执行副本保持 diff 一致,tier2 这张不走 Flyway、无执行副本的表塞进去即撞该契约。备选是另立 `contracts/db-schemas/tier2/` 子目录、并在 README 注明该子目录不参与对账;两案评审时定,但无论取哪条,**改 `contracts/README.md`(加治理指针,或加子目录说明)都是 T2 的交付项**,不改则这张表仍旧游离——这也把设计 §7② 里「独立子目录 or owner 标注」那处含糊掉的目录语义冲突讲清了:冲突点就是 db-schemas/ 根有「须与执行副本 diff 一致」的硬语义,而 tier2 表无执行副本,故要么进不对账的子目录、要么干脆不进目录只在 README 挂指针。
单一真相取「in-code DDL 执行 + .sql 由 CI 断言同步」这一路,不走「运行时读 .sql」(M3):in-code `_DDL_SOURCE_PROJECT_VERSION` 仍是首次 save 时真执行的那份,`.sql` 是权威声明与人读对账基准,再加一条**可跑**的对账断言——单测把 `store._DDL_SOURCE_PROJECT_VERSION` 与 `.sql` 文件内容各自规范化(去注释、压空白)后 assert 相等,挂 tier2 CI。这样双源改一处、漏改另一处即红,「单一真相」从注释里的口号变成一道机器门,不再靠人肉纪律(项目「规则必须编译成机器门」)。之所以不让代码运行时读 `.sql`:那会给 store.py 添一条运行期文件依赖,破坏它 6c6g 零运行期依赖、可独立 import 的现有属性,得不偿失。表名 `tier2_source_project_version`、幂等键 `uk_game_source(game_id, source_hash)`、版本键 `uk_game_version(game_id, version_id)`、MinIO object key 前缀 `game_id/versionId/` 立为 doc↔code 断言点(凭据档、store.py 落库、store.py 取回三处对齐)。
孤儿修法采 **manifest-first**(设计 §3.3 推荐,也在 §7① 待拍板列为倾向项)。现状缺陷坐实于三处收口 `now_ts=time.time()` 现取墙钟(studio.py:516、control_plane.py:408、453),versionId = `v{int(now_ts)}-{hash12}`:当 MinIO 写成功、MySQL insert 失败时,重投时 `now_ts` 变 → versionId 变 → MinIO 前缀变 → 上次写进旧前缀的对象成为永久无 manifest 指向的隐形残片(每次 MySQL-after-MinIO 瞬时失败都产生,非仅永久失败)。修法把 `save` 顺序改成先写 manifest 后写对象:
```mermaid
flowchart TB
A["save 入口:算 sourceHash"] --> B{"查 (game_id, source_hash)"}
B -->|命中 committed| R1["直接返回已有版本(幂等)"]
B -->|命中 pending| C["复用该行已定的 versionId 续 put(同前缀真覆盖=真自愈)"]
B -->|未命中| D["派生 versionId · MySQL 落一行 status=pending"]
D --> C
C --> E["put 源文件全文进 MinIO(前缀 game_id/versionId/)"]
E --> F["把该行 status 翻 committed"]
F --> R2["返回 {id, versionId, sourceHash}"]
E -.崩溃.-> P["留一条可见的 pending 行(不是隐形对象)"]
classDef n fill:#f8fafc,stroke:#0f172a,color:#0f172a;
class A,B,C,D,E,F,R1,R2,P n
```
崩溃留下的是一条**可见**的 pending 行(`fetch` 本就靠 `missingContent` 容忍残缺),孤儿 = pending 超时行,T4 的清扫 job 按 `status` 认得出、按行里的 `(game_id, versionId)` 拼出前缀删得掉。
`status` 列不只服务清扫 job——`fetch` 的可见性语义同样依赖它,这一条必须一并落,否则 manifest-first 的保证只兑现一半(R1)。现状 `fetch`(store.py:587-598)两条查询分支(versionId=None 取最新、给定 versionId 取该版本)都只按 `game_id`(加 `version_id`)过滤,没有 `status` 谓词;加了 `status` 列却不在 `fetch` 上过滤,一条尚未翻 committed 的 pending 半成品就会被当「最新版本」取回,「消费方取不到半成品」就落空。故 T2 的实现范围明确含**两条 `fetch` 分支都补 `AND status='committed'`**:取最新那条在 `WHERE game_id=%s` 后补,取指定版本那条在 `WHERE game_id=%s AND version_id=%s` 后补。「pending 版本对取回不可见」由此立为 T2 的 contract-first 一项、T3 的断言一条。
加 `status` 列还欠一条受治理的迁移路径(R3)。`BackendStore` 建表靠 `CREATE TABLE IF NOT EXISTS`(store.py:499),对一张**已存在**的表加不了列——只改 DDL 字面量的话,历史窗口里已建的无 `status` 列旧表永远补不上,这与本计划批评现状「schema 变更没版本、没回滚位」自相矛盾。故补一条 `ALTER TABLE tier2_source_project_version ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT 'committed'` 作为这张表第一份受治理迁移,落进 `.sql` 权威声明与 in-code DDL 两处:`DEFAULT 'committed'` 兜历史行(历史落进来的都是完整版本、视作已提交),新写入路径按上面时序显式置 pending / committed、不吃 DEFAULT。执行时按 T1 前置 checklist 先确认表状态,已建无列旧表则先跑这条 ALTER、确认无存量数据也可 DROP 重建,并记录处置。
代价是 manifest 表多一个 `status` 列(新建走 DDL、存量走 ALTER,都 additive、不破存量)。若执行时评估 manifest-first 改动面过大,备选是让 versionId 只由 `source_hash` 派生(去时间戳、版本时序靠 `created_at` + `idx_game_created`),重投复用同前缀真覆盖、不加列也没有 pending 中间态——但它动 `derive_version_id` 这个与 `LocalFsStore` 共享的代码契约,影响面更大,故列为备选、pick 据代码影响面在评审时定。
同时修 store.py 模块头那整块陈旧 docstring(8-19 行,见 §1):把「接口 + 本地实现 + 后端占位」「raise NotImplementedError……真实落库由 Java 后端填」「tier2 内绝不实连 MySQL/OSS」这些与 351 行真实现相反的话,改成与真实现一致,并纠正「forbidden-import 守着 tier2 不实连后端」那句误述(真守卫 `check-forbidden-import.sh` 只禁 wg1.*/SAA/L2 配置的 import,不禁 pymysql/minio)。
**contract-first。** 先改契约再改实现:① `tier2_source_project_version` 加 `status` 列(enum pending/committed;新建走 DDL、存量走 `ALTER ... ADD COLUMN status ... DEFAULT 'committed'`),`fetch` 两条分支都补 `AND status='committed'`,同步 `.sql` 权威声明与 store.py in-code DDL 两处,并加一条单测规范化对账断言(M3、挂 tier2 CI)把两处钉成机器门;② 在 `contracts/README.md` 加治理指针认领这张表(owner=tier2、明说不参与 huijing Flyway 对账),`.sql` 留在 `tier2/config/schema/`——**不塞 `contracts/db-schemas/` 根**(那是 huijing Flyway 授权源、须与执行副本 diff 一致,会撞契约;备选另立 `contracts/db-schemas/tier2/` 子目录并注明不对账,评审定);③ 落库寻址三点(表名 / 双唯一键 / object key 前缀)+「pending 对 fetch 不可见」登记为 doc↔code 断言点。不改 `tier2-source-project.schema.json` 的 addressing 字段(status 是表列、不进 manifest_json)。
**验收。** 模拟 MySQL pending 落成功、MinIO 写失败后,没有「有对象、无 manifest 指向」的残片,只留可见 pending 行;一条 pending 版本存在时 `fetch`(取最新与取指定版本两条路)都取不到它、只返回 committed 版本(R1);同源改后 in-code DDL 与 .sql 经对账单测判为等价(机器门、非人眼比),`contracts/README.md` 的治理指针能查到这张表的 owner 与「不参与 Flyway 对账」说明;幂等重投同 `(game_id, source_hash)` 复用既有记录不落第二版(改动真生效 + 血缘可查);`LocalFsStore` 与便宜档链路不受影响(非目标稳)。
**风险与回滚。** 风险:改 `save` 写入顺序若引入新回归,落库失败率变化会经 best-effort 告警暴露、不连坐主链。回滚:DDL additive 列可留(不破存量),`save` 逻辑改动可 revert 到旧顺序;因是 additive,无破坏性迁移。
### T3 取回重建真跑 harness
**背景/目标。** 「按 versionId 取回 base 源 / 取回重建」这条端到端从没在真 MySQL + 真 MinIO 上跑通过——这是 SRC 的验收核心,也是今天最大的空白。目标是留下一条可复现、有日志的 harness,同时作为 §1 几条核心断言(缺源显式失败、半落库不留孤儿)的落点。
**范围边界。** 只验 `BackendStore` 的 save→fetch→rebuild 闭环;不接生产消费方(第二装载),harness 自己驱动 fetch。
**方案(WHAT+HOW)。**
```mermaid
flowchart LR
S["save 一份富游戏源工程"] --> F["fetch 按 versionId 取回"]
F --> B["把 fileTree[].content 重新 esbuild 构建"]
B --> A{"sha256(重建 bundle) == sha256(原 bundle)?<br/>(优先走这条=真验重建确定性)"}
A -->|字节相等| OK["通过"]
A -->|实测 esbuild 非字节稳才降级| A2{"重跑九门 verdict 逐项一致<br/>+ 无 missingContent?<br/>(buildInputHash 恒等、不作承重断言)"}
A2 -->|是| OK
A2 -->|否| NO["失败:不接受降级"]
classDef n fill:#f8fafc,stroke:#0f172a,color:#0f172a;
class S,F,B,A,A2,OK,NO n
```
harness 覆盖五条。**确定性重建**(save→fetch→重新 esbuild):**优先断言重建 bundle 的 sha256 与原 bundle 字节相等**,这才是真验「重建产物是否确定」;只有实测 esbuild 在该环境非字节稳,才降级,降级后的承重断言写死为**重跑九门 verdict 逐项一致 + 无任何 `missingContent`**,不接受「能进构建就算过」。这里特意不拿 `buildInputHash` 当降级后的首断言:它 = `sha256(contentHash + buildProfile)`,save→fetch 往返这两样都原样不变,故几乎必然相等、近乎恒真,对「重建产物是否确定」零区分力(同历史「efficiency 零区分」那类坑),留它只作旁证、不承重。harness 每次记录实际走了哪条路(字节相等 / 降级),以观测 esbuild 在真环境的稳定性,免得图省事直接走弱路、其实没验到重建确定性。**版本寻址**:改一处源重建,断言落新 versionId、旧版本仍可按旧 versionId 取回。**幂等**:同源重投命中 `(game_id, source_hash)`、不落第二版。**缺源显式失败**:人为让某文件在 MinIO 缺失,fetch 标 `missingContent`,断言重建步骤据此显式失败、报「无法重建此版本」,绝不静默退回全量重生成。**半落库不留隐形孤儿**:按 T2 改后的新写入顺序(MySQL pending→MinIO→翻 committed),两个失败点各验一遍——(a)MySQL pending 落成功而 MinIO put 失败(可见 pending 行、对象缺或部分),(b)MinIO put 成功而翻 committed 失败(可见 pending 行、对象完整)。场景(b)是 manifest-first 新造的中间态,补三条断言:这条 pending 版本对 `fetch` 不可见(依赖 R1 的 committed 过滤)、同源重投能自愈把它翻成 committed、无重投时 T4 清扫 job 能认出回收;两个失败点都不得留下无 manifest 指向的隐形对象。
`buildInputHash` 若被引用只作旁证,其契约依据在:`source-project.schema.json:20` 与 `tier2-source-project.schema.json:103` 都已定义该字段(sha256(sourceHash + buildProfile)),无需新增契约。
**contract-first。** 无契约变更——harness 消费既有 `save`/`fetch` 契约与 `buildInputHash` 字段。
**验收。** 五条全绿且留下 mini-infra/mini-desktop 真跑日志;重建 bundle sha256 字节相等(实测 esbuild 非字节稳才降级到九门 verdict 逐项一致 + 无 missingContent,并记录本次走了哪条路);缺源时显式失败不静默重生成;半落库两个失败点(a/b)都不留残片、场景 b 的 pending 对 fetch 不可见。这五条即 SRC「取回可重建」的证据本体。
**风险与回滚。** 风险:esbuild 在某些环境非字节稳定,故预置「九门 verdict 逐项一致 + 无 missingContent」作降级承重断言(不拿近乎恒真的 buildInputHash 承重),避免 harness 因环境抖动误判、也避免图省事走弱路。harness 是只读验证资产,无回滚需求。
### T4 孤儿清扫 job
**背景/目标。** T2 把隐形孤儿变成可见的 pending 行后,需要一个 job 定期把超时的 pending 行连同其 MinIO 前缀回收,否则残留仍会累积。设计 §3.3 明确要求把清扫 job 抬为本线必交项(触发条件、调度、owner、一条可验收断言),不再只在括号里带一句。
**范围边界。** 只回收 tier2 这一面按 T2 方案判定的孤儿;不碰便宜档(其孤儿由 `status=2` 显式建模、另有归属)、不碰 committed 正常版本。
**方案(WHAT+HOW)。** 触发条件二选一并存:pending 超时行(`status=pending` 且 `created_at` 早于阈值),或「有对象前缀却无 committed manifest 行」的兜底扫描。回收动作:按行的 `(game_id, versionId)` 拼出 MinIO 前缀 `game_id/versionId/`,**先用带 `status=pending` 谓词的乐观删(CAS)删掉那一行、删中(影响行数=1)再删对象**——不是先删对象再删行。这个顺序堵一个 TOCTOU 竞态:清扫正要回收某超时 pending 行时,恰有一次同源 `save` 命中它、复用其 versionId 续 put(见 T2 时序图「命中 pending」分支),若先删对象再删行,续 put 刚写进去的新对象会在随后删行后又变成隐形孤儿。乐观删先删行:CAS 命中(行还在、仍 pending)才继续删对象;若这轮没删到(行已被续 put 收尾翻成 committed、或已被别的清扫删走),整单跳过、不碰对象。更稳一层的可选加固是续 put 前给 pending 行加一个短租约 / 版本号 CAS 让清扫与续 put 互斥,概率虽低、实现时权衡。全程幂等(重复跑不误删 committed)。调度挂 tier2 service 侧定时任务(与现有 service 运维口径一致),owner = 生成线(tier2)。回收前后打可追溯日志(gameId / versionId / 删除对象数)。
**contract-first。** 无新契约——复用 T2 的 `status` 列与 `(game_id, versionId)` 寻址。
**验收。** 构造一条 pending 超时行 + 其半写对象,跑清扫 job 后该行与对象都被回收、committed 版本不受影响(一条真跑过的回收断言);重复跑 job 幂等无副作用(非目标稳)。
**风险与回滚。** 风险:误删 committed 对象。缓解:只按 `status=pending` + 超时窗口选行,committed 行永不进回收集;兜底扫描对「无 committed manifest」的前缀才删。回滚:job 可停(停后仅 pending 行累积,不影响主链),无破坏性。
## 5 contract-first 契约变更清单
先改契约再改实现,逐项标 `contracts/` 落点:
| 契约项 | 变更 | 落点 | 属工单 |
|---|---|---|---|
| tier2 manifest 表 DDL | 加 `status`(pending/committed) 列(新建走 DDL、存量走 `ALTER ... ADD COLUMN status ... DEFAULT 'committed'`);`fetch` 两分支补 `AND status='committed'`;in-code DDL 与 .sql 同源 + 单测规范化对账断言(挂 tier2 CI) | `tier2/config/schema/tier2_source_project_version.sql`(权威声明) + `store.py:334` `_DDL_SOURCE_PROJECT_VERSION`(执行副本) + `fetch`(store.py:587-598) | T2 |
| tier2 manifest 表治理登记 | 在 `contracts/README.md` 加治理指针认领(owner=tier2、明说不参与 huijing Flyway 对账);.sql 留 `tier2/config/schema/` 不塞 db-schemas/ 根(备选:另立 `contracts/db-schemas/tier2/` 子目录并注明不对账) | `contracts/README.md`(治理指针) · `tier2/config/schema/*.sql`(原地) | T2 |
| 落库寻址 doc↔code 断言点 | 表名 / `uk_game_source` / `uk_game_version` / object key 前缀 `game_id/versionId/` + 「pending 对 fetch 不可见」立断言 | 凭据档 · store.py `save` · store.py `fetch` | T2 |
| addressing 落库寻址契约 | **不改**(store/sourceUrl 已由 BackendStore 兑现;fetchById 仍 directional) | `contracts/agent-loop/tier2-source-project.schema.json:108-124` | 无 |
| buildInputHash 字段 | **不改**(两份 schema 已有;因近乎恒真只作旁证、不承重,重建验收主走 bundle 字节相等) | `source-project.schema.json:20` · `tier2-source-project.schema.json:103` | T3 引用 |
| GamePackage 产物 schema / 便宜档 source-project.schema.json | **不改**(改源不改产物;便宜档不碰) | `contracts/game-package.schema.json` · `contracts/agent-loop/source-project.schema.json` | 无 |
## 6 不留孤儿:新结构与接口交付清单
| 结构 / 接口 | 入口 | 使用路径 | 失败模式 | 验收 | 状态 |
|---|---|---|---|---|---|
| `BackendStore`(save 接生产) | `run.persist_source_project` 三处收口(已在,经 default_store) | 首次生成 save 落 manifest + 全文;`TIER2_STORE=backend` 生产走它 | best-effort:落库失败告警、产物仍在 GEN_DIR workdir、不中断主链 | 真 MySQL+MinIO 往返有落库日志 | T1 接线 |
| `tier2_source_project_version` + `status` 列 | `BackendStore.save` 写入 | manifest 落 MySQL、status 标 pending→committed;`fetch` 只返 committed | 半落库留可见 pending 行(非隐形对象);双唯一键防重;pending 对 fetch 不可见 | 受治理 schema + 断言点 + 同源对账机器门 + fetch committed 过滤 | T2 新增 status |
| `BackendStore.fetch`(只返 committed) | **仅 T3 harness 调用** | save→fetch→rebuild 闭环验证;fetch 补 committed 过滤、pending 不可见 | 缺源标 missingContent;重建据此显式失败不静默重生成 | harness 五条断言 | **生产消费方 = tier2 富游戏档第二装载(modify / 重建,对应 A11 对话式调整在富游戏档的落地);锚在该后续线、SRC 之后紧邻波接,本线只 harness 验** |
| 取回重建 harness | mini-infra/mini-desktop 手动/脚本 | 确定性重建/版本寻址/幂等/缺源失败/半落库不留孤儿 | 优先 bundle 字节相等;环境抖动才退九门 verdict 逐项一致 + 无 missingContent(不拿 buildInputHash 承重) | 五条全绿留日志 | T3 新建 |
| 孤儿清扫 job | tier2 service 定时任务 | 扫 pending 超时行 → 拼前缀删对象 + 删行 | 只删 pending + 超时;committed 永不进回收集 | 一条真跑回收断言 + 幂等 | T4 新建 |
`fetch` 这一行要特别诚实:它真实现完整但**没有生产消费方**,SRC 交付后它仍是「仅 harness 验证」的接口。计划显式声明第二装载是后续线,不把 `fetch` 伪装成已接生产消费——否则就是又一个孤儿设计。
## 7 待拍板点(承接设计 §7 的 SRC 相关项)
以下几点在切具体实现前定夺,均已在设计 §7 挂号:
1. **半落库孤儿修法二选一**(设计 §7①):manifest-first vs versionId 只由 source_hash 派生(去时间戳)。这一拍要看得见两边真实的账,别只凭「倾向」——
- **manifest-first** 的隐藏成本不止「加一个 status 列」,而是连带一串必配项:`fetch` 两条分支都得补 committed 过滤(R1,否则半成品会被当最新取回),加列要给已存在的表补一条受治理 ALTER 迁移(R3,`CREATE TABLE IF NOT EXISTS` 加不了列),多出的 pending 中间态要为它补场景 b 的断言(M1),清扫 job 还要处理与续 put 的 TOCTOU 竞态(L1)。好处是隐形孤儿变可见 pending 行、不动共享的 `derive_version_id`、语义最直白。
- **versionId 只由 source_hash 派生** 的账相反:不加列、无 pending 中间态,R1 / R3 / M1 / L1 这串连带全都不需要,重投复用同前缀真覆盖即自愈;代价集中在一处——它改的是 `derive_version_id` 这个**与 `LocalFsStore` 共享**的代码契约,去掉时间戳会牵动两处实现的版本时序语义(排序改由 `created_at` + `idx_game_created` 承担),面窄但更深。
把这串连带成本都算进 manifest-first 一侧再拍,pick 据代码影响面在评审时定。无论哪条,孤儿清扫 job(T4)都是必交项。这点仍留作待拍板项,本计划不替创始人拍,只把账补齐到看得见。
2. **manifest 表治理登记的具体落点**(设计 §7②):本计划据 infra.yaml `database=tier2` 独立库事实拍「tier2 自有受治理 schema、不塞 game-cloud Flyway」。目录语义冲突讲清:`contracts/db-schemas/` 根被 `contracts/README.md:45` 定义为 huijing Flyway 授权源、须与 game-cloud 执行副本 diff 一致,而 tier2 这张表不走 Flyway、无执行副本,直接塞进根就撞这条契约。故**优先方案 = .sql 留在 `tier2/config/schema/` 原地、只在 `contracts/README.md` 加一条治理指针**(标 tier2 owner、明说不参与 huijing Flyway 对账);备选 = 另立 `contracts/db-schemas/tier2/` 子目录、README 注明该子目录不参与对账。「往 db-schemas 收录」到底怎么收由此定形、不再含糊;无论哪条,改 `contracts/README.md` 都是 T2 的交付项。
3. **寻址是否要做一层薄的跨档查询门面**(设计 §7⑤):本计划主张不合表、只统一寻址语义。若产品后续需要「一个游戏跨档查它所有源版本」的统一入口,再评估加只读查询门面——不提前造,避免孤儿抽象。
设计 §7③(assetRefs 契约归宿)、§7④(assetContext 消费核实)不属于 SRC——前者归 ③ 只读半 + 溯源链阶段一,后者归 ② MAT-VERIFY,本计划 §3 已划清。
## 8 验收总线(SRC 整体)
一款富游戏生成成功后有源工程寻址结果(gameId / versionId / sourceHash);按 versionId 能取回 base 源、改源重建落新 versionId、旧版本仍可取回、缺源时重建显式失败不静默全量重生成;取回的源工程能确定性重建(优先断言重建 bundle sha256 字节相等,实测 esbuild 非字节稳才退到九门 verdict 逐项一致 + 无 missingContent、不拿近乎恒真的 buildInputHash 承重,并记录本次走了哪条路,不接受降级);幂等重投同 `(game_id, source_hash)` 不重复制造版本;`TIER2_STORE=backend` 在生产真接线、真 MySQL + 真 MinIO 往返有跑通日志(非 LocalFsStore 假通过);manifest 表 schema 治理归属已定、不再游离,in-code DDL 与 .sql 由对账单测钉成机器门;落库失败不伪造成功、日志能追到 gameId / versionId / sourceHash / 失败阶段;半落库不留隐形孤儿、pending 半成品对 fetch 不可见、孤儿清扫 job 有 owner、有触发条件、有一条真跑过的回收断言。
---
> 本计划待 Codex + Opus 双评审通过后方可执行(Codex 不可用则 Opus 单评);评审对象 = 本份计划,把 SRC 的前提与代码事实喂进去,文内修掉发现项。