merge: refactor/document-reliability-pilot
Occupy the integration slot to rebuild dev for M1 live sign-off of document reliability U1–U4.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# ADR:以纵向绞杀方式实施模块化单体解耦
|
||||
|
||||
## 状态
|
||||
|
||||
提议(2026-09-14;架构与实施计划已修订,等待用户确认后进入首个实现里程碑)
|
||||
|
||||
## 背景
|
||||
|
||||
`internal/server`、`internal/bizdev`、共享 `UserProjectContext`、工作区路径和多表写入形成隐式耦合。
|
||||
历史受控正文 404/409、刷新后投影缺失和任务终态不完整说明,单纯搬包不会修复事实源、事务与恢复边界。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **保持模块化单体**:不拆微服务;进程内调用使用窄端口,跨进程可靠性只用于持久事件和 FAV。
|
||||
2. **纵向绞杀**:先在现有包边界插入端口和适配器,迁完一条业务链并验证后再移动或重命名包;禁止“先全量搬包、以后再接口化”。
|
||||
3. **契约分层**:外部 API DTO 归 `server/api`;持久 schema、公共事件和稳定共享词汇进入按领域划分的 contracts;一对一内部接口由消费者拥有。
|
||||
4. **命令与事务同属一个应用用例**:一个业务命令只有一个事务 owner;跨表属主通过同一不透明事务作用域协作,业务状态、账本、当前指针、staging 和 outbox 原子提交。
|
||||
5. **单一写者不等于单独事务**:表属主控制 SQL 和不变量;应用 UnitOfWork 控制跨属主提交边界。属主端口不得私自嵌套提交。
|
||||
6. **并发正确性以数据库为准**:用户编辑使用乐观版本/ETag;状态转换使用行锁和条件更新;项目级不变量可使用事务级 advisory lock;进程内 mutex 只能优化,不能作为多副本正确性依据。
|
||||
7. **锁顺序固定**:同一事务按 project/workflow instance → document record → document version/snapshot → task run → outbox/staging 的顺序取锁;事务内禁止 LLM、对象存储、网络调用和长文件 I/O。
|
||||
8. **可靠事件采用 transactional outbox**:事件按至少一次投递设计,具有稳定 ID、schema version、环境/项目范围、聚合序号、因果链和幂等键;逐消费者记录 delivery、重试、租约和死信。
|
||||
9. **工作区是可清理投影**:跨模块只传不可伪造 Workspace ID 和受限 artifact key;物理路径只在 workspace/squadrun 适配器内解析和校验。
|
||||
10. **最终成果进入 FAV**:PostgreSQL 保存业务关系和授权,FAV 只保存已确认终态的精确 payload、manifest、回执和删除状态,workspace 不承担最终成果事实源。
|
||||
11. **授权逐动作校验**:认证适配器构造不可由请求覆盖的 Principal;HTTP、CLI、后台订阅者和恢复流程都按 action/resource/current state fail closed。
|
||||
12. **先完成文档可靠性试点**:只有受控正文刷新、重启、并发提交、投影缺失降级和授权负例通过后,才批准 chat、squad、编排层和传输层扩展。
|
||||
13. **迁移采用 expand/migrate/contract**:允许新增 Goose migration;不得修改已发布迁移;回滚只保证回到最近的兼容阶段,不能任意独立 revert 早期依赖。
|
||||
14. **协议改造独立立项**:统一错误信封和 PI Runner `result.json` 不作为核心模块解耦前置条件;它们使用独立兼容与 E2E 验收。
|
||||
|
||||
## 并发与失败语义
|
||||
|
||||
- 普通聚合状态转换默认 `READ COMMITTED` + 明确行锁/条件更新;涉及跨聚合唯一不变量时才使用 Serializable,并对 `40001`/`40P01` 重跑完整事务。
|
||||
- outbox worker 使用小批次 `FOR UPDATE SKIP LOCKED` 认领,处理发生在事务外;完成、续租和失败回填使用 fencing token 防止过期 worker 覆盖新 owner。
|
||||
- 每个写命令必须同时有 idempotency key、expected version 或两者;相同 key 不同 payload 返回 typed conflict。
|
||||
- 锁等待、事务时长、重试次数、队列积压和死信必须可观测;超过预算时失败返回,不无限等待。
|
||||
|
||||
## 授权与恢复语义
|
||||
|
||||
- Principal 至少携带 actor、环境、认证来源和请求关联 ID;项目、会话、文档、阶段与 duty 从数据库权威关系重新解析。
|
||||
- 持久事件中的 actor 只用于审计,不能替代订阅者对当前资源状态的授权检查。
|
||||
- FAV 降级读取要求运行库授权图完整;授权图缺失时禁止面向用户返回内容。
|
||||
- 恢复只允许进入 scratch identity;promotion 需要系统管理员与数据责任人确认,并通过各属主端口提交,禁止恢复 CLI 直写运行表。
|
||||
|
||||
## 后果
|
||||
|
||||
- 首个实现里程碑的改动面小于原 Wave 1–6,但会先增加 UnitOfWork、事件投递和授权基础设施。
|
||||
- “单表唯一写者”检查改为生产 SQL 写权限检查,并允许 migration、测试和经批准运维脚本存在表名。
|
||||
- `feature/final-artifact-vault` 是候选依赖,不直接假定可合入;必须先按当前 dev 重放测试、迁移和权限审计。
|
||||
- 包物理移动、错误信封、SSE 和 PI Runner 协议可延后,不阻塞文档一致性整改。
|
||||
|
||||
## 替代方案
|
||||
|
||||
- **一次性横向搬包**:拒绝。冲突面大,不能先证明用户故障减少。
|
||||
- **仅增加 arch-check**:拒绝。门禁不能替代事务、锁和授权契约。
|
||||
- **拆微服务**:拒绝。当前问题是模块边界和数据一致性,不需要引入分布式事务与跨服务部署成本。
|
||||
- **继续把 workspace 作为恢复来源**:拒绝。工作区属于可清理计算现场,不能成为最终事实源。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [模块解耦架构](../system-design/2026-09-14-module-decoupling-architecture.md)
|
||||
- [模块解耦整改与重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md)
|
||||
- [方案复核](../audit/2026-09-14-module-decoupling-plan-review.md)
|
||||
- [文档版本边界 ADR](./2026-09-13-document-version-boundary.md)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "模块解耦架构与重构计划修订"
|
||||
status: done
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
owner: Codex
|
||||
branch: feature/module-decoupling-remediation
|
||||
worktree: .worktrees/feature/module-decoupling-remediation
|
||||
mutex: architecture-docs
|
||||
---
|
||||
|
||||
# 模块解耦架构与重构计划修订
|
||||
|
||||
## 目标
|
||||
|
||||
把模块解耦方案修订为可以分段实施、验证和回滚的架构与重构计划,补齐事务、并发互斥、授权、可靠事件、FAV、数据迁移和真实页面验收。
|
||||
|
||||
## 范围
|
||||
|
||||
- 修订模块化单体架构设计与 ADR。
|
||||
- 新增详细整改与重构计划。
|
||||
- 建立首个文档可靠性纵向试点及后续里程碑任务。
|
||||
- 更新 CURRENT、FEATURE-MATRIX 和相关索引。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 本任务不修改业务代码、数据库迁移或部署配置。
|
||||
- 本任务不合并 `feature/final-artifact-vault`,只定义其集成依赖和边界。
|
||||
- 本任务不执行共享容器验证,也不占用集成槽位。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 对照当前源码、已接受 ADR、FAV 分支和外部权威资料复核原方案。
|
||||
2. [x] 修订架构设计与 ADR。
|
||||
3. [x] 编写详细整改计划、里程碑任务和验收清单。
|
||||
4. [x] 更新协作 SSOT 与索引并完成文档一致性检查。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 计划明确事务所有权、锁顺序、事件投递语义、授权主体和恢复边界。
|
||||
- [x] 首个独立交付边界直接覆盖受控文档刷新、重启、投影缺失和授权负例。
|
||||
- [x] 每个后续里程碑有依赖、文件范围、并发风险、测试矩阵、停走门和回滚边界。
|
||||
- [x] FAV、迁移语料库和完整备份的职责不重叠,工作区不再承担最终成果事实源。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist: [../../ops/checklists/2026-09-14-module-decoupling-refactor-acceptance.md](../../ops/checklists/2026-09-14-module-decoupling-refactor-acceptance.md)
|
||||
- Handoff: 不需要;实现阶段使用两个 planned short-term 接续
|
||||
- Test report: 本任务仅产出计划,不生成业务验收报告
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
- 原方案位于主工作区未跟踪文件;本任务在独立 worktree 重建并修订,未覆盖原工作区。
|
||||
- `feature/final-artifact-vault` 已有实现与测试证据,但尚未合入本任务基线,实施前必须重新审计分支差异。
|
||||
- 文档校验:本地相对链接、R/KTD/U ID 唯一性、Mermaid fence 和 `git diff --check` 均通过。
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "arch-check U1 可重放基线"
|
||||
status: done
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
severity: medium
|
||||
---
|
||||
|
||||
# arch-check U1 可重放基线
|
||||
|
||||
## 现象
|
||||
|
||||
Wave 0 的全仓 grep 会把 Goose、`*_test.go` 和路径字面量一视同仁,无法作为生产写者门禁。U1 用 `go list` + AST/正则拆成四类规则,并给每条既有债写明 owner、原因、截止里程碑和到期日。
|
||||
|
||||
## 根因分析
|
||||
|
||||
- `document_records` 的生产写者不止 `docgov`:`docrevision.projectRootPointersTX` 与 `docduty` 职责槽位仍直接 `UPDATE`。
|
||||
- `output.md` / `data/dev` / `workspaces/` 仍被 agent、bizdev、coordinator、docgov、docrevision、server、storage 用 `filepath.Join`、字符串字面量或间接常量构造。
|
||||
- Goose 与测试夹具大量出现同样表名,不能算生产写者债。
|
||||
|
||||
## 严重度
|
||||
|
||||
`medium`
|
||||
|
||||
## 关联整改
|
||||
|
||||
- ADR: [../adr/2026-09-14-modular-monolith-decoupling.md](../adr/2026-09-14-modular-monolith-decoupling.md)
|
||||
- 设计: [../system-design/2026-09-14-module-decoupling-architecture.md](../system-design/2026-09-14-module-decoupling-architecture.md)
|
||||
- 边界: [../system-design/module-boundaries.yaml](../system-design/module-boundaries.yaml)
|
||||
- 任务: [../ops/short-term/2026-09-14-document-reliability-pilot.md](../ops/short-term/2026-09-14-document-reliability-pilot.md)
|
||||
|
||||
## 证据
|
||||
|
||||
命令(2026-09-14,`refactor/document-reliability-pilot` worktree):
|
||||
|
||||
```
|
||||
./scripts/arch-check.sh --mode warn
|
||||
go test ./internal/archtest -count=1
|
||||
```
|
||||
|
||||
可重放计数(只含生产 Go;Goose/`*_test.go` 不计):
|
||||
|
||||
| 规则 | 计数 | 处理 |
|
||||
|------|------|------|
|
||||
| import | 0 | 相对 2026-09-14 production `go list` allowlist,无新边 |
|
||||
| sql_write | 5 | 例外,U3/U8 到期 |
|
||||
| path | 17 | 例外,U4/U5/U8 到期 |
|
||||
| event | 0 | `OnOutbox` 生产者属主为 docrevision,server/notification 为消费者 |
|
||||
|
||||
验证:
|
||||
|
||||
- `deploy/postgres/goose` 与 `*_test.go` 出现 `document_records` 不报生产写者违规。
|
||||
- 迷你仓 `internal/server/rogue_write.go` 新增 `UPDATE document_records` 时 `go test ./internal/archtest` 失败并指出文件。
|
||||
- `filepath.Join`、字符串拼接、间接常量构造 `output.md` 均可检出。
|
||||
- 无到期日例外、过期例外立即 block。
|
||||
- `baseline_counts` 锁定现有计数,只能下降。
|
||||
|
||||
`--mode block` 在例外清零前会失败,不得挂进集成冒烟。
|
||||
|
||||
## 开放问题
|
||||
|
||||
- U3 收口 `document_records` / `freeform_documents` 跨属主写。
|
||||
- U4/U5 收口 handler 与投影路径构造。
|
||||
- U8 收口 `task_runs` 终止写与 finalize `output.md`。
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: "模块解耦方案复核"
|
||||
status: done
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
scope: architecture, transactions, concurrency, authorization, events, artifact recovery
|
||||
---
|
||||
|
||||
# 模块解耦方案复核
|
||||
|
||||
## 结论
|
||||
|
||||
模块化单体、单一写者、瘦传输层和应用编排层的方向成立,但原方案不能直接批准 Wave 1–6。
|
||||
原方案把事务、事件、工作区和最终成果四条边界拆开设计,可能在包依赖减少后产生新的数据不一致、跨租户授权旁路和不可恢复投影。
|
||||
整改应先打通一条真实文档纵向链路,再按证据决定是否扩展到 chat、squad、SSE 和 PI Runner。
|
||||
|
||||
## 已确认问题
|
||||
|
||||
### A1. 单一写者拆分会破坏跨表原子性
|
||||
|
||||
`internal/docrevision/pg_mutate.go` 当前在一个事务中写版本账本并投影 `document_records` 根指针。
|
||||
原方案要求 docrevision 改调 docgov/freedoc 普通端口,却没有定义跨属主事务或命令所有权。
|
||||
如果各端口各自提交,会出现版本账本、正文 head、治理状态和 outbox 部分成功。
|
||||
|
||||
### A2. 现有 outbox 不是可泛化的可靠事件总线
|
||||
|
||||
`internal/docrevision/service.go` 在版本事务完成后调用 `OnOutbox`,并忽略回调错误。
|
||||
`outbox_events` 只有全局 `published_at`,没有逐消费者确认、租约、重试、死信和幂等收件箱。
|
||||
拟议的 `Publish(ctx, event)` 也没有事务作用域,不能兑现“与业务同事务”。
|
||||
|
||||
### A3. 工作区仍被当成事实源
|
||||
|
||||
原 `WorkspaceRef` 暴露 `Root`、`OutputDir` 和 `ManifestPath`,`OutputContract` 继续接受裸路径。
|
||||
这不能从类型上阻止路径拼接、跨工作区访问或链接逃逸。
|
||||
docquery 若继续在运行时从 `output.md` 选择正文,会保留导致历史 404/409 的 split-brain 根因。
|
||||
|
||||
### A4. 最终成果保险库未进入模块边界
|
||||
|
||||
`feature/final-artifact-vault` 已设计并实现终态归档、只读降级和 scratch 恢复,但尚未合入当前 dev 基线。
|
||||
原解耦方案没有为 artifactvault 分配端口、事件、授权和恢复边界,后续容易由 docquery 或 CLI 重建旁路。
|
||||
|
||||
### A5. contracts 可能成为新的高扇入万能包
|
||||
|
||||
原方案把内部接口、HTTP DTO、事件、错误码和共享枚举集中到一个包,并要求所有 DTO 版本化。
|
||||
这会把语义耦合搬到 `contracts` 和 `app`,使普通内部调整承担不必要的 ADR 成本。
|
||||
|
||||
### A6. 并发控制只覆盖了进程内互斥
|
||||
|
||||
当前代码同时存在 `sync.Mutex`、行锁、乐观 ETag、唯一幂等键和 `pg_advisory_xact_lock`。
|
||||
原方案没有规定哪些锁用于正确性、锁顺序、超时、重试边界和多副本行为。
|
||||
进程内锁只能优化单实例竞争,不能作为跨实例正确性条件。
|
||||
|
||||
### A7. 授权主体和后台执行边界缺失
|
||||
|
||||
“在应用层入口统一授权”没有定义可信 Principal 的来源,也没有覆盖 CLI、事件订阅者、恢复工具和后台任务。
|
||||
若端口继续接受调用方填写的 `UserID`、`ProjectID` 或裸 `DocRef`,可能形成 confused-deputy 或跨项目访问。
|
||||
|
||||
### A8. 横向 Wave 缺少最小可交付边界
|
||||
|
||||
原方案先物理搬包,再接口化,最后才处理单写者和事件语义。
|
||||
这会重复修改高扇出代码,并在真正验证边界之前冻结大量无消费者契约。
|
||||
错误信封和 PI Runner 协议与文档一致性解耦没有直接依赖,不应阻塞核心路线。
|
||||
|
||||
### A9. 迁移与回滚承诺不成立
|
||||
|
||||
可靠多消费者 outbox、FAV 集成和必要的状态字段需要增量 migration,因此“数据零迁移”不成立。
|
||||
后续里程碑会依赖前序契约,不能任意独立 revert;只能按 expand/migrate/contract 阶段和兼容窗口回退。
|
||||
|
||||
### A10. 架构检查范围会产生误报
|
||||
|
||||
表名和路径字面量合法存在于 Goose migration、fixture、测试和运维脚本。
|
||||
R5/R6 应检查生产运行时的越权写入和路径解析,不应以全仓 grep 的零出现作为完成条件。
|
||||
|
||||
## 外部规则核对
|
||||
|
||||
- PostgreSQL 行锁和事务级 advisory lock 会在事务结束时释放,适合短期业务互斥;session 级 advisory lock 不跟随回滚,不用于本方案的业务锁。[PostgreSQL explicit locking](https://www.postgresql.org/docs/17/explicit-locking.html)
|
||||
- `SKIP LOCKED` 适合多消费者队列表,不适合作为普通一致性查询语义。[PostgreSQL SELECT](https://www.postgresql.org/docs/current/sql-select.html)
|
||||
- Serializable 或 deadlock 重试必须重跑完整事务,包括决定 SQL 和参数的业务逻辑。[PostgreSQL serialization failure handling](https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html)
|
||||
- 授权必须默认拒绝,并在每次请求或后台动作上重新校验,不能信任客户端传入的身份和资源归属。[OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
|
||||
|
||||
## 整改结论
|
||||
|
||||
1. 暂停批准原 Wave 1–6,保留只读架构基线。
|
||||
2. 先定义事务、锁、授权、事件和持久成果契约。
|
||||
3. 首个交付只覆盖受控文档读取与终态提交纵向链。
|
||||
4. FAV、迁移语料和完整备份作为三个互补层接入。
|
||||
5. 通过真实页面刷新、重启、并发、权限和恢复停走门后,再批准其他模块拆分。
|
||||
|
||||
## 证据入口
|
||||
|
||||
- `internal/docrevision/pg_mutate.go`
|
||||
- `internal/docrevision/service.go`
|
||||
- `internal/workspace/resolver.go`
|
||||
- `internal/docduty/revoke.go`
|
||||
- `internal/taskrun/manager.go`
|
||||
- `deploy/postgres/goose/00018_2026-09-12-outbox-notifications-stage-advance.sql`
|
||||
- `.doc/adr/2026-09-13-document-version-boundary.md`
|
||||
- `feature/final-artifact-vault` 分支中的 FAV ADR、整改方案与测试报告
|
||||
@@ -13,6 +13,20 @@
|
||||
|
||||
## 核心文档(按阅读顺序推荐)
|
||||
|
||||
### arch-check U1 可重放基线(2026-09-14)
|
||||
|
||||
**文件**: [2026-09-14-arch-check-baseline.md](./2026-09-14-arch-check-baseline.md)
|
||||
|
||||
**结论**: 四类规则(production import / SQL write / path / event)可重放。import=0,sql_write=5,path=17,event=0。Goose 与测试不计生产写者。新违规与过期例外立即 block。
|
||||
|
||||
### 模块解耦方案复核(2026-09-14)
|
||||
|
||||
**文件**: [2026-09-14-module-decoupling-plan-review.md](./2026-09-14-module-decoupling-plan-review.md)
|
||||
|
||||
**结论**: 模块化单体方向成立,但原横向 Wave 缺少跨属主事务、可靠事件、多副本互斥、逐动作授权和 FAV 边界;应先完成受控文档可靠性纵向试点,再按证据扩面。
|
||||
|
||||
**整改**: [模块解耦与文档可靠性重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md)
|
||||
|
||||
### 项目 143 workflow-plan 409 与三权全有或全无(2026-09-14)
|
||||
|
||||
**文件**: [2026-09-14-workflow-plan-409-frozen-and-optional-duties.md](./2026-09-14-workflow-plan-409-frozen-and-optional-duties.md)
|
||||
|
||||
+10
-3
@@ -5,13 +5,16 @@
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **CHAT-INTENT**: [对话意图运行时状态](../audit/2026-09-13-conversation-intent-runtime-state.md)(`fix/conversation-intent-state`;与文档账本运行时无关)
|
||||
2. **CHAT**: [对话历史刷新](../audit/2026-09-13-chat-history-refresh-disappears.md)(认领仍 `in_progress`;gap 行已 PASS,以代码/handoff 为准收敛)
|
||||
1. **DOC-RELIABILITY**: [M1 活验签字与条件 M2](../ops/short-term/2026-09-14-document-reliability-m1-live-and-m2.md)(`refactor/document-reliability-pilot`;`mutex=docgov`;占集成槽 live 签字)
|
||||
2. **CHAT-INTENT**: [对话意图运行时状态](../audit/2026-09-13-conversation-intent-runtime-state.md)(`fix/conversation-intent-state`;与文档账本运行时无关)
|
||||
3. **CHAT**: [对话历史刷新](../audit/2026-09-13-chat-history-refresh-disappears.md)(认领仍 `in_progress`;gap 行已 PASS,以代码/handoff 为准收敛)
|
||||
|
||||
## 并行认领
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | document-reliability-m1-live-and-m2 | refactor/document-reliability-pilot | .worktrees/refactor-document-reliability-pilot | docgov | in_progress |
|
||||
| Codex | module-decoupling-remediation-plan | feature/module-decoupling-remediation | .worktrees/feature/module-decoupling-remediation | architecture-docs | done |
|
||||
| verifier_antigravity | controlled-content-ui-acceptance | fix/controlled-content-ui-acceptance | .worktrees/fix-controlled-content-ui-acceptance | document-content-ui-acceptance | done |
|
||||
| implementer_cursor | controlled-content-canonical-hash | fix/controlled-content-canonical-hash | .worktrees/fix-controlled-content-canonical-hash | docgov | done |
|
||||
| Codex | project143-controlled-content-404-diagnosis | fix/project143-controlled-content-404-diagnosis | - | docgov-content-diagnosis | done |
|
||||
@@ -51,12 +54,13 @@
|
||||
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
|
||||
|
||||
> workflow-plan 409 与治理鉴权 G1–G5 已收口(见 [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md))。禁止终止或改写项目 143。
|
||||
> `document-reliability-pilot` 占 `docgov` + 集成槽做 M1 活验。禁止 worktree 内 `env-service`。项目 143 只读,禁止终止。
|
||||
|
||||
## 集成槽位
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | - | - | - |
|
||||
| Cursor | merging | 2026-09-14 | DOC-RELIABILITY M1 live | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
@@ -84,6 +88,9 @@
|
||||
| DOCREV 管理测试 | [../test-reports/2026-09-13-document-revision-management-test-report.md](../test-reports/2026-09-13-document-revision-management-test-report.md) |
|
||||
| DOCREV 运行时 UI Round20 | [../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md) |
|
||||
| 纯软件项目自举设计 | [../system-design/2026-09-14-software-project-bootstrap-workflow.md](../system-design/2026-09-14-software-project-bootstrap-workflow.md) |
|
||||
| 模块化单体解耦架构 | [../system-design/2026-09-14-module-decoupling-architecture.md](../system-design/2026-09-14-module-decoupling-architecture.md) |
|
||||
| 模块解耦整改计划 | [../remediation/2026-09-14-module-decoupling-refactor-plan.md](../remediation/2026-09-14-module-decoupling-refactor-plan.md) |
|
||||
| 文档可靠性 M1 报告 | [../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) |
|
||||
| SSE/幂等审计 | [../audit/2026-09-13-document-revision-sse-idempotency.md](../audit/2026-09-13-document-revision-sse-idempotency.md) |
|
||||
| 质量门禁自污染审计 | [../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) |
|
||||
| 当前文档路由审计 | [../audit/2026-09-13-document-revision-active-doc-routing.md](../audit/2026-09-13-document-revision-active-doc-routing.md) |
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "模块解耦重构验收清单"
|
||||
status: in_progress
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
owner: Cursor
|
||||
---
|
||||
|
||||
# 模块解耦重构验收清单
|
||||
|
||||
## M0 架构与基线
|
||||
|
||||
- [x] contracts 分类、事务 owner、表属主、锁顺序、事件信封、Principal 和 FAV 边界均有唯一规范来源。
|
||||
- [x] arch-check 分离 production import、SQL write、path 和 event registration;例外有 owner 与到期日。
|
||||
- [x] 当前违规基线可重复生成,新违规立即阻断。
|
||||
|
||||
## M1 文档可靠性
|
||||
|
||||
- [x] 人工编辑、agent commit、approve/reject 的跨表写入同事务(U3;隔离库双会话编辑已测)。
|
||||
- [x] fault injection 任一点失败均无部分提交(U2 隔离库)。
|
||||
- [x] 同 ETag 并发写只有一个赢家(内存 + 双会话)。
|
||||
- [ ] 1、2、4 app replicas 结果一致。
|
||||
- [x] GET 服务端零写入 + typed consistency(U4 单测)。refresh/reload 真页面未测。
|
||||
- [ ] 项目 143 等价 hazard 与新项目真实页面刷新、重登、restart、rebuild PASS。
|
||||
- [x] 服务端负例 fail closed;浏览器跨用户未测。
|
||||
|
||||
## M2 事件与 FAV
|
||||
|
||||
- [ ] 业务终态、staging 和 outbox 同事务。
|
||||
- [ ] 每个 consumer 有独立 delivery/inbox/attempt/lease/dead-letter。
|
||||
- [ ] 1、2、4 dispatcher 无重复可见副作用和永久饥饿。
|
||||
- [ ] crash-before/after-side-effect/ack 全部可恢复。
|
||||
- [ ] stale fencing、跨项目事件、错误 producer、坏 digest 均拒绝。
|
||||
- [ ] FAV 中间结果、Prompt、工具轨迹和 secret 准入率为零。
|
||||
- [ ] DB 授权成功后才允许 degraded read;授权图缺失 fail closed。
|
||||
- [ ] tombstone/删除账本 union-deny PASS。
|
||||
|
||||
## M3 TaskRun、workspace 与数据生命周期
|
||||
|
||||
- [ ] RunManifest 规范字节、SHA、成员和 storage identity 持久化。
|
||||
- [ ] 重复 finalize、SSE 断线和 cancel race 不重复消息或 artifact。
|
||||
- [ ] WorkspaceRef 无物理路径,路径遍历和链接逃逸负例 PASS。
|
||||
- [ ] 旧 lease writer 被 fencing token 拒绝。
|
||||
- [ ] workspace 清空后最终成果仍可定位和校验。
|
||||
- [ ] backfill 可暂停、续跑和重放;异常进入 quarantine。
|
||||
- [ ] 八条 migration/reload/recovery 轨道 PASS。
|
||||
- [ ] 数据销毁前完整备份、corpus、FAV coverage 和人工批准全部满足。
|
||||
|
||||
## M4–M6 选择性扩展
|
||||
|
||||
- [ ] 每个子切片在原包边界先获得行为收益,再决定物理搬包。
|
||||
- [ ] chatlog/contextasm 保持授权可见集合和计数一致。
|
||||
- [ ] app 是唯一组合根,subscriber 重复启动不重复注册。
|
||||
- [ ] UserProjectContext 不再被多个模块字段级直改。
|
||||
- [ ] server 无 SQL、路径拼接和业务闭包。
|
||||
- [ ] 对应架构规则全部 block,过期例外和 flags 已删除。
|
||||
|
||||
## 证据分层
|
||||
|
||||
- [x] Unit/stub 证据。
|
||||
- [x] PostgreSQL integration 证据(隔离库,非共享 env 库)。
|
||||
- [x] race/concurrency 目标包证据(非全仓 race)。
|
||||
- [x] fault injection 证据(U2)。
|
||||
- [ ] 真实 PI 证据。
|
||||
- [ ] 真实浏览器证据。
|
||||
- [ ] restart/rebuild 证据。
|
||||
- [x] cross-user/project authorization negative 服务端证据。
|
||||
- [ ] scratch recovery 与完整 DB restore 证据。
|
||||
|
||||
## 关闭条件
|
||||
|
||||
- [ ] test report 回链具体 U-ID、commit、环境 identity 和 fixture/dataset。
|
||||
- [ ] CURRENT、FEATURE-MATRIX、gap-matrix 与任务状态一致。
|
||||
- [ ] 未实施后续仍为 planned/deferred,不用局部测试冒充全计划完成。
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "选择性模块解耦 M4–M6"
|
||||
status: planned
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
owner: TBD
|
||||
mutex: architecture
|
||||
review_cycle: milestone
|
||||
---
|
||||
|
||||
# 选择性模块解耦 M4–M6
|
||||
|
||||
## 目标
|
||||
|
||||
在文档可靠性、可靠事件和 FAV 已证明收益后,按 U10–U12 分别收敛 chatlog/contextasm、应用组合根、共享上下文和传输层。
|
||||
|
||||
## 为什么是长期任务
|
||||
|
||||
这些改动不直接修复当前受控正文事实源问题,且会触及仍在进行的 chat/intent 工作。
|
||||
M1–M3 未通过前执行会扩大冲突面,并提前冻结未经真实消费者验证的契约。
|
||||
|
||||
## 触发条件
|
||||
|
||||
- M1–M3 的 test report 全部 PASS。
|
||||
- 同类缺陷触达文件数和运行故障率表明继续解耦具有收益。
|
||||
- `chat`、`intent`、`bizdev/coordinator`、`server/stream` mutex 按子切片释放。
|
||||
- 每个子切片有独立 owner、回滚点和行为验收,不批准一个巨型 Wave。
|
||||
|
||||
## 候选子任务
|
||||
|
||||
1. U10 chatlog/contextasm 原地端口化。
|
||||
2. U11a 唯一 app composition root。
|
||||
3. U11b 按事件逐个替换闭包桥。
|
||||
4. U11c 逐 use case 迁移 ExecuteTask/executeStageInternal。
|
||||
5. U11d UserProjectContext snapshot/update 收权。
|
||||
6. U12 server/SSE 边界和架构门禁收口。
|
||||
|
||||
## 不包含
|
||||
|
||||
- 错误信封统一。
|
||||
- PI Runner result.json 协议。
|
||||
- 微服务拆分。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [模块解耦整改与重构计划](../../remediation/2026-09-14-module-decoupling-refactor-plan.md)
|
||||
- [模块化单体解耦架构](../../system-design/2026-09-14-module-decoupling-architecture.md)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "文档可靠性 M1 活验签字与条件 M2"
|
||||
status: in_progress
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
owner: Cursor
|
||||
branch: refactor/document-reliability-pilot
|
||||
worktree: .worktrees/refactor-document-reliability-pilot
|
||||
mutex: docgov
|
||||
---
|
||||
|
||||
# 文档可靠性 M1 活验签字与条件 M2
|
||||
|
||||
## 目标
|
||||
|
||||
占集成槽把试点合入本地 `dev` 并 rebuild,补齐真实 PI / 浏览器 / 跨用户 / 多会话签字。仅当新报告最后一句为「继续 M2」时开工 U5–U7。
|
||||
|
||||
## 范围
|
||||
|
||||
- A1 占槽、合入、rebuild、health/ready
|
||||
- A2 143 只读 + 新建项目页面 + fail-closed
|
||||
- A3 真实 pi-agent 编制后刷新
|
||||
- A4 隔离库双 pool / 4 会话
|
||||
- A5 活验 go/no-go
|
||||
- B 仅 go 后:U5–U7
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不终止项目 143
|
||||
- 未 go 不开 outbox/FAV
|
||||
- 不拆 chat/squad
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [ ] 占集成槽并合入本地 dev rebuild
|
||||
2. [ ] 浏览器 + 跨用户
|
||||
3. [ ] 真实 PI
|
||||
4. [ ] 双 pool/4 会话
|
||||
5. [ ] 活验报告
|
||||
6. [ ] 条件 M2
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] health + ready 来自本分支镜像
|
||||
- [ ] 143/新项目刷新 404/409/projection-missing = 0 或报告停止扩面
|
||||
- [ ] 报告最后一句只能是继续 M2 或停止扩面
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist: [../checklists/2026-09-14-module-decoupling-refactor-acceptance.md](../checklists/2026-09-14-module-decoupling-refactor-acceptance.md)
|
||||
- Prior report: [../../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../../test-reports/2026-09-14-document-reliability-pilot-test-report.md)
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "文档可靠性纵向试点 M0–M1"
|
||||
status: blocked
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
owner: Cursor
|
||||
branch: refactor/document-reliability-pilot
|
||||
worktree: .worktrees/refactor-document-reliability-pilot
|
||||
mutex: docgov
|
||||
---
|
||||
|
||||
# 文档可靠性纵向试点 M0–M1
|
||||
|
||||
## 目标
|
||||
|
||||
在不横向搬包的前提下,完成 U1–U4,使受控文档在并发提交、页面刷新、重登、restart、rebuild 和 projection 缺失后仍从数据库账本返回唯一授权正文。
|
||||
|
||||
## 范围
|
||||
|
||||
- U1 架构基线与 production writer/path 门禁。
|
||||
- U2 应用 UnitOfWork 和跨属主事务能力。
|
||||
- U3 人工编辑、agent commit、批准/驳回事务化。
|
||||
- U4 授权后的文档统一读模型。
|
||||
- 项目 143 等价脱敏 hazard 与新项目真实 PI/UI 验收。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不启用 FAV degraded read。
|
||||
- 不拆 chatlog/contextasm/squadrun 包。
|
||||
- 不统一错误信封,不修改 PI Runner 协议。
|
||||
- 不清理历史 dev 数据。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 从最新 `dev`(`a8f9451`)创建实现分支,登记 CURRENT 并确认 `docgov` mutex 已释放。
|
||||
2. [x] U1:四类规则 arch-check + archtest + 可重放基线(新违规 block)。
|
||||
3. [x] 实现 U2,验证任意属主写入/commit 故障整体回滚。
|
||||
4. [x] 实现 U3,删除提交后 outbox 正确性回调。
|
||||
5. [x] 实现 U4,保证 GET 零写入和 projection 缺失不再伪装成 404。
|
||||
6. [x] 隔离库 PG + 目标包 race 已跑;真实 PI / 浏览器 / rebuild **未**签字(未占槽)。
|
||||
7. [x] 写 test report:结论 **停止扩面**。更新验收清单、FEATURE-MATRIX、CURRENT 和 gap-matrix。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 同一 ETag 并发编辑只有一个合法赢家(内存 8 协程 + 隔离库双会话)。
|
||||
- [x] 任一事务故障点不会留下部分提交(U2 隔离库)。
|
||||
- [ ] 项目 143 等价 hazard 和新项目刷新/重启目标 404/409/projection missing 为 0(真实页面未测)。
|
||||
- [x] 服务端匿名/未知文档/跨项目/错误责任人 fail closed;浏览器跨登录未测。
|
||||
- [ ] 1、2、4 个 app 实例的最终状态一致(仅到双会话)。
|
||||
- [x] M1 test report 结论:**停止扩面**。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist: [../checklists/2026-09-14-module-decoupling-refactor-acceptance.md](../checklists/2026-09-14-module-decoupling-refactor-acceptance.md)
|
||||
- Test report: [../../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../../test-reports/2026-09-14-document-reliability-pilot-test-report.md)
|
||||
- Plan: [../../remediation/2026-09-14-module-decoupling-refactor-plan.md](../../remediation/2026-09-14-module-decoupling-refactor-plan.md)
|
||||
|
||||
## M2+ 门(不开工)
|
||||
|
||||
U5 WorkspaceRef、U6 outbox、U7 按文件摘 FAV 需要独立审批,且仅当 M1 报告最后一句为「继续 M2」。当前是「停止扩面」,禁止实现 U5–U12。
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
隔离库与目标包 race 见 test-report assets。未占槽、未 rebuild。只有真实 PI/浏览器/rebuild 补测并把报告改为「继续 M2」后,才允许启动 durable event/FAV。
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "可靠事件与 FAV 集成 M2–M3"
|
||||
status: planned
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
owner: TBD
|
||||
branch: refactor/durable-event-fav-integration
|
||||
worktree: "-"
|
||||
mutex: event-delivery
|
||||
---
|
||||
|
||||
# 可靠事件与 FAV 集成 M2–M3
|
||||
|
||||
## 目标
|
||||
|
||||
完成 U5–U9,使投影、通知、RunManifest 和最终成果在多 worker、崩溃、重放和 workspace 清理后仍可恢复且不越权。
|
||||
|
||||
## 范围
|
||||
|
||||
- opaque workspace capability、lease 和 fencing。
|
||||
- durable outbox、逐消费者 delivery/inbox、重试和 dead letter。
|
||||
- 审计并集成 `feature/final-artifact-vault` 的适用实现。
|
||||
- taskrun finalize、RunManifest 持久化、FAV coverage 和迁移 corpus。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不盲目整分支 cherry-pick FAV。
|
||||
- 不启用跨授权图的 vault 读取。
|
||||
- 不自动销毁 dev/main 数据。
|
||||
- 不执行 U10–U12。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [ ] 读取 M1 test report,确认继续 M2 的 go/no-go 已通过。
|
||||
2. [ ] 重新分配 Goose 序号并审计 U6/FAV schema 组合。
|
||||
3. [ ] 实现 U5–U6,多 app/worker 和 crash matrix 先通过。
|
||||
4. [ ] 逐文件审计 FAV 分支,按 U7 边界迁移合格实现。
|
||||
5. [ ] 实现 U8,收敛 taskrun terminal finalize 与 RunManifest。
|
||||
6. [ ] 实现 U9 的 checkpoint backfill、corpus、quarantine 和 cleanup gates。
|
||||
7. [ ] 完成真实 PI、浏览器、workspace 清理、scratch recovery 和 rebuild 演练。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 多订阅者有独立 delivery,失败者不阻塞其他订阅者。
|
||||
- [ ] worker crash/retry/replay 不造成重复可见副作用。
|
||||
- [ ] workspace 清空后,verified 最终文档/回复仍可定位并校验。
|
||||
- [ ] 授权图缺失、跨项目、tombstone、坏 digest 和 stale fencing 全部拒绝。
|
||||
- [ ] clean/oldest/previous/hazard/import/reload/rollback/artifact recovery 八轨有独立证据。
|
||||
- [ ] 任何数据销毁仍需完整备份、corpus、FAV coverage 和用户批准。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist: [../checklists/2026-09-14-module-decoupling-refactor-acceptance.md](../checklists/2026-09-14-module-decoupling-refactor-acceptance.md)
|
||||
- Handoff: 实施时创建
|
||||
- Test report: 实施后按 M2、M3 分开创建
|
||||
- Plan: [../../remediation/2026-09-14-module-decoupling-refactor-plan.md](../../remediation/2026-09-14-module-decoupling-refactor-plan.md)
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
本任务依赖 M1,不得与 M1 同时修改 docgov/docrevision 的事务主链。
|
||||
@@ -0,0 +1,677 @@
|
||||
---
|
||||
title: "模块解耦与文档可靠性重构计划"
|
||||
status: planned
|
||||
created: 2026-09-14
|
||||
updated: 2026-09-14
|
||||
type: refactor
|
||||
owner: TBD
|
||||
deepened: 2026-09-14
|
||||
---
|
||||
|
||||
# 模块解耦与文档可靠性重构计划
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
- **Objective:** 项目文档、任务最终产物和最终智能体回复在并发写入、页面刷新、服务重启、工作区清理和受控恢复后仍能按权限定位到唯一、可校验的内容。
|
||||
- **Means:** 采用模块化单体、纵向绞杀、应用 UnitOfWork、数据库互斥、transactional outbox、opaque workspace capability 和 FAV。
|
||||
- **Authority:** 本计划受 [模块化单体解耦 ADR](../adr/2026-09-14-modular-monolith-decoupling.md)、[文档版本边界 ADR](../adr/2026-09-13-document-version-boundary.md) 和 [工作流运行时权威 ADR](../adr/2026-09-12-workflow-runtime-authority.md) 约束。
|
||||
- **Stop condition:** 任一里程碑出现数据不变量无法在单事务维持、授权只能通过放宽实现、旧数据无法安全迁移,或真实页面故障率没有下降时,停止扩面并回到设计评审。
|
||||
- **Execution profile:** 高风险、跨模块、迁移和授权敏感;characterization-first,逐纵向切片发布。
|
||||
- **Delivery:** 每个里程碑独立 short-term、独立分支和独立验收报告;本计划本身不授权合并、部署或删数据。
|
||||
|
||||
---
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Summary
|
||||
|
||||
重构先解决文档事实源、事务与恢复链,再扩展其他模块。
|
||||
架构改造必须用用户可见的刷新、重启、并发和授权结果证明价值,不能只用包数量或代码行数证明完成。
|
||||
|
||||
### Problem Frame
|
||||
|
||||
系统当前同时依赖 PostgreSQL 账本、`document_records` 指针、workspace `output.md` 和若干内存状态。
|
||||
这些状态在提交、刷新和重启时没有统一事务与恢复协议,导致历史上出现正文 404/409、projection missing、缓存竞争和任务完成后文档仍为空。
|
||||
|
||||
原解耦方案提出 contracts、单一写者和事件总线,但没有解释单一写者如何维持现有跨表原子性,也没有为多个 event subscriber、跨实例互斥和 FAV 恢复定义可执行协议。
|
||||
|
||||
### Requirements
|
||||
|
||||
**事实源与持久成果**
|
||||
|
||||
- R1. PostgreSQL immutable ledger 是文档版本与业务关系的权威,workspace 只作为可重建投影。
|
||||
- R2. FAV 只归档服务端确认的终态文档、终态助手回复、类型化 RunManifest 和必要附件,中间结果准入率为零。
|
||||
- R3. 数据库授权图完整但正文不可用时,系统可按 feature flag 从已验证 FAV 只读降级;授权图不完整时必须拒绝。
|
||||
- R4. GET、刷新和重载不得隐式修复数据库、覆盖 head 或创建归档。
|
||||
|
||||
**事务与并发**
|
||||
|
||||
- R5. 一个业务命令只有一个事务 owner;业务状态、账本、当前指针及该里程碑已启用的 staging/outbox 必须原子提交。
|
||||
- R6. 单一写者只约束 SQL 和不变量所有权,不允许各属主各自提交导致跨表半完成。
|
||||
- R7. 多副本正确性必须依赖数据库行锁、事务级 advisory lock、唯一约束、expected version 和 fencing token;进程内 mutex 不能成为唯一正确性条件。
|
||||
- R8. 所有写命令必须具有 idempotency key、expected version 或两者;相同 key 不同 payload 必须返回冲突。
|
||||
- R9. 事务不得包含 LLM、PI Runner、对象存储、HTTP/SSE 或长文件 I/O。
|
||||
|
||||
**事件与恢复**
|
||||
|
||||
- R10. 持久事件采用至少一次投递、逐消费者 delivery、幂等 inbox、租约、fencing token、重试和死信。
|
||||
- R11. 同一聚合事件按 aggregate version 有序,不同聚合允许并发;失败消费者不阻塞其他消费者。
|
||||
- R12. 旧 workspace、旧数据库和 FAV 回填必须可断点续跑、可重复、可审计,异常进入 quarantine。
|
||||
- R13. 恢复只进入 scratch identity,promotion 需要系统管理员和数据责任人确认,并通过属主端口提交。
|
||||
|
||||
**授权与隔离**
|
||||
|
||||
- R14. HTTP、CLI、worker、event subscriber 和恢复工具必须使用服务端构造的 Principal,并按 action/resource/current state 逐动作授权。
|
||||
- R15. 客户端或事件 payload 中的 user、project、role、duty、backend 和路径不能作为授权事实。
|
||||
- R16. WorkspaceRef 只能暴露不可伪造 ID、scope 和 fencing token;物理路径不得跨模块传播。
|
||||
- R17. 路径解析必须拒绝绝对路径、`..`、跨项目 ID、symlink/hardlink 逃逸和 storage identity 不匹配。
|
||||
- R18. 事件、日志、metrics、manifest 和 result 文件不得包含 secret、认证材料、Prompt 或不必要正文。
|
||||
|
||||
**交付与验证**
|
||||
|
||||
- R19. 首个独立交付边界是受控文档可靠性纵向试点,未通过停走门前不启动横向包搬移。
|
||||
- R20. 每个里程碑必须分别提供 unit、PostgreSQL、并发/故障注入、真实 PI、真实浏览器、restart/rebuild 和跨用户负例证据;不适用项必须说明原因。
|
||||
- R21. 迁移采用 expand/migrate/contract,允许新的 Goose migration,禁止修改已发布 migration。
|
||||
- R22. 数据清理必须同时满足完整备份恢复、migration corpus、FAV coverage、精确删除 manifest 和用户批准,不得因“已有最终成果”跳过其他门禁。
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- 项目 143 等价脱敏 hazard 和新建项目两条链路在刷新、重登、restart 与 rebuild 后正文成功率为 100%,目标 404/409/projection missing 为 0。
|
||||
- 同一文档并发写只有一个合法赢家;失败请求不产生孤儿 snapshot、错 head、重复消息、重复通知或重复 artifact。
|
||||
- 每个应发布终态都存在 publish request;已验证 artifact 可在 workspace 删除后按稳定逻辑 ID 定位并校验 SHA。
|
||||
- 匿名、非成员、跨用户、跨项目、错误 duty、错误 stage、篡改事件和授权依赖失败全部 fail closed。
|
||||
- 1、2、4 个应用实例和 dispatcher 下,状态与副作用结果一致;性能阈值由基线报告确认后固化,不能通过降低一致性达标。
|
||||
- 旧 schema corpus、legacy-v0 workspace 和 previous-release corpus 均可升级、重载和回滚到最近兼容点。
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
**本计划包含**
|
||||
|
||||
- 文档、taskrun、workspace、artifactvault、可靠事件和授权基础设施。
|
||||
- chatlog/contextasm、squadrun、app 组合根和 UserProjectContext 的后续选择性扩展。
|
||||
- 数据生命周期、迁移 corpus、回填、quarantine 和 scratch 恢复的集成门禁。
|
||||
|
||||
**Deferred to Follow-Up Work**
|
||||
|
||||
- HTTP 错误信封统一。
|
||||
- PI Runner stdout/result.json 协议替换。
|
||||
- 全量 SSE transport 重写。
|
||||
- 微服务拆分和跨服务消息基础设施。
|
||||
- main 数据自动清理和未来 tenant 实体。
|
||||
|
||||
---
|
||||
|
||||
## Planning Contract
|
||||
|
||||
### Key Technical Decisions
|
||||
|
||||
- KTD1. **纵向绞杀优先。** 先在原包边界增加窄端口和适配器,迁完消费者并通过行为验收后再物理移动包。
|
||||
- KTD2. **事务 owner 与表属主分离。** 应用用例拥有事务,领域模块拥有 SQL;同一 `pgx.Tx` 通过不透明 TxScope 绑定 repository。
|
||||
- KTD3. **显式锁策略。** 普通状态转换使用 `READ COMMITTED`、行锁和条件更新;项目级不变量使用事务级 advisory lock;Serializable 只用于经过证明的复杂不变量。[PostgreSQL explicit locking](https://www.postgresql.org/docs/17/explicit-locking.html)
|
||||
- KTD4. **固定锁顺序。** project/workflow instance → document root → version/snapshot/turn → task run → staging/outbox/delivery。
|
||||
- KTD5. **事务外副作用。** 网络、LLM、对象存储、通知和投影由提交后的持久事件驱动。
|
||||
- KTD6. **至少一次事件。** outbox 不承诺 exactly-once;event ID、consumer inbox 和业务唯一约束共同提供幂等结果。
|
||||
- KTD7. **opaque workspace capability。** 跨模块不传物理路径,所有读写使用 scope-bound ID、artifact key 和 fencing token。
|
||||
- KTD8. **三层权威。** PostgreSQL 管业务关系与授权,workspace 管临时现场,FAV 管已验证终态 payload/manifest/receipt/tombstone。
|
||||
- KTD9. **授权逐动作重算。** Principal 来自受信适配器;状态改变在取得资源锁后重读当前授权事实,subscriber 的 actor 快照只用于审计。[OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
|
||||
- KTD10. **依赖感知回滚。** 每个里程碑只保证回到最近兼容点;进入 contract 阶段后须按逆依赖顺序回滚。
|
||||
- KTD11. **按证据收紧门禁。** arch-check 先记录基线,每条规则在对应纵向切片清零后转 block,不按日历统一切换。
|
||||
- KTD12. **FAV 分支先审后用。** `feature/final-artifact-vault` 的现有实现作为候选,不直接视为 dev 已有能力;集成前重放迁移、测试、安全和并发审计。
|
||||
|
||||
### High-Level Technical Design
|
||||
|
||||
#### Component topology
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Transport[HTTP SSE CLI] --> UseCases[application use cases]
|
||||
Jobs[workers and recovery] --> UseCases
|
||||
UseCases --> Policy[authorization policy]
|
||||
UseCases --> Unit[UnitOfWork]
|
||||
Unit --> DG[docgov owner]
|
||||
Unit --> DR[docrevision owner]
|
||||
Unit --> TR[taskrun owner]
|
||||
Unit --> OB[outbox and staging]
|
||||
UseCases --> WSC[workspace capability]
|
||||
OB --> Dispatch[delivery dispatcher]
|
||||
Dispatch --> Projection[projection subscriber]
|
||||
Dispatch --> AV[artifactvault subscriber]
|
||||
Dispatch --> Notify[notification subscriber]
|
||||
```
|
||||
|
||||
#### Write sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant A as App use case
|
||||
participant P as Policy
|
||||
participant DB as PostgreSQL
|
||||
participant O as Outbox worker
|
||||
participant V as FAV
|
||||
C->>A: command + idempotency + expected version
|
||||
A->>P: authorize principal/action/resource
|
||||
P-->>A: allow or deny
|
||||
A->>DB: begin and acquire locks in canonical order
|
||||
A->>DB: ledger + root + staging + outbox
|
||||
DB-->>A: commit
|
||||
A-->>C: committed result
|
||||
O->>DB: claim delivery
|
||||
O->>V: publish immutable artifact
|
||||
V-->>O: verified receipt
|
||||
O->>DB: idempotent ack
|
||||
```
|
||||
|
||||
#### Event delivery lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> leased
|
||||
leased --> delivered
|
||||
leased --> retryable_failed
|
||||
leased --> dead_letter
|
||||
retryable_failed --> leased
|
||||
leased --> pending: lease expired
|
||||
delivered --> [*]
|
||||
```
|
||||
|
||||
#### Migration lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Expand[Expand schema and compatible readers] --> Shadow[Shadow validation]
|
||||
Shadow --> Backfill[Checkpointed backfill]
|
||||
Backfill --> Switch[Switch writer]
|
||||
Switch --> Observe[Observe and restart test]
|
||||
Observe --> Contract{Go or rollback?}
|
||||
Contract -->|go| Remove[Remove old path]
|
||||
Contract -->|rollback| Previous[Return to prior compatible point]
|
||||
```
|
||||
|
||||
### Concurrency and Lock Matrix
|
||||
|
||||
| Resource/operation | Correctness primitive | Conflict result | Retry policy | Forbidden while held |
|
||||
|---|---|---|---|---|
|
||||
| Project duty/heal/revoke | transaction advisory lock keyed by project | wait then typed timeout | whole idempotent tx for deadlock only | network, LLM, filesystem copy |
|
||||
| Workflow stage advance | instance row lock + expected config revision | stale-state conflict | caller refresh; no blind retry | external execution |
|
||||
| Controlled document edit | document row + open version row + ETag | version conflict with current head metadata | user/client rebases | projection and notification |
|
||||
| Agent candidate commit | candidate/document/version locks + idempotency key | stale candidate or idempotency conflict | exact same request may replay | PI/LLM call |
|
||||
| TaskRun claim/finalize | unique idempotency + conditional status/version update | existing run or state conflict | same digest returns prior result | message/SSE side effect |
|
||||
| Workspace write | DB lease + fencing token + atomic file commit | stale lease/token | reacquire then re-resolve state | arbitrary path access |
|
||||
| Outbox delivery | row lease + `SKIP LOCKED` + fencing token | another worker owns item | retry at next schedule | subscriber work inside claim tx |
|
||||
| FAV publish | artifact idempotency unique key + no-replace object write | existing verified artifact or digest conflict | same digest resumes | reading mutable workspace |
|
||||
|
||||
### Authorization Matrix
|
||||
|
||||
| Surface | Principal source | Resource facts | Required checks | Failure behavior |
|
||||
|---|---|---|---|---|
|
||||
| HTTP/SSE | authenticated session/middleware | DB project/session/document/workflow | membership, owner, duty, stage, action | 401/403/typed conflict; no data |
|
||||
| CLI ops | service identity + operator session | explicit environment/dataset/resource manifest | capability, exact scope, dry-run where destructive | fail closed and audit |
|
||||
| Event subscriber | registered service identity; event actor for audit only | DB current aggregate + event scope/digest | producer allowlist, project/resource match, current policy | dead letter/quarantine |
|
||||
| FAV degraded read | current authenticated Principal | DB relation + vault manifest/tombstone | normal read permission first, then integrity | no fallback if auth graph incomplete |
|
||||
| Recovery promotion | recovery credential + admin + data steward approval | scratch identity + signed plan + conflict map | two-party approval, no unresolved conflicts | refuse promotion |
|
||||
| Agent backend | app-resolved BackendRef | project policy + InputBundle sensitivity | backend allowlist, least data, secret injection boundary | reject before dispatch |
|
||||
|
||||
### Delivery dependency graph
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
M0[M0 baseline and contract correction] --> M1[M1 document reliability pilot]
|
||||
M1 --> M2[M2 durable events and FAV integration]
|
||||
M2 --> M3[M3 taskrun and workspace finalization]
|
||||
M3 --> M4[M4 selective domain decoupling]
|
||||
M4 --> M5[M5 app composition and context ownership]
|
||||
M5 --> M6[M6 transport boundary cleanup]
|
||||
M2 --> D[Data corpus and lifecycle rollout]
|
||||
D --> M3
|
||||
```
|
||||
|
||||
### Assumptions
|
||||
|
||||
- 当前 dev 基线为 schema 33;任何实现分支必须在创建 migration 时重新查询最新序号。
|
||||
- `feature/final-artifact-vault` 尚未合 dev,现有代码与测试只能作为候选实现和证据来源。
|
||||
- 当前无独立 tenant 实体,权限范围沿用 environment + project membership/admin;未来 tenant 模型另立 ADR。
|
||||
- 性能容量目标需要在 M1 压测后固化;本计划先固定正确性和观测项,不伪造吞吐数字。
|
||||
|
||||
---
|
||||
|
||||
## Phased Delivery
|
||||
|
||||
| 里程碑 | 交付边界 | 预计范围 | 进入条件 | 退出/停走门 |
|
||||
|---|---|---|---|---|
|
||||
| M0 | 基线与契约纠偏 | 文档/门禁/characterization | 在途 mutex 不冲突 | 事务/锁/授权/事件/FAV 契约评审通过 |
|
||||
| M1 | 文档可靠性纵向试点 | docflow/docgov/docrevision/docquery | M0 完成,独占 docgov | 刷新、重启、并发、权限和故障注入全 PASS |
|
||||
| M2 | 持久事件与 FAV | outbox/delivery/artifactvault | M1 数据边界稳定 | 多 worker、重放、归档、降级读取和 tombstone PASS |
|
||||
| M3 | taskrun/workspace 终态 | taskrun/squadrun/workspace | M2 verified receipt 可用 | 清 workspace 后最终产物仍可定位,重复 finalize 无副作用 |
|
||||
| M4 | 选择性领域端口化 | chatlog/contextasm/squadrun | M1–M3 复盘证明收益 | 每个子切片独立回归;无纯搬包 |
|
||||
| M5 | 组合根与上下文收权 | app/chatflow/stageflow/UserProjectContext | M4 稳定 | 每个 use case 单 owner;闭包按事件逐个替换 |
|
||||
| M6 | 传输收口 | server/SSE | M5 完成 | server 无 SQL/编排/路径;R1–R7 对应规则全 block |
|
||||
|
||||
M0–M1 是首个批准范围。
|
||||
M2–M6 需要在前一里程碑验收报告后重新 go/no-go,不构成一次性整体授权。
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### Unit Index
|
||||
|
||||
| ID | 标题 | 主要文件 | 依赖 |
|
||||
|---|---|---|---|
|
||||
| U1 | 固化架构基线和门禁 | `scripts/arch-check.sh`, `.doc/system-design/module-boundaries.yaml` | 无 |
|
||||
| U2 | 建立应用 UnitOfWork | `internal/app/uow`, owner repositories | U1 |
|
||||
| U3 | 文档命令原子化 | docgov/docrevision/docflow | U2 |
|
||||
| U4 | 文档授权读模型 | docquery/server/authz | U1, U3 |
|
||||
| U5 | Workspace capability | workspace/squadrun | U1 |
|
||||
| U6 | Durable event delivery | outbox/dispatcher/inbox | U2 |
|
||||
| U7 | FAV 集成与降级读取 | artifactvault/docflow/docquery | U3, U4, U6 |
|
||||
| U8 | TaskRun/RunManifest 终态 | taskrun/squadrun/workspace | U5, U6, U7 |
|
||||
| U9 | 数据回填与迁移 corpus | dataset/FAV tooling | U6–U8 |
|
||||
| U10 | chatlog/contextasm 端口化 | ctxmgr/chat/contextasm | M1–M3 go/no-go |
|
||||
| U11 | 组合根和 use case 收权 | app/server/bizdev/coordinator | U10 |
|
||||
| U12 | 传输边界与最终门禁 | server/stream/CI | U11 |
|
||||
|
||||
### U1. 固化架构基线和可执行门禁
|
||||
|
||||
- **Goal:** 建立不会把现有违规合法化、也不会误报 migration/fixture 的边界基线。
|
||||
- **Requirements:** R19–R21;KTD1、KTD11。
|
||||
- **Dependencies:** 无;必须避开 `intent`、`chat` 等在途 mutex,当前只占 `arch`。
|
||||
- **Files:** `scripts/arch-check.sh`、`.doc/system-design/module-boundaries.yaml`、`internal/archtest/arch_test.go`、`internal/archtest/arch_test_test.go`、`.doc/audit/<date>-arch-check-baseline.md`。
|
||||
- **Approach:**
|
||||
1. 分离 production import、production SQL write、path construction、event producer/consumer 四类规则。
|
||||
2. 表名扫描只识别 production Go 中的写语句;Goose、测试、fixture 和受控运维脚本进入显式类别,不以全仓零出现为目标。
|
||||
3. 为每项既有违规记录 owner、原因、截止里程碑和到期日;新违规立即 block。
|
||||
4. 只为已完成纵向切片的规则切换 block,不能以“两周后统一 block”代替清零。
|
||||
- **Execution note:** 先对当前依赖和 SQL 写者生成可重复 characterization 报告,再改门禁。
|
||||
- **Patterns to follow:** `go list` 依赖图、现有 AGENTS mutex、Goose 不可改写规则。
|
||||
- **Test scenarios:**
|
||||
- 合法的 Goose/fixture 表名不触发生产写者违规。
|
||||
- 非属主 production 包新增 `UPDATE document_records` 时测试失败并定位文件。
|
||||
- 路径通过字符串拼接、`filepath.Join` 和间接常量构造时均被检测。
|
||||
- 无过期日例外、过期例外、新增越权 event producer 均阻断。
|
||||
- **Verification:** 基线报告可重放;新增违规阻断;现有违规数只能下降。
|
||||
|
||||
### U2. 建立应用 UnitOfWork 和事务能力
|
||||
|
||||
- **Goal:** 在单一写者边界内维持跨表业务不变量。
|
||||
- **Requirements:** R5–R9;KTD2–KTD5。
|
||||
- **Dependencies:** U1。
|
||||
- **Files:** `internal/app/uow/uow.go`、`internal/app/uow/pg_uow.go`、`internal/app/uow/pg_uow_test.go`、`internal/docgov/repository.go`、`internal/docrevision/repository.go`、`internal/taskrun/repository.go`、`internal/freedoc/repository.go`。
|
||||
- **Approach:**
|
||||
1. UnitOfWork 开启、提交和回滚唯一 `pgx.Tx`,向每个属主提供绑定该 transaction 的窄 repository capability。
|
||||
2. TxScope 不暴露通用 `Exec` 给应用层;只有属主 repository 能执行自己的 SQL。
|
||||
3. 禁止 repository 在已有 TxScope 内嵌套 transaction 或提前 commit。
|
||||
4. 实现统一的完整事务重试 wrapper,只重试明确的 serialization/deadlock 错误,并保持 idempotency context。
|
||||
5. 为锁超时、事务时长、重试和 rollback reason 加结构化指标,不记录业务正文。
|
||||
- **Test scenarios:**
|
||||
- 第二个属主写入失败时第一个属主写入回滚。
|
||||
- outbox/staging 写入失败时业务状态和账本均回滚。
|
||||
- commit 失败时不会返回成功或执行提交后副作用。
|
||||
- `40001`/`40P01` 重跑完整事务并最终只产生一个业务结果。
|
||||
- version conflict、permission denied、payload digest conflict 不进入自动重试。
|
||||
- 尝试跨属主执行任意 SQL 无法通过类型边界或架构测试。
|
||||
- **Verification:** fault injection 覆盖 begin、每个 owner write、outbox、commit;无部分提交。
|
||||
|
||||
### U3. 文档提交纵向事务化
|
||||
|
||||
- **Goal:** 将人工编辑、agent commit、批准/驳回和根指针投影收敛到文档用例事务。
|
||||
- **Requirements:** R1、R5–R9、R19;KTD2–KTD5。
|
||||
- **Dependencies:** U2;实施时独占 `docgov` mutex。
|
||||
- **Files:** `internal/app/docflow/commands.go`、`internal/app/docflow/commands_pg_test.go`、`internal/docgov/engine.go`、`internal/docgov/lease.go`、`internal/docrevision/pg_mutate.go`、`internal/docrevision/service.go`、`internal/docrevision/document_commit_concurrency_pg_test.go`、`internal/server/document_revision_handlers.go`。
|
||||
- **Approach:**
|
||||
1. 为每个文档命令明确 aggregate、expected head、idempotency key 和授权 action。
|
||||
2. 版本账本与 `document_records` 指针先在同一 UoW 写入;M1 保留现有通知兼容适配,但任何核心正确性不得依赖提交后的 `OnOutbox` 回调。
|
||||
3. 按 KTD4 锁定 document root 与 open version;状态转换采用条件更新。
|
||||
4. 提交返回 ledger/root 已持久化结果;workspace projection 和通知由提交后事件处理。
|
||||
5. 保留现有 ETag 语义,统一 agent、人工与批准路径的冲突分类。
|
||||
- **Execution note:** 先补当前行为的 PG characterization,再移动写入所有权;不得先删除旧事务路径。
|
||||
- **Patterns to follow:** `internal/docrevision/pg_mutate.go` 当前同事务投影、`internal/docgov/lease.go` expected SHA、现有 document revision idempotency tests。
|
||||
- **Test scenarios:**
|
||||
- 同一 ETag 两个用户并发编辑,只有一个成功,另一个得到当前 head。
|
||||
- 人工编辑与 agent commit 并发,账本 event_no 连续且 head 指向赢家。
|
||||
- approve 与 reject 并发,只允许一个合法终态。
|
||||
- candidate commit 在 root/version/outbox 任一故障点整体回滚。
|
||||
- 相同 idempotency key 和相同 digest 返回原结果;不同 digest 返回冲突。
|
||||
- 锁超时返回 typed retryable busy,不泄露 SQL 或路径。
|
||||
- 两个 app 实例执行同一请求,结果与单实例一致。
|
||||
- **Verification:** 文档四类命令的跨表不变量由 PostgreSQL 测试证明;`OnOutbox` 不再承担正确性。
|
||||
|
||||
### U4. 建立授权后的文档统一读模型
|
||||
|
||||
- **Goal:** 正文读取不再依赖 handler 拼路径或把一致性错误伪装成 404。
|
||||
- **Requirements:** R1、R3、R4、R14、R15、R19;KTD1、KTD8、KTD9。
|
||||
- **Dependencies:** U1、U3;实施时按 CURRENT 排队使用 `docgov` 和 `authz` mutex。
|
||||
- **Files:** `internal/app/docflow/query.go`、`internal/app/docflow/query_test.go`、`internal/docquery/service.go`、`internal/docquery/service_pg_test.go`、`internal/server/project_doc_handlers.go`、`internal/server/document_revision_authz_test.go`、`web/src/api/projectDocuments.ts`、`web/src/lib/controlledDocHydrate.ts`、`web/e2e/controlled-document-reload.spec.ts`。
|
||||
- **Approach:**
|
||||
1. Principal 由认证适配器构造,DocRef 不携带可覆盖 Principal 的 user/role/duty。
|
||||
2. 先解析数据库 resource→project、workflow、stage 和 duty,再读取 ledger/current pointer。
|
||||
3. workspace projection 只作为校验缓存;缺失时不阻止读取数据库 immutable content。
|
||||
4. ledger/root、projection 和 FAV 的差异映射为 typed consistency state,不再统一变成 404。
|
||||
5. FAV 降级在 U7 接入前保持关闭;接口先保留明确 extension point。
|
||||
6. 列表和版本查询强制分页,BatchGet 首期上限 100;批量授权按 Principal/project 分组并使用有界并发。
|
||||
- **Test scenarios:**
|
||||
- 正常 projection、projection 缺失、projection hash 漂移、ledger 缺失分别返回正确结果或 typed error。
|
||||
- GET、刷新、重登不产生数据库写入、workspace 文件或归档事件。
|
||||
- 匿名、非成员、跨用户会话、跨项目、错误 stage/duty 均拒绝且不泄露文档存在性。
|
||||
- 授权依赖超时/错误时 fail closed。
|
||||
- BatchGet 的 100/101 边界、重复引用、跨项目混合引用和限流拒绝不泄露资源存在性。
|
||||
- 项目 143 等价 hazard 和新项目在页面刷新、restart、rebuild 后正文一致。
|
||||
- **Verification:** 控制台和服务器目标 404/409/projection missing 为零;所有来源分支有服务端与浏览器证据。
|
||||
|
||||
### U5. Workspace capability、lease 与文件提交
|
||||
|
||||
- **Goal:** 从类型和运行时同时禁止跨模块裸路径与跨租户工作区访问。
|
||||
- **Requirements:** R1、R7、R9、R16–R18;KTD7。
|
||||
- **Dependencies:** U1。
|
||||
- **Files:** `internal/workspace/types.go`、`internal/workspace/resolver.go`、`internal/workspace/capability.go`、`internal/workspace/capability_test.go`、`internal/workspace/lease_pg_test.go`、`internal/squad/workspace_adapter.go`、`internal/bizdev/service.go`、`internal/server/project_doc_handlers.go`。
|
||||
- **Approach:**
|
||||
1. WorkspaceRef 删除 Root/OutputDir/ManifestPath,只保留 scope-bound ID、schema version 和 fencing token。
|
||||
2. workspace service 提供限定 artifact key 的 read、atomic writer、commit projection 和 inspect 能力。
|
||||
3. ID 与 lease 绑定 environment、project、run 和 storage identity;所有写入校验 fencing token。
|
||||
4. 使用 realpath/descriptor-based 检查,拒绝 `..`、绝对路径、symlink/hardlink 和根外 rename。
|
||||
5. legacy-v0 workspace 只读;首次受控 Resume 校验后原子升级 v1,并保留兼容备份一个发布周期。
|
||||
- **Test scenarios:**
|
||||
- `..`、绝对路径、编码后的分隔符、空 key、跨 project Workspace ID 全部拒绝。
|
||||
- symlink 和 hardlink 在校验后替换的 TOCTOU 场景仍不能逃逸。
|
||||
- 旧 lease writer 在新 lease 签发后因 fencing token 过期而失败。
|
||||
- 两个 app 实例并发提交同一 projection,reader 只看到完整 marker。
|
||||
- v0 读取、升级失败回滚、未知 major 版本拒绝。
|
||||
- **Verification:** production 包中 workspace 物理路径只存在于 workspace/squadrun adapter;多实例测试不依赖 `sync.Mutex`。
|
||||
|
||||
### U6. Durable outbox、逐消费者 delivery 与 replay
|
||||
|
||||
- **Goal:** 让后台投影、通知和 FAV 发布在崩溃与水平扩容下可恢复。
|
||||
- **Requirements:** R5、R9–R11、R14、R18、R21;KTD5、KTD6。
|
||||
- **Dependencies:** U2;需要新的 additive Goose migration。
|
||||
- **Files:** `deploy/postgres/goose/000xx_*durable_event_delivery.sql`、`internal/events/envelope.go`、`internal/events/outbox.go`、`internal/events/dispatcher.go`、`internal/events/inbox.go`、`internal/events/dispatcher_pg_test.go`、`internal/events/replay.go`、`cmd/events/main.go`。
|
||||
- **Approach:**
|
||||
1. 保留业务 outbox record,新增 event envelope、每消费者 delivery/inbox、attempt、lease、next_attempt_at 和 dead-letter 数据。
|
||||
2. `FOR UPDATE SKIP LOCKED` 只用于小批次 claim;worker 在 claim transaction 外执行 subscriber。
|
||||
3. ack/fail 使用 delivery version/fencing token,过期 worker 不能覆盖新 lease owner。
|
||||
4. 同 aggregate 依据 aggregate version 串行,不同 aggregate 并行;缺号进入等待或 quarantine。
|
||||
5. retry policy 区分 transient、permanent、authorization/integrity failures;后两者默认 dead letter/quarantine。
|
||||
6. replay CLI 先 dry-run,必须限定 consumer/event/project/time/id 范围并记录 operator、reason 和计划 hash。
|
||||
7. 逐个迁移并删除 `OnOutbox` 与闭包桥;切换前 shadow 对账,切换后旧回调不再注册。
|
||||
- **Test scenarios:**
|
||||
- 两个和四个 worker 并发 claim,没有同一 lease 的双 owner。
|
||||
- worker 在副作用前、后、ack 前崩溃,重启后最终只有一个可见业务结果。
|
||||
- 一个 subscriber 永久失败不阻塞其他 subscriber。
|
||||
- lease 过期后旧 worker ack 被 fencing token 拒绝。
|
||||
- 相同 event 重放被 inbox 去重;相同 aggregate 乱序事件不越过缺失版本。
|
||||
- 伪造 producer、跨项目 scope、payload digest 不符进入 quarantine。
|
||||
- dead-letter replay 未授权、无 dry-run 或范围过宽时拒绝。
|
||||
- **Verification:** 删除旧单一 `published_at` 语义前完成双读对账;pending age、attempt、lease steal、dead letter 可观测。
|
||||
|
||||
### U7. FAV 分支审计、终态发布与授权降级读取
|
||||
|
||||
- **Goal:** 将最终成果与 workspace 清理解耦,同时不形成第三套业务真相源。
|
||||
- **Requirements:** R1–R4、R10–R15、R18、R21、R22;KTD8、KTD9、KTD12。
|
||||
- **Dependencies:** U3、U4、U6;先审计 `feature/final-artifact-vault` 相对当前 dev 的完整差异。
|
||||
- **Files:** `internal/artifactvault/`、`deploy/postgres/goose/000xx_*final_artifact*.sql`、`internal/app/docflow/artifact.go`、`internal/docquery/service.go`、`internal/server/vault.go`、`cmd/artifact-vault/`、`internal/artifactvault/*_test.go`。
|
||||
- **Approach:**
|
||||
1. 重用通过审计的 FAV eligibility、staging、publisher、manifest、tombstone 和 scratch recovery;不直接 cherry-pick 未复核的组合根/handler 改动。
|
||||
2. 重新分配最新 Goose 序号,并验证 migration 与 U6 delivery schema 的组合和 Down 边界。
|
||||
3. 文档终态 transaction 冻结精确 payload/SHA 或 immutable blob ref 并写 publish request;publisher 不重读 workspace 或可变消息行。
|
||||
4. docquery 只在正常数据库授权成功后使用 FAV;manifest 只做 scope/integrity 交叉校验,不能授予权限。
|
||||
5. union-deny:运行删除账本或 vault tombstone 任一侧声明删除即拒绝读取/恢复。
|
||||
- **Execution note:** 现有 FAV 分支包含业务代码和 migration,必须先 rebase/merge audit,再按本计划拆分原子提交。
|
||||
- **Test scenarios:**
|
||||
- committed/sealed/approved 文档按准入表归档;ready/rejected/stale/quality_failed 和 workspace 草稿为零。
|
||||
- publish 重复、对象已存在、每个 fsync/rename/marker 崩溃点都可恢复。
|
||||
- workspace 删除后,授权用户可读 verified payload;未授权用户看不到 artifact 存在性。
|
||||
- DB 授权关系缺失、manifest scope 不符、坏 SHA、tombstone、vault 不可用均 fail closed 或返回明确 degraded error。
|
||||
- GET 零数据库写入、零 workspace 写入、零隐式恢复。
|
||||
- 跨项目相同 SHA 不共享删除或密钥边界。
|
||||
- **Verification:** FAV test report 与当前 dev 重放通过;`FAV_DEGRADED_READ` 默认关闭,直到 M2 全部负例通过。
|
||||
|
||||
### U8. TaskRun、RunManifest 与 workspace 清理门禁
|
||||
|
||||
- **Goal:** 任务终态和最终产物在重复 finalize、SSE 断线和 workspace 清理后保持一致。
|
||||
- **Requirements:** R1、R2、R5–R12、R16–R18、R22;KTD5–KTD8。
|
||||
- **Dependencies:** U5、U6、U7。
|
||||
- **Files:** `internal/taskrun/manager.go`、`internal/taskrun/store.go`、`internal/taskrun/finalize.go`、`internal/taskrun/finalize_pg_test.go`、`internal/squad/manifest.go`、`internal/app/stageflow/execute.go`、`internal/server/task_finalize.go`、`internal/server/task_run_idempotent_sse_test.go`。
|
||||
- **Approach:**
|
||||
1. RunManifest 核心字段类型化并持久化规范字节、SHA、workspace/storage identity 和成员列表。
|
||||
2. taskrun terminal finalize transaction 写状态、assistant terminal turn、manifest、artifact staging 和 outbox。
|
||||
3. SSE done 只投影已提交状态;客户端断线与重试复用 task run idempotency key。
|
||||
4. workspace 清理只依赖已冻结 staging 和 policy;需长期保留的最终成果还必须取得 verified receipt。
|
||||
5. backend 由服务端 BackendRef policy 解析,InputBundle 携带数据分类和最小权限范围。
|
||||
- **Test scenarios:**
|
||||
- 同一 finalize 重复调用返回同结果,不重复消息、事件、manifest 或 artifact。
|
||||
- finalize 与 cancel 并发只有一个合法终态。
|
||||
- SSE 在 commit 前后断线并重试,历史中只有一个 assistant turn。
|
||||
- workspace 在 finalize 后立即清理,publisher 仍从 staging 归档原 SHA。
|
||||
- manifest digest、storage identity、artifact member 不一致阻止 verified/cleanup。
|
||||
- 未授权 backend、敏感 InputBundle、凭证回显和 result 残留全部拒绝或清理。
|
||||
- **Verification:** “完成但没有 durable manifest/publish request”和“已显示完成但正文仍模板”两类状态不可产生。
|
||||
|
||||
### U9. 存量回填、migration corpus 与数据生命周期
|
||||
|
||||
- **Goal:** 只保留可解释的演示/回归数据,并持续验证旧数据迁移、重载和受控恢复。
|
||||
- **Requirements:** R2、R12、R13、R21、R22;KTD8、KTD10。
|
||||
- **Dependencies:** U6–U8。
|
||||
- **Files:** `cmd/dataset/`、`internal/dataset/`、`internal/artifactvault/backfill.go`、`scripts/backup-system-db.sh`、`scripts/restore-system-db-drill.sh`、`.doc/test-fixtures/`、`.doc/test-reports/`。
|
||||
- **Approach:**
|
||||
1. 建立 dataset registry,分类 pinned、demo、regression、migration baseline、ephemeral 和 quarantine。
|
||||
2. backfill 只枚举 immutable document snapshot、approved/commit event 和 completed chat turn,禁止扫描 workspace。
|
||||
3. 大表按稳定主键 checkpoint 分批处理;多个 worker 使用 `SKIP LOCKED` 或互斥 shard,限速避免挤占在线连接池。
|
||||
4. 每个 batch 记录 source schema/commit/storage identity、范围、成功、quarantine 和重试水位。
|
||||
5. dev 先蓝绿建立新数据根,导入 6–10 个批准 dataset;旧库/状态根只读 quarantine 7–14 天后,需用户再次批准才销毁。
|
||||
6. 每个 release 验证 clean、oldest-supported、previous、hazard、import、reload、rollback 和 artifact recovery 八条轨道。
|
||||
- **Test scenarios:**
|
||||
- backfill 中断、重复和并发新终态时分母/分子可复算且不重复 artifact。
|
||||
- legacy-v0 workspace、旧 schema、高 ID、projection missing/fork、缺 snapshot 分别升级或 quarantine。
|
||||
- restore 强制 scratch,拒绝 dev/test/main DSN、错误 storage identity、篡改 manifest 和缺权限凭证。
|
||||
- FAV coverage、完整备份 restore 或 migration corpus 任一失败都阻止 destroy。
|
||||
- dev 选择性导入后项目关系、文件 SHA、sequence、授权和 reload 一致。
|
||||
- **Verification:** 数据清理有精确 manifest、可恢复位置、观察窗口和批准记录;不修改历史 Goose。
|
||||
|
||||
### U10. chatlog/contextasm 原地端口化
|
||||
|
||||
- **Goal:** 在不重复付费搬包的前提下分离对话记录和 prompt 上下文装配。
|
||||
- **Requirements:** R7、R14、R15、R19、R20;KTD1、KTD9。
|
||||
- **Dependencies:** M1–M3 go/no-go;必须等待当前 `chat` mutex 释放。
|
||||
- **Files:** `internal/ctxmgr/`、`internal/chat/`、`internal/app/chatflow/`、`internal/ctxmgr/chat_auth_pg_test.go`、`internal/server/chat_history_auth_test.go`。
|
||||
- **Approach:**
|
||||
1. 先定义 chatflow 所需的消费者侧 ChatLog 和 ContextAssembler ports。
|
||||
2. 在现有 ctxmgr 内提供 adapters,迁移调用者并保持表所有权不变。
|
||||
3. 统计接口 fan-in、DTO 转换和同类改动触达文件数;收益成立后再物理拆为 chatlog/contextasm。
|
||||
4. 所有读取携带 Principal/action,不接受请求体 user/project 作为权限事实。
|
||||
- **Test scenarios:**
|
||||
- 刷新前后同一授权消息集合的正文、分类和计数一致。
|
||||
- 跨用户、跨项目、已退出成员和服务端后台导出负例。
|
||||
- context assembly 只读取允许集合,摘要/缓存不放大不可见消息。
|
||||
- 原地 adapter 与最终拆包实现的 contract tests 等价。
|
||||
- **Verification:** 同类对话变更触达面相对基线下降;拆包前已获得独立行为收益。
|
||||
|
||||
### U11. 组合根迁移、事件替换和共享上下文收权
|
||||
|
||||
- **Goal:** 让每个跨模块业务流程具有唯一应用用例 owner,而不是集中在 server/bizdev 闭包和共享指针。
|
||||
- **Requirements:** R5、R7、R9–R11、R14、R15、R19、R20;KTD1、KTD5、KTD6、KTD9。
|
||||
- **Dependencies:** U10;按四个独立子切片执行,不建立一个巨型 Wave。
|
||||
- **Files:** `internal/app/app.go`、`internal/app/chatflow/`、`internal/app/stageflow/`、`internal/app/docflow/`、`internal/server/server.go`、`internal/bizdev/service.go`、`internal/coordinator/`、对应 contract/integration tests。
|
||||
- **Approach:**
|
||||
1. U11a 只把构造和生命周期迁到唯一 app composition root。
|
||||
2. U11b 一次只替换一种闭包事件;每种事件单独迁移、对账和回滚。
|
||||
3. U11c 逐个迁移 ExecuteTask/executeStageInternal 用例,不同时修改无关领域。
|
||||
4. U11d 将 UserProjectContext 改为只读 snapshot + 类型化 ContextUpdate,单一 use case 应用增量。
|
||||
- **Test scenarios:**
|
||||
- app start/stop/restart 不丢 worker,重复启动不创建重复 subscriber。
|
||||
- 每个闭包新旧路径 shadow 结果一致,切换后旧路径无调用。
|
||||
- ContextUpdate 并发应用使用版本检查,不发生字段级 lost update。
|
||||
- server、CLI 和 worker 调用同一 use case 得到一致授权和事务结果。
|
||||
- **Verification:** 每个子切片独立发布、独立回滚;server 不再保存业务闭包,UserProjectContext 不被多方直接修改。
|
||||
|
||||
### U12. 传输边界收口和最终架构门禁
|
||||
|
||||
- **Goal:** 在领域行为稳定后缩减 server/stream 耦合并全面启用已清零规则。
|
||||
- **Requirements:** R14、R18–R21;KTD1、KTD11。
|
||||
- **Dependencies:** U11。
|
||||
- **Files:** `internal/server/`、`internal/stream/transport/`、`scripts/arch-check.sh`、`.doc/system-design/module-boundaries.yaml`、CI 配置、`web/e2e/`。
|
||||
- **Approach:**
|
||||
1. server handler 只保留参数、Principal、use case 调用和 wire serialization。
|
||||
2. SSE transport 只编码中性 progress/event frame,不拥有业务状态或重试决策。
|
||||
3. 对已清零的 import、SQL writer、path、producer/consumer 规则逐项转 block。
|
||||
4. 错误信封和 PI Runner 协议保持独立 follow-up,不进入本单元。
|
||||
- **Test scenarios:**
|
||||
- HTTP、SSE 断线重连和 CLI 对同一业务状态观察一致。
|
||||
- server 新增领域实现 import、SQL 或路径拼接时 CI 失败。
|
||||
- SSE 慢客户端、断开和重复连接不阻塞业务 transaction 或 dispatcher。
|
||||
- 全套授权、重启和多实例回归保持通过。
|
||||
- **Verification:** R1–R7 的修订版规则全 block;server 行数只作观察指标,不作替代行为验收。
|
||||
|
||||
---
|
||||
|
||||
## Verification Contract
|
||||
|
||||
### Evidence layers
|
||||
|
||||
| 层级 | 必须证明 | 不得替代 |
|
||||
|---|---|---|
|
||||
| Unit | 状态机、错误分类、schema、路径和 policy | PostgreSQL 原子性 |
|
||||
| PostgreSQL integration | 事务回滚、锁、幂等、migration、并发 | 浏览器用户体验 |
|
||||
| Fault injection | 每个 commit/delivery/publish 崩溃窗口 | 真实进程 restart |
|
||||
| Concurrency/load | 多 app/worker、热点与非热点资源、积压和公平性 | 正确性断言 |
|
||||
| Real PI | 真实 agent 生成、manifest、finalize、归档 | stub 或固定假输出 |
|
||||
| Browser | 页面创建/打开/编辑/刷新/重登、错误与 degraded 状态 | API 直接调用 |
|
||||
| Restart/rebuild | app、worker、workspace root、存储 identity 重载 | health 单点检查 |
|
||||
| Authorization negative | 匿名/跨用户/跨项目/阶段/duty/后台/recovery | 前端隐藏按钮 |
|
||||
| Recovery | 完整 DB restore、migration corpus、FAV scratch 恢复 | 仅列出 dump 内容 |
|
||||
|
||||
### Repository gates
|
||||
|
||||
- `go vet ./...`
|
||||
- `go test -short ./...`
|
||||
- 目标模块 PostgreSQL 集成和 race 测试;若全包 race 存在已知 TempDir flake,报告必须分离目标模块结果和基建债。
|
||||
- `scripts/arch-check.sh` 对当前里程碑已收紧规则为 block,对未迁移规则输出基线差异。
|
||||
- `web` 单元测试与 production build。
|
||||
- dev 容器必须 rebuild 后检查 `/health` 与 `/ready`,不能用 restart 证明新代码已部署。
|
||||
- 真实 PI、真实浏览器、post-restart 和多用户负例按独立证据分栏。
|
||||
|
||||
### Concurrency test matrix
|
||||
|
||||
| 维度 | 场景 | 必须断言 |
|
||||
|---|---|---|
|
||||
| App replicas | 1/2/4 | 结果和不变量一致,进程内 mutex 关闭仍正确 |
|
||||
| Dispatcher replicas | 1/2/4 | 无双 owner ack,无重复可见副作用,无永久饥饿 |
|
||||
| Resource shape | 同一文档热点/不同文档并行 | 热点正确冲突,非热点不被全局锁串行化 |
|
||||
| Failure point | before commit/after commit/before ack/after side effect | 无丢事件,重复交付幂等 |
|
||||
| Lock behavior | timeout/deadlock/serialization | 分类和完整事务重试正确,无无限等待 |
|
||||
| Lease behavior | expiry/renew/steal/stale fencing | 旧 owner 无法覆盖新 owner |
|
||||
| Load | steady/burst/backlog recovery | 记录 p50/p95/p99、锁等待、连接池、oldest event age |
|
||||
|
||||
### Authorization test matrix
|
||||
|
||||
每个状态改变和读取接口至少覆盖:匿名、已登录非成员、错误项目成员、会话非 owner、错误 duty、错误 stage、过期 lease、跨环境 ID、服务身份越权、授权依赖失败和系统管理员合法路径。
|
||||
FAV 额外覆盖 manifest project 不符、tombstone、删除账本不一致和授权图缺失。
|
||||
状态改变必须覆盖授权检查后的成员撤销、duty 变化、stage 推进和 lease 失效,证明用例在取得资源锁后重新验证当前事实。
|
||||
|
||||
### Migration and rollback matrix
|
||||
|
||||
| 阶段 | Forward proof | Rollback proof |
|
||||
|---|---|---|
|
||||
| Expand | 新 schema 与旧代码兼容 | migration Down 或旧代码继续运行 |
|
||||
| Shadow | 新旧读取差异为零或有解释的 quarantine | 关闭 flag 回旧路径 |
|
||||
| Backfill | checkpoint、幂等、并发新写一致 | 停止 worker,不撤销已安全扩展列 |
|
||||
| Switch | 新 writer 唯一、旧 writer 只读 | flag 回最近兼容 writer |
|
||||
| Observe | restart/rebuild/浏览器/PI/指标稳定 | 切回旧 reader/writer 与只读 quarantine 数据 |
|
||||
| Contract | 引用扫描和用户批准 | contract 后只能逆序部署兼容版本,不能单 commit revert |
|
||||
|
||||
---
|
||||
|
||||
## Risk Analysis and Mitigation
|
||||
|
||||
| 风险 | 触发信号 | 缓解和停止条件 |
|
||||
|---|---|---|
|
||||
| contracts 成为新万能包 | fan-in、共享 DTO、ADR 数持续上升 | 消费者侧端口;按域拆公共契约;超过阈值回评边界 |
|
||||
| 跨属主事务过大 | 锁等待、事务 p99、deadlock 上升 | 缩小原子集合;事务外副作用;固定锁序;停止扩面 |
|
||||
| advisory lock 形成全局瓶颈 | 非热点资源也排队 | 只对项目级不变量使用;key 分区;检查 `pg_locks` |
|
||||
| outbox backlog 放大 | oldest age/attempt/dead letter 超阈值 | 独立 worker 连接预算、批次和退避;按 aggregate 并行 |
|
||||
| poison event 无限重试 | 同 event 高频失败 | typed permanent failure、dead letter、受控 replay |
|
||||
| FAV 成为授权旁路 | DB 关系缺失仍返回内容 | 正常授权先行、union-deny、默认关闭 degraded read |
|
||||
| 工作区链接逃逸 | realpath/scope mismatch | opaque ID、descriptor 检查、fencing、负例测试 |
|
||||
| 回填压垮在线库 | lock wait/replication lag/连接池饱和 | checkpoint、小批次、限速、独立连接预算、可暂停 |
|
||||
| 双读长期不收口 | exception 数和差异长期不降 | 明确 owner、到期日和 go/no-go;禁止无限兼容 |
|
||||
| 物理搬包造成大冲突 | import churn 大于行为收益 | 原地端口化;行为稳定后再移动;按 mutex 排队 |
|
||||
| FAV 分支漂移 | migration 冲突、dev API 已变化 | rebase audit,不整分支盲合;按单元摘取并重测 |
|
||||
| 数据清理误删 | coverage/backup/corpus 任一未知 | 默认不删;蓝绿迁移;quarantine 观察;用户二次批准 |
|
||||
|
||||
---
|
||||
|
||||
## Operational and Rollout Notes
|
||||
|
||||
### Feature flags
|
||||
|
||||
- 新 docquery reader、单写路径、dispatcher consumer、FAV degraded read 和 legacy workspace reader 分别使用独立 flag。
|
||||
- flag 只选择已兼容路径,不能跳过授权、完整性或终态准入。
|
||||
- 每个 flag 有 owner、默认值、环境范围、启用前置和删除里程碑。
|
||||
|
||||
### Resource budgets
|
||||
|
||||
- app transaction、dispatcher、backfill 和 FAV publisher 使用分离的连接池或明确配额,避免后台积压耗尽在线请求连接。
|
||||
- worker batch、lease、heartbeat、retry 和 concurrency 由配置管理;M1/M2 基线报告确定初值和告警阈值。
|
||||
- 对同 aggregate 设置最大在途数 1;跨 aggregate 并行度按数据库与 vault 指标调节。
|
||||
|
||||
以下是首轮试点的安全起始值,不是产品 SLO;压测报告必须记录调整依据:
|
||||
|
||||
| 参数 | 起始值 | 调整约束 |
|
||||
|---|---|---|
|
||||
| 写事务 lock wait | 最多 2 秒且不超过请求剩余 deadline | 不允许无限等待;热点冲突优先返回 typed busy |
|
||||
| serialization/deadlock retry | 最多 3 次,指数退避并带 jitter | 只重跑完整幂等事务 |
|
||||
| dispatcher claim batch | 50 | claim transaction 只认领,不执行 subscriber |
|
||||
| delivery lease/heartbeat | 30 秒 / 10 秒 | heartbeat 失败后旧 worker 不得 ack |
|
||||
| delivery attempts | 12 次后 dead letter | permanent/auth/integrity failure 不做 12 次盲重试 |
|
||||
| retry backoff ceiling | 15 分钟 | oldest age 仍受告警和人工处置约束 |
|
||||
| backfill batch | 每批 200 个 aggregate | 按 lock wait、replication lag 和在线池占用自动降速 |
|
||||
| backfill workers | dev 初始 1,验证后最多 4 | 每个 shard/checkpoint 不重叠 |
|
||||
|
||||
### Observability
|
||||
|
||||
- Dashboard:transaction/lock、document consistency、outbox delivery、workspace lease、FAV publication、authorization deny、backfill/quarantine。
|
||||
- Alert:oldest event age、dead letter、storage identity mismatch、projection mismatch、FAV coverage gap、恢复/删除拒绝。
|
||||
- Trace 使用 correlation/causation ID 串联 HTTP/use case/transaction/event/subscriber,但不得携带正文或 secret。
|
||||
|
||||
### Documentation lifecycle
|
||||
|
||||
- 每个里程碑开始前新建 short-term 和 CURRENT claim。
|
||||
- 每个里程碑结束生成 test report,回链本计划 U-ID 和验收清单。
|
||||
- 完成项移入 archive;未解决问题进入 audit/remediation,不通过口头说明关闭。
|
||||
|
||||
---
|
||||
|
||||
## Sources and Research
|
||||
|
||||
- [模块解耦方案复核](../audit/2026-09-14-module-decoupling-plan-review.md)记录当前源码中的事务、outbox、workspace 和授权缺口。
|
||||
- `internal/docrevision/pg_mutate.go` 是现有账本与根指针同事务模式,U2–U3 不得降低其原子性。
|
||||
- `internal/docduty/revoke.go` 已使用事务级 project advisory lock,是 KTD3 的本地先例。
|
||||
- `internal/taskrun/manager.go`、`internal/taskrun/store.go` 已有进程内幂等锁与数据库唯一键,U8 需要把正确性完全落到跨实例可用的数据库协议。
|
||||
- `feature/final-artifact-vault` 已实现候选 FAV 能力;U7 必须按当前 dev 重新审计,而不是把分支状态当成当前发布事实。
|
||||
- [PostgreSQL `SKIP LOCKED`](https://www.postgresql.org/docs/current/sql-select.html)只用于队列式 claim,不用于普通一致性读取。
|
||||
- [PostgreSQL serialization failure handling](https://www.postgresql.org/docs/16/mvcc-serialization-failure-handling.html)要求 serialization/deadlock 重试覆盖完整事务逻辑。
|
||||
- [OWASP Business Logic Security](https://cheatsheetseries.owasp.org/cheatsheets/Business_Logic_Security_Cheat_Sheet.html)支持服务端重新推导身份、所有权和安全相关状态。
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] U1–U9 完成并通过 M0–M3 的逐里程碑 go/no-go;U10–U12 只在收益复盘批准后执行。
|
||||
- [ ] 文档提交不存在跨表半完成,所有提交后副作用来自持久事件。
|
||||
- [ ] 多实例、多 worker、崩溃和 lease 过期场景保持相同业务结果。
|
||||
- [ ] workspace 不再是正文或最终 manifest 的唯一事实源,裸路径不跨模块传播。
|
||||
- [ ] FAV 只含终态成果,中间结果准入率为零,授权缺失时不可降级读取。
|
||||
- [ ] 项目 143 等价 hazard 与新项目完成真实 PI、页面刷新、重登、restart/rebuild 和跨用户负例。
|
||||
- [ ] clean/oldest/previous/hazard/import/reload/rollback/artifact recovery 八条迁移轨通过。
|
||||
- [ ] arch-check 规则按纵向切片清零后全部 block,例外有 owner 和到期日。
|
||||
- [ ] CURRENT、FEATURE-MATRIX、gap-matrix、ADR、系统设计、test report 和 checklist 状态一致。
|
||||
- [ ] 放弃方案、临时代码、双写旁路、过期 flags 和无消费者契约已移除,不留在最终 diff。
|
||||
|
||||
## Related Links
|
||||
|
||||
- Audit: [模块解耦方案复核](../audit/2026-09-14-module-decoupling-plan-review.md)
|
||||
- Architecture: [模块化单体解耦架构](../system-design/2026-09-14-module-decoupling-architecture.md)
|
||||
- ADR: [模块化单体解耦](../adr/2026-09-14-modular-monolith-decoupling.md)
|
||||
- Planning task: [本次计划修订任务](../archive/ops/2026-09-14-module-decoupling-remediation-plan.md)
|
||||
- Acceptance: [重构验收清单](../ops/checklists/2026-09-14-module-decoupling-refactor-acceptance.md)
|
||||
- FAV dependency: `feature/final-artifact-vault` 中的 `.doc/remediation/2026-09-14-final-artifact-vault-remediation.md`
|
||||
- Data lifecycle dependency: `feature/final-artifact-vault` 中的 `.doc/remediation/2026-09-14-runtime-data-lifecycle-and-migration-corpus-remediation.md`
|
||||
@@ -3,6 +3,7 @@
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
|
||||
| [2026-09-14-module-decoupling-refactor-plan.md](./2026-09-14-module-decoupling-refactor-plan.md) | 纵向绞杀、UnitOfWork、高并发互斥、可靠事件、逐动作授权、FAV、迁移与恢复的详细重构计划 |
|
||||
| [2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md](./2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md) | 项目设置 409 冻结态守卫 + 非必需文档三权全有或全无 + 成员/门禁/权限一致性 |
|
||||
| [2026-09-14-controlled-content-canonical-hash-remediation.md](./2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 正文 canonical/raw 哈希漂移、错误语义与重复 hydration 整改 |
|
||||
| [2026-09-13-document-revision-reviewer-duty-sync.md](./2026-09-13-document-revision-reviewer-duty-sync.md) | UI-01 已分配 B=reviewer,受控文档 reviewer_id 硬编码 2,矩阵降为 viewer |
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
| DOCREV-ACTIVE-DOC-ROUTING | UI-05「当前受控文档」被法规关键词改路由到 `feasibility_reg`,intake 无 pending | **PASS** | [audit](../audit/2026-09-13-document-revision-active-doc-routing.md);Round20 UI-05 pending 停在 intake V2 | **不改弱 E2E**;不删证据 |
|
||||
| DOCREV-QUALITY-STATUS-FP | 行动计划表状态列「待续」被门禁当敷衍,两轮 repair 后 quality_failed 80 | **PASS** | [audit](../audit/2026-09-13-document-revision-quality-status-false-positive.md);Round20 UI-03 | **不删禁词、不放宽正文**;不删证据 |
|
||||
| GOVERNANCE-ACTION-AUTHZ | 文档动作按职责鉴权;未激活 202-blocked;关闭创建直通 | **PASS** | [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[audit 异态](../audit/2026-09-14-planning-with-workflow-key-anomaly.md) | 不改 83 条 planning+key 夹具;none 长 PI 审批链另开 |
|
||||
| DOC-RELIABILITY-PILOT | 受控文档写路径跨表非同事务、读路径被 projection 绑架、授权 TOCTOU;刷新/重启后可能 404/409/projection-missing | OPEN | [复核](../audit/2026-09-14-module-decoupling-plan-review.md);[重构计划](./2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 报告](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | U1–U4 已编码。M1 **停止扩面**(无真实 PI/浏览器/rebuild)。未占槽不得 rebuild;禁止 U5–U12 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
|
||||
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
|
||||
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[运行时 UI Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md) | CI `TEST_DATABASE_URL` 隔离库仍缺;文档运行时真实 UI 已按 UI_PHASE 分文件且 post-restart 断言最终 A1 正文。`-race` 全包并行 TempDir flake 为基建残留 | `ci` |
|
||||
| 模块解耦与可靠事件 | 文档可靠性纵向试点、跨属主事务、多副本互斥、FAV 与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 报告](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | U1–U4 已在 `refactor/document-reliability-pilot` 落地(Unit/隔离库 PASS)。M1 **停止扩面**:真实 PI/浏览器/rebuild 未签。禁止 U5–U12 | `docgov` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
# 模块化单体解耦架构
|
||||
|
||||
> 状态:**设计已修订,首个文档可靠性试点待实施**
|
||||
>
|
||||
> 决策:[模块化单体解耦 ADR](../adr/2026-09-14-modular-monolith-decoupling.md)
|
||||
>
|
||||
> 实施:[模块解耦整改与重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md)
|
||||
|
||||
## 1. 目标与边界
|
||||
|
||||
本设计把 Agent-System 收敛为模块化单体,使项目文档在刷新、服务重启、并发修改和工作区清理后仍能从稳定逻辑标识定位到正确正文。
|
||||
模块边界必须同时约束依赖、事务、并发、授权、文件和最终成果;只减少 Go import 或移动目录不算解耦完成。
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
1. 每个业务命令有唯一应用用例、事务 owner 和幂等语义。
|
||||
2. 每张运行表和每类文件有唯一写入属主,但跨表不变量仍能在同一事务提交。
|
||||
3. HTTP、SSE、CLI、worker 和恢复工具经过同一授权决策,不信任调用方填写的身份或资源归属。
|
||||
4. workspace 只保存计算现场和可重建投影;最终文档、最终回复和 manifest 进入 FAV。
|
||||
5. 事件在崩溃、重复投递、多订阅者和水平扩容下可追踪、可重试、可隔离。
|
||||
6. 首个纵向试点直接消除受控正文刷新/重启后 404、409 和 projection missing 类故障。
|
||||
|
||||
### 1.2 非目标
|
||||
|
||||
- 不拆微服务,不引入分布式事务协调器。
|
||||
- 不在本轮统一全部 HTTP 错误响应。
|
||||
- 不在本轮替换 PI Runner stdout/result 协议。
|
||||
- 不把所有历史数据永久保留,也不把生产数据复制为测试 fixture。
|
||||
- 不允许 FAV 绕过数据库授权或自动重建活动工作流、lease、pending task。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Client[Web or CLI] --> Adapter[server adapters]
|
||||
Worker[Background workers] --> App[application use cases]
|
||||
Adapter --> App
|
||||
App --> Authz[authorization policy]
|
||||
App --> UoW[UnitOfWork]
|
||||
App --> ReadModels[read use cases]
|
||||
UoW --> Owners[domain table owners]
|
||||
Owners --> PG[(PostgreSQL)]
|
||||
UoW --> Outbox[(outbox and staging)]
|
||||
Outbox --> Dispatcher[event dispatcher]
|
||||
Dispatcher --> Subscribers[idempotent subscribers]
|
||||
Subscribers --> Vault[artifactvault]
|
||||
App --> Workspace[workspace capability service]
|
||||
Workspace --> Scratch[(scratch workspace)]
|
||||
ReadModels --> PG
|
||||
ReadModels --> Vault
|
||||
```
|
||||
|
||||
### 2.1 分层职责
|
||||
|
||||
| 层 | 职责 | 禁止 |
|
||||
|---|---|---|
|
||||
| `internal/server` | 参数解析、认证信息转 Principal、调用应用用例、序列化 HTTP/SSE | SQL、事务、路径拼接、跨模块编排、后台重试 |
|
||||
| `internal/app/*flow` | 用例编排、授权、UnitOfWork、命令幂等、事件发布 | 领域 SQL、物理路径、传输格式 |
|
||||
| 消费者侧 ports | 表达一个用例需要的最小能力 | 聚合所有模块方法的万能接口 |
|
||||
| 领域模块 | 业务不变量、表/文件写入所有权、事务绑定 repository | import 其他领域实现、开启不受控嵌套事务 |
|
||||
| read model | 授权后的跨模块只读聚合 | 业务状态变更、修复式 GET 写入 |
|
||||
| 基础设施 | PostgreSQL、CAS、FAV、workspace、事件 dispatcher | 推断业务终态或自行授予权限 |
|
||||
|
||||
### 2.2 契约分类
|
||||
|
||||
`internal/contracts` 不再作为所有 DTO 的公共仓库。
|
||||
|
||||
| 契约 | 所有位置 | 版本规则 |
|
||||
|---|---|---|
|
||||
| HTTP/SSE wire DTO | `internal/server/api`、`internal/stream/transport` | 对外兼容,breaking change 走 ADR |
|
||||
| 一对一调用端口 | 消费者 use case 包内 | 随用例演化,不带 JSON tag,不自动走 ADR |
|
||||
| 公共领域事件 | `internal/contracts/events/<domain>` | 有 schema version;生产者和多个消费者共同评审 |
|
||||
| 持久文件/manifest schema | 所属模块,如 `artifactvault`、`workspace` | major/minor 兼容;未知 major fail closed |
|
||||
| 稳定共享词汇 | `internal/contracts/vocabulary` | 仅无行为枚举和值对象;禁止依赖业务实现 |
|
||||
| 错误分类 | 领域错误留领域包;wire 映射留 server | 不把数据库/路径/第三方原始错误暴露给客户端 |
|
||||
|
||||
接口默认由消费者拥有,应用组合根只注入实现。
|
||||
只有三个以上消费者确实共享相同语义时,才提升为公共端口。
|
||||
|
||||
## 3. 模块边界
|
||||
|
||||
| 模块 | 唯一职责与写入所有权 | 可依赖 | 禁止 |
|
||||
|---|---|---|---|
|
||||
| `app/docflow` | 文档读取、提交、批准、投影和归档用例;相关事务 owner | 消费端口、authz、uow | SQL、物理路径 |
|
||||
| `app/stageflow` | 阶段推进和任务执行用例 | 消费端口、authz、uow | bizdev 门面、HTTP DTO |
|
||||
| `app/chatflow` | 一轮对话识别、协调、消息落账和任务触发 | 消费端口、authz、uow | 直接写共享上下文字段 |
|
||||
| `docgov` | 文档治理状态、角色、lease、`document_records` 写者 | 自有 repository、稳定词汇 | 直调 docrevision 实现、读 workspace 文件 |
|
||||
| `docrevision` | immutable snapshot/version/revision/event/report 账本写者 | 自有 repository | 直接写 `document_records`、`freeform_documents` |
|
||||
| `freedoc` | 自由文档根表和发布状态写者 | 自有 repository | 被 docrevision 反向更新 |
|
||||
| `docquery` | 授权后文档列表、正文、版本聚合读取 | 只读端口、artifact read fallback | 任何写入、目录扫描、授权推断 |
|
||||
| `chatlog` | `chat_*` 表族读写和会话/消息/turn 查询 | 自有 repository | prompt 装配、外部导出策略 |
|
||||
| `contextasm` | 记忆、摘要、召回和 prompt 上下文组装 | 消费者侧 ChatLog/KB read ports | HTTP、SQL 越界写入 |
|
||||
| `intent` | 纯规则意图分类 | vocabulary | bizdev、server、IO |
|
||||
| `taskrun` | task run 状态机、claim/finalize、幂等写者 | 自有 repository | server 补协议、workspace 路径 |
|
||||
| `squadrun` | 在受限 workspace capability 内执行角色与步骤 | agent/template/quote 消费端口 | DB、SSE、业务授权、任意路径 |
|
||||
| `workspace` | Workspace ID 签发、物理路径解析、projection 生命周期 | fsutil、storage identity | 业务终态、最终归档 |
|
||||
| `artifactvault` | 终态 payload/manifest/receipt/tombstone 权威 | 独立存储、registry | 活动业务关系、运行授权、workspace 扫描 |
|
||||
| `notification` | 通知偏好、投递和状态 | 事件消费、repository | 业务状态推进 |
|
||||
| `app` | 唯一组合根、worker 生命周期、事件订阅注册 | 所有构造函数 | 被 server 反向装配 |
|
||||
|
||||
模块“可依赖”只表示通过端口调用,不表示允许 import 对方实现包。
|
||||
|
||||
## 4. 事务与单一写者
|
||||
|
||||
### 4.1 两种所有权
|
||||
|
||||
- **表属主**决定 SQL、约束和单表不变量。
|
||||
- **事务 owner**决定一个业务命令需要哪些属主共同提交。
|
||||
|
||||
两者不能合并为“每个模块各开一个事务”。
|
||||
应用用例开启 `UnitOfWork`,取得绑定同一 `pgx.Tx` 的 repository capabilities;领域服务只能使用这些 capabilities,不能 commit、rollback 或启动嵌套事务。
|
||||
|
||||
### 4.2 文档提交原子集合
|
||||
|
||||
一次受控文档提交必须原子包含:
|
||||
|
||||
1. 校验 Principal、项目成员、duty、lease 和 expected head。
|
||||
2. 锁定 `document_records` 与当前 open version。
|
||||
3. 写 immutable content blob/snapshot/revision/change event。
|
||||
4. 更新版本 head 与 `document_records` 当前指针/哈希。
|
||||
5. 冻结应归档 payload 或不可变 blob 引用。
|
||||
6. 写领域事件和 FAV publish request。
|
||||
7. 提交后再进行 workspace 投影、通知和外部存储发布。
|
||||
|
||||
任一步失败,步骤 2–6 全部回滚。
|
||||
投影或发布失败不会回滚业务提交,而是进入可重试队列并在 UI 暴露 degraded 状态。
|
||||
|
||||
### 4.3 事务隔离选择
|
||||
|
||||
| 场景 | 机制 | 原因 |
|
||||
|---|---|---|
|
||||
| 单文档编辑/批准/提交 | `READ COMMITTED` + `SELECT ... FOR UPDATE` + expected head | 竞争范围明确,冲突可向用户解释 |
|
||||
| 同幂等键并发创建 | 唯一约束 + payload digest 校验 | 数据库决定唯一赢家 |
|
||||
| 跨表项目级 duty/heal | `pg_advisory_xact_lock(project_id)` + 固定行锁顺序 | 复用现有项目级互斥,事务结束自动释放 |
|
||||
| 复杂跨聚合唯一不变量 | `SERIALIZABLE`,完整事务有限重试 | 仅在显式证明需要时使用 |
|
||||
| outbox 多 worker claim | `FOR UPDATE SKIP LOCKED` + lease/fencing token | 提高队列吞吐,不作为普通读取语义 |
|
||||
|
||||
### 4.4 锁顺序
|
||||
|
||||
所有跨属主事务按下列顺序获得锁:
|
||||
|
||||
1. project 或 workflow instance 事务级 advisory/row lock;
|
||||
2. document/freeform/chat aggregate root;
|
||||
3. version/snapshot/turn;
|
||||
4. task run;
|
||||
5. staging/outbox/delivery。
|
||||
|
||||
禁止在持锁期间调用 LLM、PI Runner、对象存储、HTTP、SSE 或执行大文件复制。
|
||||
需要外部工作时采用 prepare → 外部执行 → compare-and-commit;commit 阶段重新验证 expected version。
|
||||
|
||||
### 4.5 超时与重试
|
||||
|
||||
- 每个写事务设置独立 context deadline 和数据库 `lock_timeout`;初始预算由配置管理,不在 handler 硬编码。
|
||||
- `40001` 与 `40P01` 只允许重跑完整幂等事务,采用带抖动退避和有限次数。
|
||||
- expected-version 冲突、权限拒绝、唯一键 payload 不同不是瞬时错误,不自动重试。
|
||||
- 事务重试期间不得重复发送消息、SSE、通知或对象存储写入;这些副作用只能由提交后的 outbox 驱动。
|
||||
|
||||
## 5. 并发协议
|
||||
|
||||
### 5.1 文档并发
|
||||
|
||||
客户端读取正文时获得 `ETag = head_snapshot_id:head_revision_no`。
|
||||
PATCH、agent commit、approve 和 projection repair 都必须携带 expected head 或稳定 idempotency key。
|
||||
旧 ETag 返回 typed `VERSION_CONFLICT`,响应包含当前 head 元数据但不泄露无权正文。
|
||||
|
||||
### 5.2 TaskRun 并发
|
||||
|
||||
`Claim(idempotency_key, request_digest)` 通过唯一约束保证只创建一个 run。
|
||||
相同 key、相同 digest 返回既有 run;相同 key、不同 digest 返回 `IDEMPOTENCY_CONFLICT`。
|
||||
状态迁移使用 `WHERE status = expected AND version = expected_version`,影响行数为零即重新读取并分类冲突。
|
||||
幂等键按 environment、project、actor、action 和客户端 key 作用域化,并与规范化 request digest 绑定。
|
||||
幂等记录的保留期不得短于客户端重试窗口、事件重放窗口和对应业务记录的审计期。
|
||||
|
||||
### 5.3 Workspace 并发
|
||||
|
||||
Workspace ID 由服务端签发并绑定 environment、project、run、storage identity 和生命周期。
|
||||
同一 workspace 同时只有一个写 lease;续租返回 fencing token,所有写操作校验 token。
|
||||
本地 `sync.Mutex` 可减少同进程竞争,但跨副本 lease 和 token 才是正确性依据。
|
||||
文件写入采用同目录临时文件、校验、原子 rename 和目录同步;reader 只读取完整 projection marker。
|
||||
|
||||
### 5.4 FAV 并发
|
||||
|
||||
逻辑 artifact 的幂等键由终态事件、资源、payload SHA 和 policy version 决定。
|
||||
对象写入使用 content-addressed key 和 no-replace 语义;blob、manifest 全部校验完成后最后写 commit marker。
|
||||
重复 publisher 可共享已有 blob,但只有一个 registry 状态迁移为 verified。
|
||||
|
||||
### 5.5 查询与背压
|
||||
|
||||
docquery 的列表和版本历史必须分页,BatchGet 首期硬上限 100 个引用;超过上限返回 typed validation error,不自动拆成无界并发查询。
|
||||
批量读取先按 Principal 和 project scope 分组授权,再使用有界并发和数据库 statement deadline。
|
||||
API 对 actor/project/action 维度限流,后台 backfill 和 dispatcher 使用独立连接预算,不能耗尽在线读写连接池。
|
||||
SSE 慢客户端只影响传输缓冲;不能持有业务 transaction、数据库锁或阻止事件持久化。
|
||||
|
||||
## 6. 可靠事件
|
||||
|
||||
### 6.1 事件信封
|
||||
|
||||
每个持久事件至少包含:
|
||||
|
||||
```text
|
||||
event_id
|
||||
event_name
|
||||
schema_version
|
||||
environment
|
||||
project_id
|
||||
aggregate_type / aggregate_id / aggregate_version
|
||||
actor_ref
|
||||
correlation_id / causation_id
|
||||
idempotency_key
|
||||
payload_digest
|
||||
occurred_at
|
||||
```
|
||||
|
||||
事件不携带 secret、认证 token、Prompt、正文或任意文件路径。
|
||||
大 payload 先冻结到不可变 staging,事件只保存引用和 digest。
|
||||
|
||||
### 6.2 投递模型
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UC as Use case transaction
|
||||
participant DB as PostgreSQL
|
||||
participant W as Dispatcher worker
|
||||
participant S as Subscriber
|
||||
UC->>DB: business state + staging + outbox
|
||||
DB-->>UC: commit
|
||||
W->>DB: claim delivery with SKIP LOCKED
|
||||
W->>S: deliver event and fencing token
|
||||
S->>DB: idempotent side effect and inbox receipt in one transaction
|
||||
S-->>W: success or typed failure
|
||||
W->>DB: ack, retry schedule, or dead letter
|
||||
```
|
||||
|
||||
- 每个订阅者有独立 delivery 状态,不能共享单一 `published_at`。
|
||||
- 投递语义为至少一次;消费者以 event ID/inbox 唯一约束幂等。
|
||||
- 同一 aggregate 按 aggregate version 有序;不同 aggregate 可并行。
|
||||
- claim 按 `next_attempt_at, event_id` 排序并采用 aging,防止热点失败事件令旧事件永久饥饿。
|
||||
- 数据库 subscriber 的业务副作用与 inbox receipt 必须同事务;外部副作用使用业务幂等键并在回执落库前允许安全重放。
|
||||
- transient failure 指数退避;永久失败进入 dead letter,不能阻塞其他订阅者。
|
||||
- worker 认领带 lease、attempt 和 fencing token;过期 worker 无权提交 ack。
|
||||
- replay 必须指定事件范围、目标消费者、操作者和原因,并写审计记录。
|
||||
- delivery/outbox 的清理只能在所有必需消费者完成、重放窗口结束且恢复 checkpoint 已覆盖后执行;审计类事件按独立保留策略处理。
|
||||
|
||||
## 7. Workspace、账本与 FAV
|
||||
|
||||
### 7.1 三层权威
|
||||
|
||||
| 层 | 权威内容 | 清理规则 |
|
||||
|---|---|---|
|
||||
| PostgreSQL | 项目、权限、workflow、task、文档关系、版本账本、当前指针 | 依完整备份/PITR 和环境生命周期处理 |
|
||||
| workspace/state | 执行现场、临时材料、可重建 projection | 终态冻结和归档门禁通过后按 TTL 清理 |
|
||||
| FAV | 已归档 payload、manifest、receipt、tombstone | 按 retention/legal hold/delete ledger 处理 |
|
||||
|
||||
### 7.2 Workspace capability
|
||||
|
||||
跨模块 DTO 只包含:
|
||||
|
||||
```text
|
||||
workspace_id
|
||||
project_scope
|
||||
run_id
|
||||
lease_fencing_token
|
||||
schema_version
|
||||
```
|
||||
|
||||
调用方使用 `ReadArtifact(key)`、`OpenAtomicWriter(key)`、`CommitProjection(key, digest)` 等受限端口。
|
||||
artifact key 必须是受控相对标识,禁止绝对路径、`..`、空段、跨作用域 ID、symlink 和 hardlink 逃逸。
|
||||
|
||||
### 7.3 RunManifest
|
||||
|
||||
RunManifest 的规范字节、SHA、workspace ID、storage identity 和输出成员在 taskrun finalize 时持久化。
|
||||
`Evidence map[string]string` 和 `Gaps []string` 不作为核心协议;核心字段类型化,扩展只能进入带 namespace 和 schema 的 extensions。
|
||||
workspace 内 manifest 是可重建副本,不是唯一事实源。
|
||||
|
||||
### 7.4 FAV 接入
|
||||
|
||||
artifactvault 只接收服务端确认的终态文档、终态助手回复和必要附件。
|
||||
流式 chunk、候选、Prompt、工具轨迹、修复过程、临时报告和 workspace 草稿准入率必须为零。
|
||||
发布从终态事务的 staging/outbox 驱动,禁止目录扫描。
|
||||
|
||||
docquery 的读取优先级为:
|
||||
|
||||
1. 校验 Principal 和数据库 resource→project 授权图;
|
||||
2. 读取数据库 immutable ledger/current pointer;
|
||||
3. 检查并校验 workspace projection,仅作为缓存;
|
||||
4. 正文缺失但授权图完整时,可按 feature flag 从 FAV 只读降级;
|
||||
5. 权限图缺失、vault tombstone、SHA 冲突或来源不一致时 fail closed。
|
||||
|
||||
GET 不得修复数据库或覆盖 head。
|
||||
修复和恢复是独立命令,并产生审计记录。
|
||||
|
||||
## 8. 授权架构
|
||||
|
||||
### 8.1 Principal
|
||||
|
||||
Principal 由 HTTP session、服务身份或恢复凭证适配器构造,至少包含 actor ID、actor type、environment、认证来源、request/correlation ID 和明确 capabilities。
|
||||
`UserID`、`ProjectID`、role 和 duty 不能直接从业务请求体覆盖 Principal。
|
||||
|
||||
### 8.2 决策模型
|
||||
|
||||
授权决策输入为 `Principal + Action + Resource + CurrentState`。
|
||||
|
||||
| 动作类别 | 强制条件 |
|
||||
|---|---|
|
||||
| 文档读取 | 项目成员/管理员 + 分类可见性 + stage/duty 范围 |
|
||||
| 文档编辑/提交 | 写权限 + 当前 duty + 合法状态 + 有效 lease + expected head |
|
||||
| 批准/驳回 | 对应 action-level duty + 冻结实例配置 + 当前状态 |
|
||||
| TaskRun 执行/恢复 | 项目权限 + 合法 workflow/stage + 服务端解析的 backend capability |
|
||||
| FAV 降级读取 | 正常读取授权已成功 + artifact scope/hash/删除状态一致 |
|
||||
| replay/backfill | 运维 capability + 精确范围 + dry-run + 审计 |
|
||||
| scratch recovery promotion | 系统管理员 + 数据责任人确认 + scratch identity + 冲突计划无未决项 |
|
||||
|
||||
应用用例可以在事务前做快速拒绝,但状态改变必须在取得资源锁后重新读取授权相关事实并再次判定,防止成员、duty、stage 或 lease 在检查后变化。
|
||||
授权服务不可用或关系不完整时默认拒绝。
|
||||
事件携带的 actor 只用于审计;订阅者产生持久或外部副作用前重新读取当前权威状态。
|
||||
|
||||
### 8.3 后端与数据最小化
|
||||
|
||||
调用方不能提交任意 `Backend string`。
|
||||
应用层按环境、项目策略和输入敏感级别解析 `BackendRef`,agent 适配器再注入受管凭证。
|
||||
凭证不得进入 DTO、日志、事件、manifest、result 文件或 FAV。
|
||||
InputBundle 只包含完成任务必需的材料,并标记来源、分类、允许用途和生命周期。
|
||||
|
||||
## 9. 架构门禁
|
||||
|
||||
### 9.1 静态规则
|
||||
|
||||
| 规则 | 检查对象 | 例外 |
|
||||
|---|---|---|
|
||||
| 领域实现互不 import | Go production packages | 消费端口和稳定 vocabulary |
|
||||
| server 不 import repository/领域实现 | server production packages | app facade、wire DTO |
|
||||
| 表写入唯一属主 | production SQL 的 INSERT/UPDATE/DELETE/MERGE | Goose、测试 fixture、批准的修复工具 |
|
||||
| 工作区物理路径唯一属主 | production path construction/open/write | workspace/squadrun adapter |
|
||||
| 事件发布者 allowlist | event name → producer package | 无运行时动态注册旁路 |
|
||||
| 订阅者 allowlist | event name → consumer use case | replay 工具需显式 capability |
|
||||
|
||||
例外必须记录 owner、原因、范围、到期日和移除里程碑。
|
||||
warn/block 不按固定“两周”切换;每条规则在对应纵向切片清零后单独转 block。
|
||||
|
||||
### 9.2 行为门禁
|
||||
|
||||
架构检查不能替代以下测试:
|
||||
|
||||
- 同文档并发编辑、批准和 agent commit;
|
||||
- 任意事务步骤失败时全量回滚;
|
||||
- worker 崩溃、lease 过期、重复投递和多订阅者部分失败;
|
||||
- workspace 删除、换根、重启和 projection 重建;
|
||||
- FAV 降级读取与 scratch 恢复;
|
||||
- 匿名、非成员、跨用户、跨项目、错误 duty、错误 stage、授权依赖失败;
|
||||
- 真实浏览器刷新/重登/rebuild 与真实 PI 主链。
|
||||
|
||||
## 10. 可观测性与容量
|
||||
|
||||
### 10.1 必须指标
|
||||
|
||||
- 数据库事务时长、锁等待、deadlock、serialization retry、版本冲突率。
|
||||
- outbox pending/oldest age、delivery attempts、lease steal、dead letter 数。
|
||||
- projection missing/mismatch/repair、FAV degraded read、vault verification lag。
|
||||
- 授权 deny 按 action/reason 计数,不记录正文或敏感资源名。
|
||||
- workspace active lease、过期清理、fencing rejection、storage identity mismatch。
|
||||
|
||||
### 10.2 高并发基线
|
||||
|
||||
首个试点必须建立并保存基线,而不是直接承诺未经测量的容量数字:
|
||||
|
||||
- 同一文档热点写入与不同文档并行写入分开压测。
|
||||
- 1、2、4 个 app 实例下验证进程内锁不影响正确性。
|
||||
- 1、2、4 个 dispatcher 下验证无重复可见副作用、无饥饿和有界积压。
|
||||
- p50/p95/p99 延迟、锁等待、事务重试、CPU/连接池和队列年龄进入 test report。
|
||||
- 正确性门禁固定;吞吐阈值在基线评审后写入环境配置与发布 SLO,不能靠降低一致性通过。
|
||||
|
||||
## 11. 迁移与兼容
|
||||
|
||||
采用 expand → migrate → contract:
|
||||
|
||||
1. 新增兼容 schema、端口和读路径,不改变旧写入。
|
||||
2. 新写路径 shadow/dual-read 验证,禁止无审计双写。
|
||||
3. 按 checkpoint 幂等回填,异常进入 quarantine。
|
||||
4. 切主写者,保留旧 reader 一个明确发布窗口。
|
||||
5. 验证真实刷新/restart/rebuild、FAV coverage 和回滚。
|
||||
6. 删除旧路径前再次审计引用和数据差异。
|
||||
|
||||
旧 workspace 文件缺少 `schema_ver` 时按只读 legacy-v0 解析。
|
||||
首次受控 Resume 先校验并原子升级到 v1,保留可回滚备份;未知 major 版本 fail closed。
|
||||
|
||||
回滚只保证回到最近兼容点。
|
||||
已被下游里程碑消费的 schema 或契约必须按逆依赖顺序回滚,不能单独 revert 早期提交。
|
||||
|
||||
## 12. 首个交付边界
|
||||
|
||||
首个里程碑只覆盖受控文档可靠性:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Read[Authorized content read] --> Ledger[Immutable ledger]
|
||||
Ledger --> Projection{Projection aligned?}
|
||||
Projection -->|yes| Return[Return canonical content]
|
||||
Projection -->|no| Vault{Authorized FAV fallback enabled?}
|
||||
Vault -->|yes| Degraded[Return verified degraded content]
|
||||
Vault -->|no| Conflict[Typed consistency error]
|
||||
Commit[Edit or agent commit] --> Tx[Single UnitOfWork]
|
||||
Tx --> Ledger
|
||||
Tx --> Root[Document root pointer]
|
||||
Tx --> Stage[Artifact staging]
|
||||
Tx --> Event[Outbox]
|
||||
```
|
||||
|
||||
只有这个切片完成刷新、重启、并发、权限和恢复验收后,才启动其他包的物理拆分。
|
||||
|
||||
## 13. 后续扩展顺序
|
||||
|
||||
1. 文档可靠性试点。
|
||||
2. taskrun finalize、RunManifest 持久化和 FAV 集成。
|
||||
3. 逐个事件替换闭包桥。
|
||||
4. chatlog/contextasm 原地端口化,再按证据移动包。
|
||||
5. squadrun capability 化和 backend policy。
|
||||
6. app 组合根迁移。
|
||||
7. UserProjectContext 增量收权。
|
||||
8. server/SSE 收口。
|
||||
9. 错误信封与 PI Runner 协议作为独立项目实施。
|
||||
|
||||
详细依赖、文件和测试场景见整改计划。
|
||||
@@ -99,6 +99,26 @@
|
||||
|
||||
---
|
||||
|
||||
### 6. 模块化单体解耦架构
|
||||
|
||||
| 项 | 链接 |
|
||||
|-----|------|
|
||||
| 主报告 | [2026-09-14-module-decoupling-architecture.md](2026-09-14-module-decoupling-architecture.md) |
|
||||
| ADR | [../adr/2026-09-14-modular-monolith-decoupling.md](../adr/2026-09-14-modular-monolith-decoupling.md) |
|
||||
| 整改计划 | [../remediation/2026-09-14-module-decoupling-refactor-plan.md](../remediation/2026-09-14-module-decoupling-refactor-plan.md) |
|
||||
| 一句话总结 | 先以文档可靠性纵向试点验证事务、锁、授权、事件与 FAV,再按证据扩展模块边界 |
|
||||
|
||||
**核心要点**:
|
||||
|
||||
- 单一写者负责 SQL 和不变量,应用 UnitOfWork 负责跨属主原子提交;
|
||||
- 数据库行锁、事务级 advisory lock、唯一约束和 fencing token 支撑多副本并发;
|
||||
- workspace 是可清理投影,FAV 只保存已确认终态成果;
|
||||
- 事件采用逐消费者 delivery、幂等 inbox、租约、重试和 dead letter;
|
||||
- HTTP、CLI、worker、subscriber 和恢复工具使用同一逐动作授权模型;
|
||||
- 先原地端口化,行为稳定后才物理搬包。
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 在整体架构中的位置
|
||||
|
||||
```
|
||||
@@ -161,6 +181,7 @@
|
||||
├── 04-项目多工作流与智能产品经理-测试经理工作流-实施计划.md
|
||||
├── 05-Phase0-基线确认与契约冻结.md
|
||||
└── 06-Phase7-全链路验收与发布基线.md
|
||||
├── 2026-09-14-module-decoupling-architecture.md ← 模块化单体解耦
|
||||
|
||||
.tmp/ ← 中间分析文件
|
||||
├── intent-recognition/
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
# U1 architecture baseline. Import allowlist is a 2026-09-14 production go list snapshot.
|
||||
# SQL write / path / event rules apply only to production Go (not Goose, *_test.go, or fixtures).
|
||||
# Existing debt lives in exceptions with owner + milestone + expiry. New findings block immediately.
|
||||
|
||||
imports:
|
||||
agent:
|
||||
- config
|
||||
auth:
|
||||
- config
|
||||
authz:
|
||||
- docduty
|
||||
bizdev:
|
||||
- authz
|
||||
- config
|
||||
- ctxmgr
|
||||
- db
|
||||
- docduty
|
||||
- docgov
|
||||
- docrevision
|
||||
- freedoc
|
||||
- kb
|
||||
- projectcontext
|
||||
- projectworkflow
|
||||
- quote
|
||||
- squad
|
||||
- storage
|
||||
- stream
|
||||
- workflowdef
|
||||
chat:
|
||||
- bizdev
|
||||
- coordinator
|
||||
- ctxmgr
|
||||
- docgov
|
||||
- intent
|
||||
- projectinstance
|
||||
- replyexport
|
||||
- squad
|
||||
- stream
|
||||
- taskrun
|
||||
config: []
|
||||
coordinator:
|
||||
- agent
|
||||
- bizdev
|
||||
- ctxmgr
|
||||
- docgov
|
||||
- intent
|
||||
- kb
|
||||
- projectinstance
|
||||
- squad
|
||||
- stream
|
||||
ctxmgr:
|
||||
- docgov
|
||||
- replyexport
|
||||
- storage
|
||||
db: []
|
||||
docduty: []
|
||||
app: []
|
||||
app/docflow:
|
||||
- app/uow
|
||||
- docgov
|
||||
- docquery
|
||||
- docrevision
|
||||
app/uow: []
|
||||
docquery:
|
||||
- docgov
|
||||
- docrevision
|
||||
docgov:
|
||||
- app/uow
|
||||
- docduty
|
||||
- docrevision
|
||||
- fsutil
|
||||
docrevision:
|
||||
- app/uow
|
||||
- fsutil
|
||||
freedoc:
|
||||
- app/uow
|
||||
- ctxmgr
|
||||
- docrevision
|
||||
- replyexport
|
||||
fsutil: []
|
||||
intent:
|
||||
- bizdev
|
||||
kb:
|
||||
- config
|
||||
notification: []
|
||||
observability: []
|
||||
projectcontext:
|
||||
- squad
|
||||
projectinstance:
|
||||
- docduty
|
||||
- docgov
|
||||
- projectworkflow
|
||||
- workflowdef
|
||||
projectworkflow:
|
||||
- auth
|
||||
quote: []
|
||||
replyexport: []
|
||||
server:
|
||||
- agent
|
||||
- app/docflow
|
||||
- app/uow
|
||||
- auth
|
||||
- authz
|
||||
- bizdev
|
||||
- chat
|
||||
- config
|
||||
- coordinator
|
||||
- ctxmgr
|
||||
- docduty
|
||||
- docgov
|
||||
- docquery
|
||||
- docrevision
|
||||
- freedoc
|
||||
- intent
|
||||
- notification
|
||||
- observability
|
||||
- projectinstance
|
||||
- projectworkflow
|
||||
- replyexport
|
||||
- squad
|
||||
- stream
|
||||
- taskrun
|
||||
- upload
|
||||
- workflowdef
|
||||
- workspace
|
||||
squad:
|
||||
- agent
|
||||
- config
|
||||
- quote
|
||||
- stream
|
||||
- template
|
||||
storage: []
|
||||
stream: []
|
||||
taskrun:
|
||||
- app/uow
|
||||
- bizdev
|
||||
- ctxmgr
|
||||
- projectworkflow
|
||||
- stream
|
||||
template: []
|
||||
upload: []
|
||||
workflow: []
|
||||
workflow/runtime:
|
||||
- projectinstance
|
||||
- workflowdef
|
||||
workflowdef:
|
||||
- projectworkflow
|
||||
- storage
|
||||
- upload
|
||||
- workflow
|
||||
workspace: []
|
||||
|
||||
table_owners:
|
||||
document_records:
|
||||
- docgov
|
||||
chat_messages:
|
||||
- ctxmgr
|
||||
chat_turns:
|
||||
- ctxmgr
|
||||
freeform_documents:
|
||||
- freedoc
|
||||
task_runs:
|
||||
- taskrun
|
||||
document_versions:
|
||||
- docrevision
|
||||
document_snapshots:
|
||||
- docrevision
|
||||
document_change_events:
|
||||
- docrevision
|
||||
|
||||
path_owners:
|
||||
output.md:
|
||||
- squad
|
||||
- workspace
|
||||
data/dev:
|
||||
- config
|
||||
- workspace
|
||||
workspaces/:
|
||||
- squad
|
||||
- workspace
|
||||
|
||||
event_producers:
|
||||
outbox.notify:
|
||||
- docrevision
|
||||
- app/docflow
|
||||
document.agent_candidate_ready:
|
||||
- docrevision
|
||||
document.agent_version_committed:
|
||||
- docrevision
|
||||
- app/docflow
|
||||
document.downstream_impact_detected:
|
||||
- docrevision
|
||||
document.change_report_degraded:
|
||||
- docrevision
|
||||
|
||||
event_consumers:
|
||||
outbox.notify:
|
||||
- server
|
||||
- notification
|
||||
|
||||
unwrap_owners:
|
||||
- docgov
|
||||
- docrevision
|
||||
- taskrun
|
||||
- freedoc
|
||||
|
||||
exceptions:
|
||||
- kind: sql_write
|
||||
package: docduty
|
||||
file: internal/docduty/revoke.go
|
||||
symbol: document_records
|
||||
owner: docduty
|
||||
reason: duty slot revoke still writes reviewer/approver columns directly; fold into docgov repository during U3
|
||||
milestone: U3
|
||||
expires: "2026-09-28"
|
||||
- kind: sql_write
|
||||
package: docduty
|
||||
file: internal/docduty/store.go
|
||||
symbol: document_records
|
||||
owner: docduty
|
||||
reason: heal/store updates duty columns on document_records; move behind docgov repository in U3
|
||||
milestone: U3
|
||||
expires: "2026-09-28"
|
||||
- kind: sql_write
|
||||
package: docrevision
|
||||
file: internal/docrevision/pg_store.go
|
||||
symbol: document_records
|
||||
owner: docrevision
|
||||
reason: projectRootPointersTX updates controlled root pointers; U3 must keep this inside the shared UnitOfWork
|
||||
milestone: U3
|
||||
expires: "2026-09-28"
|
||||
- kind: sql_write
|
||||
package: docrevision
|
||||
file: internal/docrevision/pg_store.go
|
||||
symbol: freeform_documents
|
||||
owner: docrevision
|
||||
reason: projectRootPointersTX also projects freeform heads; keep atomic in U3 then hand write ownership back
|
||||
milestone: U3
|
||||
expires: "2026-09-28"
|
||||
- kind: sql_write
|
||||
package: projectinstance
|
||||
file: internal/projectinstance/store.go
|
||||
symbol: task_runs
|
||||
owner: projectinstance
|
||||
reason: workflow terminate cancels task_runs; U8 owns run finalization in the same UoW
|
||||
milestone: U8
|
||||
expires: "2026-10-26"
|
||||
- kind: path
|
||||
package: agent
|
||||
file: internal/agent/cursor.go
|
||||
symbol: output.md
|
||||
owner: agent
|
||||
reason: runner still names the deliverable file; U5 replaces this with WorkspaceRef
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: agent
|
||||
file: internal/agent/piagent.go
|
||||
symbol: output.md
|
||||
owner: agent
|
||||
reason: PI runner contract still uses output.md; U5 capability must hide the literal
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: bizdev
|
||||
file: internal/bizdev/service.go
|
||||
symbol: data/dev
|
||||
owner: bizdev
|
||||
reason: default data-root example string; U5 resolver owns environment roots
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: bizdev
|
||||
file: internal/bizdev/service.go
|
||||
symbol: output.md
|
||||
owner: bizdev
|
||||
reason: orchestration still joins workspace output.md; U5 removes Root/OutputDir
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: coordinator
|
||||
file: internal/coordinator/context.go
|
||||
symbol: output.md
|
||||
owner: coordinator
|
||||
reason: coordinator reads projection path; U4/U5 must go through workspace capability
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: coordinator
|
||||
file: internal/coordinator/dialogue_llm.go
|
||||
symbol: data/dev
|
||||
owner: coordinator
|
||||
reason: prompt example embeds data/dev; U5 must not leak runtime roots
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: coordinator
|
||||
file: internal/coordinator/document_summary.go
|
||||
symbol: data/dev
|
||||
owner: coordinator
|
||||
reason: summary prompt embeds data/dev; U5 must not leak runtime roots
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: coordinator
|
||||
file: internal/coordinator/engine.go
|
||||
symbol: output.md
|
||||
owner: coordinator
|
||||
reason: engine joins deliverable path; U5 WorkspaceRef
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: docgov
|
||||
file: internal/docgov/lease.go
|
||||
symbol: output.md
|
||||
owner: docgov
|
||||
reason: SaveDocumentContent still writes projection files; U5 capability + U4 ledger-first read
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: docgov
|
||||
file: internal/docgov/project_docs.go
|
||||
symbol: output.md
|
||||
owner: docgov
|
||||
reason: project doc helpers still construct output.md; U5
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: docrevision
|
||||
file: internal/docrevision/projection.go
|
||||
symbol: output.md
|
||||
owner: docrevision
|
||||
reason: InspectProjection reads workspace file; U4 treats this as optional cache only
|
||||
milestone: U4
|
||||
expires: "2026-09-28"
|
||||
- kind: path
|
||||
package: docrevision
|
||||
file: internal/docrevision/reconcile.go
|
||||
symbol: output.md
|
||||
owner: docrevision
|
||||
reason: reconcile still repairs projection files; U5
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: server
|
||||
file: internal/server/document_revision_access.go
|
||||
symbol: output.md
|
||||
owner: server
|
||||
reason: handler still reads workspace files; U4 must use docquery ledger
|
||||
milestone: U4
|
||||
expires: "2026-09-28"
|
||||
- kind: path
|
||||
package: server
|
||||
file: internal/server/document_revision_handlers.go
|
||||
symbol: output.md
|
||||
owner: server
|
||||
reason: handler error text mentions output.md; U4/U5 remove path leakage
|
||||
milestone: U4
|
||||
expires: "2026-09-28"
|
||||
- kind: path
|
||||
package: server
|
||||
file: internal/server/server.go
|
||||
symbol: workspaces/
|
||||
owner: server
|
||||
reason: server still concatenates workspaces/ roots; U5
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
- kind: path
|
||||
package: server
|
||||
file: internal/server/task_finalize.go
|
||||
symbol: output.md
|
||||
owner: server
|
||||
reason: finalize still joins output.md; U8 verified receipt
|
||||
milestone: U8
|
||||
expires: "2026-10-26"
|
||||
- kind: path
|
||||
package: storage
|
||||
file: internal/storage/cas.go
|
||||
symbol: data/dev
|
||||
owner: storage
|
||||
reason: CAS default example path; U5 resolver
|
||||
milestone: U5
|
||||
expires: "2026-10-12"
|
||||
|
||||
baseline_counts:
|
||||
import: 0
|
||||
sql_write: 5
|
||||
path: 17
|
||||
event: 0
|
||||
@@ -0,0 +1,132 @@
|
||||
# 文档可靠性纵向试点(M0–M1 / U1–U4)测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
受控文档写路径曾把账本、root pointer 与通知拆到提交后回调;读路径在 ledger 已存在时仍被 `InspectProjection` 绑架成 409。刷新、重登或 projection 缺失会被前端当成 404 再 hydrate。本轮只批准 M0–M1(U1–U4):门禁纠偏、UnitOfWork、文档命令同事务、授权后账本读。明确不授权 FAV 降级读、拆 chat/squad、占槽 rebuild。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- U1:四类生产规则可重放;新违规 block;Goose/`*_test.go` 不记生产写者债。
|
||||
- U2:跨属主同事务;第二属主/staging/commit 失败零残留;仅 `40001`/`40P01` 整事务重试。
|
||||
- U3:人工编辑 / agent commit / approve / reject 锁后重鉴权;同 ETag 一赢一输。
|
||||
- U4:projection missing/drift/empty 仍返回账本正文 + typed `consistency`;GET 零写入。
|
||||
- 分栏必须各自签字。未占集成槽不得把 `:8080` 旧镜像或 `docker compose restart` 写成 rebuild 证据。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| OS | Linux 7.0.0-31-generic |
|
||||
| Go | 1.27.1 |
|
||||
| 分支 / worktree | `refactor/document-reliability-pilot` / `.worktrees/refactor-document-reliability-pilot` |
|
||||
| 实现基线 | `dev@a8f9451` → `c754f77`(另含本报告前的 `fix(docflow)` 竞态修复) |
|
||||
| Unit / stub | `go test -short ./...`(**未**设置 `TEST_DATABASE_URL`) |
|
||||
| PostgreSQL | 隔离库 `agent_rel_itest`(`shuimu-system-pg-test:5434`),测完 `DROP DATABASE`;禁止 `agent_system_dev/test/main` |
|
||||
| 目标包 race | `./internal/app/uow ./internal/app/docflow ./internal/docquery ./internal/archtest` |
|
||||
| 前端 | `npx tsx --test src/lib/controlledDocHydrate.test.ts`(17 passed) |
|
||||
| 真实 PI / 浏览器 / rebuild | **未执行**。集成槽 idle;CURRENT 禁止 M1 未签字 rebuild;`:8080` 仍是治理鉴权旧镜像,不能当本分支证据 |
|
||||
| 证据 | `.doc/test-reports/assets/2026-09-14-document-reliability-pilot/` |
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
| 栏 | 期望 |
|
||||
|----|------|
|
||||
| Unit / stub | 全绿;arch-check `--mode warn` 过门;characterization 同 ETag 一赢、approve/reject 唯一终态 |
|
||||
| PG 事务与并发 | U2 故障注入绿;同 ETag 双会话 HumanEdit 仅一赢;head revision=1 |
|
||||
| 故障注入 | 第二属主失败 / staging 失败 / commit 失败零行;`40001` 整事务重跑一条结果 |
|
||||
| 真实 PI | 本分支容器内真实 `pi-agent` 全链路。**本轮不做** |
|
||||
| 真实浏览器 | 项目 143 等价 hazard + 新建项目打开/刷新/重登正文 100%,404/409/projection-missing=0。**本轮不做** |
|
||||
| restart/rebuild | 占槽后 `env-service.sh dev rebuild`。**本轮不做** |
|
||||
| 跨用户负例 | 匿名/未知文档/授权拒绝 → `docquery.ErrClosed`,不泄露存在性 |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 Unit / stub
|
||||
|
||||
- **测试输入**: `go test -short ./...`
|
||||
- **实际输出**: 38 个有测试包 `ok`,无 `FAIL`。摘录:
|
||||
|
||||
```
|
||||
ok chat-agent-system/internal/app/docflow
|
||||
ok chat-agent-system/internal/app/uow
|
||||
ok chat-agent-system/internal/archtest
|
||||
ok chat-agent-system/internal/docquery
|
||||
ok chat-agent-system/internal/docrevision
|
||||
ok chat-agent-system/internal/server 9.796s
|
||||
```
|
||||
|
||||
- **评价判定**: **PASS**
|
||||
- **性能与耗时**: `internal/server` 约 9.8s,其余亚秒到数秒。日志:`assets/2026-09-14-document-reliability-pilot/go-test-short.txt`
|
||||
|
||||
`./scripts/arch-check.sh --mode warn` → `arch-check warn: production gate passed`。`--mode block` 仍会因既有例外基线失败(U1 设计如此)。
|
||||
|
||||
### 5.2 PostgreSQL 事务与并发
|
||||
|
||||
- **测试输入**: 隔离库 `TEST_DATABASE_URL` → `./internal/app/uow`、`./internal/app/docflow`、`./internal/docquery`;`./internal/docrevision -run TestDocumentCommitConcurrencyPG`
|
||||
- **实际输出**:
|
||||
|
||||
```
|
||||
ok chat-agent-system/internal/app/uow 0.065s
|
||||
ok chat-agent-system/internal/app/docflow 0.003s
|
||||
ok chat-agent-system/internal/docquery 0.003s
|
||||
ok chat-agent-system/internal/docrevision 0.024s
|
||||
```
|
||||
|
||||
- **评价判定**: **PASS**(1–2 会话级)。**未**跑 4 个独立 app 进程。
|
||||
- **性能与耗时**: UoW 包 65ms;并发编辑 24ms。日志:`go-test-pg-race.txt`
|
||||
|
||||
### 5.3 故障注入
|
||||
|
||||
- **测试输入**: `internal/app/uow` PG 用例(第二属主失败、staging/outbox 失败、commit 失败、`40001` 重试、version/permission/digest 不重试)
|
||||
- **实际输出**: `ok chat-agent-system/internal/app/uow 0.065s`
|
||||
- **评价判定**: **PASS**
|
||||
- **性能与耗时**: 与 5.2 同一次隔离库运行
|
||||
|
||||
### 5.4 真实 PI
|
||||
|
||||
- **测试输入**: 未切本分支容器,未跑真实 `MiniMax-M2.7-highspeed` 编制链路
|
||||
- **实际输出**: 无
|
||||
- **评价判定**: **FAIL(未测)** — 禁止用 stub 冒充终态
|
||||
- **性能与耗时**: n/a
|
||||
|
||||
### 5.5 真实浏览器
|
||||
|
||||
- **测试输入**: 未对项目 143 / 新建项目做本分支页面打开、刷新、重登
|
||||
- **实际输出**: 仅前端单测 17/17(`hydrate treats projection missing as ledger content, not 404`)
|
||||
- **评价判定**: 单测 **PASS**;真实页面 **FAIL(未测)**
|
||||
- **性能与耗时**: hydrate 套件 149ms
|
||||
|
||||
### 5.6 restart / rebuild
|
||||
|
||||
- **测试输入**: 未占集成槽,未执行 `./scripts/env-service.sh dev rebuild`
|
||||
- **实际输出**: 无。`:8080` 仍为既有 dev 镜像(治理鉴权 G5),不得当作本分支部署证据
|
||||
- **评价判定**: **FAIL(未测)**
|
||||
- **性能与耗时**: n/a
|
||||
|
||||
### 5.7 跨用户负例
|
||||
|
||||
- **测试输入**: `TestGetFailClosed`(匿名、Authorize 拒绝、未知 doc_id);`TestReauthDeniesWrongAuthor`;`TestBatchGetLimitsAndCrossProject`(101 条 / 跨项目)
|
||||
- **实际输出**: 均失败关闭;未知文档与拒绝同一 `ErrClosed`
|
||||
- **评价判定**: 服务端单测 **PASS**。HTTP 跨登录浏览器负例 **未测**
|
||||
- **性能与耗时**: 亚秒
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
| 命令 | 结果 |
|
||||
|------|------|
|
||||
| `go vet ./...` | PASS(U4 提交前) |
|
||||
| `go test -short ./...` | PASS,38 ok |
|
||||
| 隔离库 `go test ./internal/app/uow ./internal/app/docflow ./internal/docquery` | PASS |
|
||||
| `go test ./internal/docrevision -run TestDocumentCommitConcurrencyPG` | PASS |
|
||||
| `go test -race` 目标包 | PASS(修复 `Commands.Auth` 并发写入后) |
|
||||
| `arch-check --mode warn` | PASS |
|
||||
| `tsx --test controlledDocHydrate.test.ts` | 17 passed |
|
||||
| 全仓 `go test`(带 `TEST_DATABASE_URL` 且无 `-short`) | 未作为本报告门禁;会把无关 workflow PG 和「5434=共享 test 口」拒绝用例搅在一起 |
|
||||
|
||||
`-race` 初跑曾在 `run()` 里给共享 `Commands.Auth` 赋值,判定为数据竞争;已改为只读 `authorizer()`。
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
U1–U4 代码已在实现分支落地,Unit/stub、隔离库事务/故障注入、目标包 race 与前端 hydrate 单测通过。用户可感知的主修复(ledger-first GET)**没有**真实 PI、真实浏览器、restart/rebuild 签字;1/2/4 进程一致性只验证到双会话。按计划停走门,不得用「部分通过」打开 U5–U12。
|
||||
|
||||
停止扩面
|
||||
@@ -0,0 +1,11 @@
|
||||
===== uow/docflow/docquery PG+unit =====
|
||||
ok chat-agent-system/internal/app/uow 0.065s
|
||||
ok chat-agent-system/internal/app/docflow 0.003s
|
||||
ok chat-agent-system/internal/docquery 0.003s
|
||||
===== concurrency PG =====
|
||||
ok chat-agent-system/internal/docrevision 0.024s
|
||||
===== race target =====
|
||||
ok chat-agent-system/internal/app/uow 1.122s
|
||||
ok chat-agent-system/internal/app/docflow 1.010s
|
||||
ok chat-agent-system/internal/docquery 1.009s
|
||||
ok chat-agent-system/internal/archtest 1.742s
|
||||
@@ -0,0 +1,44 @@
|
||||
ok chat-agent-system/cmd/bizdev-cli (cached)
|
||||
ok chat-agent-system/cmd/document-content-reconcile (cached)
|
||||
? chat-agent-system/cmd/document-revision-backfill [no test files]
|
||||
? chat-agent-system/cmd/recategorize [no test files]
|
||||
? chat-agent-system/cmd/server [no test files]
|
||||
ok chat-agent-system/internal/agent (cached)
|
||||
ok chat-agent-system/internal/app/docflow 0.003s
|
||||
ok chat-agent-system/internal/app/uow (cached)
|
||||
ok chat-agent-system/internal/archtest 0.075s
|
||||
ok chat-agent-system/internal/auth 0.832s
|
||||
ok chat-agent-system/internal/authz (cached)
|
||||
ok chat-agent-system/internal/bizdev (cached)
|
||||
ok chat-agent-system/internal/chat (cached)
|
||||
ok chat-agent-system/internal/config (cached)
|
||||
ok chat-agent-system/internal/coordinator (cached)
|
||||
ok chat-agent-system/internal/ctxmgr (cached)
|
||||
ok chat-agent-system/internal/db (cached)
|
||||
ok chat-agent-system/internal/docduty (cached)
|
||||
ok chat-agent-system/internal/docgov (cached)
|
||||
ok chat-agent-system/internal/docquery (cached)
|
||||
ok chat-agent-system/internal/docrevision 0.005s
|
||||
ok chat-agent-system/internal/freedoc 0.021s
|
||||
ok chat-agent-system/internal/fsutil (cached)
|
||||
ok chat-agent-system/internal/intent (cached)
|
||||
ok chat-agent-system/internal/kb (cached)
|
||||
ok chat-agent-system/internal/notification 0.052s
|
||||
? chat-agent-system/internal/observability [no test files]
|
||||
ok chat-agent-system/internal/projectcontext (cached)
|
||||
ok chat-agent-system/internal/projectinstance (cached)
|
||||
ok chat-agent-system/internal/projectworkflow (cached)
|
||||
ok chat-agent-system/internal/quote (cached)
|
||||
ok chat-agent-system/internal/replyexport (cached)
|
||||
ok chat-agent-system/internal/server 9.796s
|
||||
ok chat-agent-system/internal/squad (cached)
|
||||
ok chat-agent-system/internal/storage (cached)
|
||||
ok chat-agent-system/internal/stream (cached)
|
||||
ok chat-agent-system/internal/taskrun 0.149s
|
||||
ok chat-agent-system/internal/template (cached)
|
||||
ok chat-agent-system/internal/upload (cached)
|
||||
? chat-agent-system/internal/workflow [no test files]
|
||||
ok chat-agent-system/internal/workflow/runtime (cached)
|
||||
ok chat-agent-system/internal/workflowdef 0.003s
|
||||
ok chat-agent-system/internal/workspace (cached)
|
||||
? chat-agent-system/templates [no test files]
|
||||
@@ -4,9 +4,12 @@ go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/pressly/goose/v3 v3.24.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/xuri/excelize/v2 v2.11.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/text v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -17,7 +20,6 @@ require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/pressly/goose/v3 v3.24.1 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.7 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
@@ -28,6 +30,4 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,6 +2,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -14,12 +20,18 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
|
||||
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
|
||||
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
@@ -49,12 +61,10 @@ golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -63,3 +73,17 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package docflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionEdit Action = "document.edit"
|
||||
ActionApprove Action = "document.approve"
|
||||
ActionReject Action = "document.reject"
|
||||
ActionCommit Action = "document.agent_commit"
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
UserID int64
|
||||
Name string
|
||||
Role docgov.UserRole
|
||||
}
|
||||
|
||||
type HumanEditCommand struct {
|
||||
DocID string
|
||||
Request docrevision.HumanRevisionRequest
|
||||
}
|
||||
|
||||
type AgentCommitCommand struct {
|
||||
DocID string
|
||||
CandidateID int64
|
||||
Decision docrevision.CommitAgentDecision
|
||||
}
|
||||
|
||||
type DecisionCommand struct {
|
||||
DocID string
|
||||
Comment string
|
||||
RejectReason string
|
||||
RollbackTo docgov.DocumentState
|
||||
}
|
||||
|
||||
type Commands struct {
|
||||
UoW uow.UnitOfWork
|
||||
Gov *docgov.GovernanceEngine
|
||||
Rev *docrevision.Service
|
||||
Auth Authorizer
|
||||
}
|
||||
|
||||
type Authorizer interface {
|
||||
Authorize(ctx context.Context, p Principal, rec *docgov.DocumentRecord, action Action) error
|
||||
}
|
||||
|
||||
type dutyAuthorizer struct{}
|
||||
|
||||
func DutyAuthorizer() Authorizer { return dutyAuthorizer{} }
|
||||
|
||||
func (dutyAuthorizer) Authorize(_ context.Context, p Principal, rec *docgov.DocumentRecord, action Action) error {
|
||||
if rec == nil {
|
||||
return uow.ErrPermissionDenied
|
||||
}
|
||||
switch action {
|
||||
case ActionEdit, ActionCommit:
|
||||
if rec.AuthorID != 0 && p.UserID != 0 && rec.AuthorID != p.UserID {
|
||||
return uow.ErrPermissionDenied
|
||||
}
|
||||
case ActionApprove, ActionReject:
|
||||
if rec.ApproverID != 0 && p.UserID != 0 && rec.ApproverID != p.UserID {
|
||||
return uow.ErrPermissionDenied
|
||||
}
|
||||
default:
|
||||
return uow.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Commands) run(ctx context.Context, docID string, p Principal, action Action, fn func(context.Context, uow.TxScope) error) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("docflow: commands required")
|
||||
}
|
||||
auth := c.authorizer()
|
||||
if c.UoW == nil {
|
||||
if err := c.authorizeUnlocked(ctx, docID, p, action); err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(ctx, nil)
|
||||
}
|
||||
return c.UoW.Within(ctx, func(scope uow.TxScope) error {
|
||||
ctx = uow.WithScope(ctx, scope)
|
||||
if docID != "" && c.Gov != nil && c.Gov.UsingPG() {
|
||||
rec, err := (docgov.Repository{}).GetForUpdate(ctx, scope, docID)
|
||||
if err != nil {
|
||||
if action == ActionApprove || action == ActionReject {
|
||||
return err
|
||||
}
|
||||
} else if err := auth.Authorize(ctx, p, rec, action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fn(ctx, scope)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Commands) authorizer() Authorizer {
|
||||
if c != nil && c.Auth != nil {
|
||||
return c.Auth
|
||||
}
|
||||
return DutyAuthorizer()
|
||||
}
|
||||
|
||||
func (c *Commands) authorizeUnlocked(ctx context.Context, docID string, p Principal, action Action) error {
|
||||
if docID == "" || c.Gov == nil {
|
||||
return nil
|
||||
}
|
||||
rec, err := c.Gov.GetDocument(ctx, docID)
|
||||
if err != nil || rec == nil {
|
||||
if action == ActionApprove || action == ActionReject {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uow.ErrPermissionDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return c.authorizer().Authorize(ctx, p, rec, action)
|
||||
}
|
||||
|
||||
func (c *Commands) HumanEdit(ctx context.Context, p Principal, cmd HumanEditCommand) (*docrevision.HumanRevisionResult, error) {
|
||||
var out *docrevision.HumanRevisionResult
|
||||
err := c.run(ctx, cmd.DocID, p, ActionEdit, func(ctx context.Context, _ uow.TxScope) error {
|
||||
res, err := c.Rev.CommitHumanRevision(ctx, cmd.Request)
|
||||
out = res
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *Commands) AgentCommit(ctx context.Context, p Principal, cmd AgentCommitCommand) (*docrevision.RevisionState, error) {
|
||||
var out *docrevision.RevisionState
|
||||
err := c.run(ctx, cmd.DocID, p, ActionCommit, func(ctx context.Context, _ uow.TxScope) error {
|
||||
st, err := c.Rev.CommitAgentVersion(ctx, cmd.DocID, cmd.CandidateID, cmd.Decision)
|
||||
out = st
|
||||
return err
|
||||
})
|
||||
if err == nil && c.UoW != nil && c.Rev != nil && c.Rev.Notify && c.Rev.OnOutbox != nil && out != nil {
|
||||
_ = c.Rev.OnOutbox(ctx, "document.agent_version_committed", map[string]any{
|
||||
"doc_id": cmd.DocID, "version_no": out.CurrentVersionNo,
|
||||
})
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *Commands) Approve(ctx context.Context, p Principal, cmd DecisionCommand) (*docgov.DocumentRecord, error) {
|
||||
var out *docgov.DocumentRecord
|
||||
err := c.run(ctx, cmd.DocID, p, ActionApprove, func(ctx context.Context, _ uow.TxScope) error {
|
||||
doc, err := c.Gov.ApproverApprove(ctx, cmd.DocID, p.UserID, p.Role, p.Name, cmd.Comment)
|
||||
out = doc
|
||||
return err
|
||||
})
|
||||
if err == nil && out != nil && c.UoW != nil && c.Gov != nil {
|
||||
c.Gov.RunApprovedHooksAfterCommit(ctx, out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *Commands) Reject(ctx context.Context, p Principal, cmd DecisionCommand) (*docgov.DocumentRecord, error) {
|
||||
var out *docgov.DocumentRecord
|
||||
err := c.run(ctx, cmd.DocID, p, ActionReject, func(ctx context.Context, _ uow.TxScope) error {
|
||||
doc, err := c.Gov.ApproverReject(ctx, cmd.DocID, p.UserID, p.Role, p.Name, cmd.RejectReason, cmd.RollbackTo)
|
||||
out = doc
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package docflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func TestHumanEditCharacterizationSameETagOneWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rev := docrevision.NewService(nil)
|
||||
st, err := rev.OpenTemplateVersion(ctx, "doc_flow_etag", "# base\n", docrevision.ActorSnapshot{}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds := &Commands{Rev: rev}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
wins, losses := 0, 0
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := cmds.HumanEdit(ctx, Principal{UserID: int64(i + 1)}, HumanEditCommand{
|
||||
DocID: "doc_flow_etag",
|
||||
Request: docrevision.HumanRevisionRequest{
|
||||
DocID: "doc_flow_etag", Markdown: fmt.Sprintf("# base\n\nedit %d\n", i),
|
||||
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
||||
IdempotencyKey: fmt.Sprintf("flow-%d", i), ActorUserID: int64(i + 1), LifecycleState: "drafting",
|
||||
},
|
||||
})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if err == docrevision.ErrHeadConflict {
|
||||
losses++
|
||||
} else {
|
||||
t.Errorf("unexpected %v", err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if wins != 1 || losses != 7 {
|
||||
t.Fatalf("wins=%d losses=%d", wins, losses)
|
||||
}
|
||||
got, err := rev.GetRevisionState(ctx, "doc_flow_etag")
|
||||
if err != nil || got.HeadRevisionNo != 1 {
|
||||
t.Fatalf("head=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveRejectCharacterizationOneTerminal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gov := docgov.NewGovernanceEngine(nil)
|
||||
doc, err := gov.CreateOrGetDocument(ctx, "doc_flow_appr", 1, "intake", "t", "V0.5_feasibility", "", 1, "A", 2, "R", 3, "P")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gov.SubmitForReview(ctx, doc.DocID, 1, docgov.RoleAuthor, "A"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gov.ReviewApprove(ctx, doc.DocID, 2, docgov.RoleReviewer, "R", "ok"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds := &Commands{Gov: gov}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan string, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
doc, err := cmds.Approve(ctx, Principal{UserID: 3, Role: docgov.RoleApprover, Name: "P"}, DecisionCommand{DocID: doc.DocID})
|
||||
if err == nil && doc != nil {
|
||||
results <- string(doc.CurrentState)
|
||||
} else {
|
||||
results <- "err"
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
doc, err := cmds.Reject(ctx, Principal{UserID: 3, Role: docgov.RoleApprover, Name: "P"}, DecisionCommand{
|
||||
DocID: doc.DocID, RejectReason: "no", RollbackTo: docgov.StateDrafting,
|
||||
})
|
||||
if err == nil && doc != nil {
|
||||
results <- string(doc.CurrentState)
|
||||
} else {
|
||||
results <- "err"
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(results)
|
||||
var states []string
|
||||
for s := range results {
|
||||
states = append(states, s)
|
||||
}
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("%v", states)
|
||||
}
|
||||
approved, rejected := 0, 0
|
||||
for _, s := range states {
|
||||
switch s {
|
||||
case string(docgov.StateApproved):
|
||||
approved++
|
||||
case string(docgov.StateDrafting), string(docgov.StateRejected):
|
||||
rejected++
|
||||
}
|
||||
}
|
||||
if approved+rejected != 1 {
|
||||
t.Fatalf("expected one legal terminal, got %v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdempotentHumanEditSameDigest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rev := docrevision.NewService(nil)
|
||||
st, err := rev.OpenTemplateVersion(ctx, "doc_flow_idemp", "# base\n", docrevision.ActorSnapshot{}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds := &Commands{Rev: rev}
|
||||
req := docrevision.HumanRevisionRequest{
|
||||
DocID: "doc_flow_idemp", Markdown: "# base\n\nedit\n",
|
||||
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
||||
IdempotencyKey: "same-key", ActorUserID: 1, LifecycleState: "drafting",
|
||||
}
|
||||
first, err := cmds.HumanEdit(ctx, Principal{UserID: 1}, HumanEditCommand{DocID: req.DocID, Request: req})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := cmds.HumanEdit(ctx, Principal{UserID: 1}, HumanEditCommand{DocID: req.DocID, Request: req})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.State.HeadSnapshotID != second.State.HeadSnapshotID {
|
||||
t.Fatal("same key+digest must replay")
|
||||
}
|
||||
req.Markdown = "# base\n\nother digest\n"
|
||||
if _, err := cmds.HumanEdit(ctx, Principal{UserID: 1}, HumanEditCommand{DocID: req.DocID, Request: req}); err != docrevision.ErrIdempotencyConflict {
|
||||
t.Fatalf("different digest: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReauthDeniesWrongAuthor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gov := docgov.NewGovernanceEngine(nil)
|
||||
if _, err := gov.CreateOrGetDocument(ctx, "doc_flow_authz", 1, "intake", "t", "V0.5_feasibility", "", 1, "A", 2, "R", 3, "P"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rev := docrevision.NewService(nil)
|
||||
st, err := rev.OpenTemplateVersion(ctx, "doc_flow_authz", "# base\n", docrevision.ActorSnapshot{}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds := &Commands{Gov: gov, Rev: rev}
|
||||
_, err = cmds.HumanEdit(ctx, Principal{UserID: 99}, HumanEditCommand{
|
||||
DocID: "doc_flow_authz",
|
||||
Request: docrevision.HumanRevisionRequest{
|
||||
DocID: "doc_flow_authz", Markdown: "# base\n\nnope\n",
|
||||
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
||||
IdempotencyKey: "authz-deny", ActorUserID: 99, LifecycleState: "drafting",
|
||||
},
|
||||
})
|
||||
if err != uow.ErrPermissionDenied {
|
||||
t.Fatalf("want permission denied, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package docflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"chat-agent-system/internal/docquery"
|
||||
)
|
||||
|
||||
// Query is the authorized read model for controlled documents.
|
||||
type Query struct {
|
||||
Docs *docquery.Service
|
||||
}
|
||||
|
||||
func (q *Query) Get(ctx context.Context, userID int64, docID string) (*docquery.ContentView, error) {
|
||||
if q == nil || q.Docs == nil {
|
||||
return nil, docquery.ErrClosed
|
||||
}
|
||||
return q.Docs.Get(ctx, docquery.Principal{UserID: userID}, docquery.DocRef{DocID: docID})
|
||||
}
|
||||
|
||||
func (q *Query) BatchGet(ctx context.Context, userID int64, docIDs []string) ([]*docquery.ContentView, error) {
|
||||
if q == nil || q.Docs == nil {
|
||||
return nil, docquery.ErrClosed
|
||||
}
|
||||
refs := make([]docquery.DocRef, len(docIDs))
|
||||
for i, id := range docIDs {
|
||||
refs[i] = docquery.DocRef{DocID: id}
|
||||
}
|
||||
return q.Docs.BatchGet(ctx, docquery.Principal{UserID: userID}, refs)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package uow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type pgScope struct {
|
||||
ctx context.Context
|
||||
tx pgx.Tx
|
||||
done bool
|
||||
}
|
||||
|
||||
func (s *pgScope) Context() context.Context { return s.ctx }
|
||||
|
||||
// AsPG returns the bound pgx.Tx for owner repositories only.
|
||||
// Application / docflow code must not call this; archtest enforces unwrap owners.
|
||||
func AsPG(scope TxScope) (pgx.Tx, error) {
|
||||
s, ok := scope.(*pgScope)
|
||||
if !ok || s == nil || s.tx == nil {
|
||||
return nil, fmt.Errorf("uow: scope is not a postgres transaction")
|
||||
}
|
||||
if s.done {
|
||||
return nil, ErrClosedScope
|
||||
}
|
||||
return s.tx, nil
|
||||
}
|
||||
|
||||
type PG struct {
|
||||
Pool *pgxpool.Pool
|
||||
Policy RetryPolicy
|
||||
Now func() time.Time
|
||||
Sleep func(time.Duration)
|
||||
Rand *rand.Rand
|
||||
BeforeBegin func(context.Context) error
|
||||
AfterBegin func(TxScope) error
|
||||
BeforeCommit func(TxScope) error
|
||||
}
|
||||
|
||||
func NewPG(pool *pgxpool.Pool) *PG {
|
||||
return &PG{Pool: pool, Policy: DefaultRetryPolicy()}
|
||||
}
|
||||
|
||||
func (p *PG) Within(ctx context.Context, fn func(TxScope) error) error {
|
||||
if p == nil || p.Pool == nil {
|
||||
return fmt.Errorf("uow: postgres pool required")
|
||||
}
|
||||
if fn == nil {
|
||||
return fmt.Errorf("uow: command required")
|
||||
}
|
||||
policy := p.Policy
|
||||
if policy.MaxAttempts < 1 {
|
||||
policy.MaxAttempts = DefaultMaxAttempts
|
||||
}
|
||||
if policy.LockTimeout <= 0 {
|
||||
policy.LockTimeout = DefaultLockTimeout
|
||||
}
|
||||
var last error
|
||||
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
|
||||
last = p.one(ctx, policy.LockTimeout, fn)
|
||||
if last == nil {
|
||||
return nil
|
||||
}
|
||||
if !IsSerialization(last) || IsNonRetryable(last) {
|
||||
return last
|
||||
}
|
||||
if attempt == policy.MaxAttempts {
|
||||
return last
|
||||
}
|
||||
p.backoff(attempt)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func (p *PG) one(ctx context.Context, lockWait time.Duration, fn func(TxScope) error) error {
|
||||
if p.BeforeBegin != nil {
|
||||
if err := p.BeforeBegin(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
remain := time.Until(dl)
|
||||
if remain > 0 && lockWait > remain {
|
||||
lockWait = remain
|
||||
}
|
||||
}
|
||||
tx, err := p.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := &pgScope{ctx: ctx, tx: tx}
|
||||
defer func() {
|
||||
scope.done = true
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
if _, err := tx.Exec(ctx, fmt.Sprintf("SET LOCAL lock_timeout = '%dms'", lockWait.Milliseconds())); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.AfterBegin != nil {
|
||||
if err := p.AfterBegin(scope); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := fn(scope); err != nil {
|
||||
return classifyLock(err)
|
||||
}
|
||||
if p.BeforeCommit != nil {
|
||||
if err := p.BeforeCommit(scope); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return classifyLock(err)
|
||||
}
|
||||
scope.done = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PG) backoff(attempt int) {
|
||||
sleep := p.Sleep
|
||||
if sleep == nil {
|
||||
sleep = time.Sleep
|
||||
}
|
||||
n := 10
|
||||
if p.Rand != nil {
|
||||
n = 5 + p.Rand.Intn(20)
|
||||
} else {
|
||||
n = 5 + rand.Intn(20)
|
||||
}
|
||||
sleep(time.Duration(n*attempt) * time.Millisecond)
|
||||
}
|
||||
|
||||
func IsSerialization(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "40001" || pgErr.Code == "40P01"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsNonRetryable(err error) bool {
|
||||
return errors.Is(err, ErrVersionConflict) ||
|
||||
errors.Is(err, ErrPermissionDenied) ||
|
||||
errors.Is(err, ErrDigestConflict)
|
||||
}
|
||||
|
||||
func classifyLock(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "55P03": // lock_not_available
|
||||
return ErrBusy
|
||||
case "57014": // query_canceled / statement timeout
|
||||
if strings.Contains(strings.ToLower(pgErr.Message), "lock") {
|
||||
return ErrBusy
|
||||
}
|
||||
}
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "lock timeout") || strings.Contains(msg, "canceling statement due to lock timeout") {
|
||||
return ErrBusy
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package uow_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/db"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func testPool(t *testing.T) (*pgxpool.Pool, context.Context) {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("postgresql fault injection is not part of -short")
|
||||
}
|
||||
dsn := db.SkipIfNoTestDatabaseURL(t)
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return pool, ctx
|
||||
}
|
||||
|
||||
func init() {
|
||||
// ResolveMigrationsDir is cwd-relative; tests in this package start under internal/app/uow.
|
||||
if _, err := os.Stat("deploy/postgres/goose"); err == nil {
|
||||
return
|
||||
}
|
||||
for _, cand := range []string{"../../..", "../../../.."} {
|
||||
if fi, err := os.Stat(cand + "/deploy/postgres/goose"); err == nil && fi.IsDir() {
|
||||
_ = os.Chdir(cand)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedProject(t *testing.T, ctx context.Context, pool *pgxpool.Pool, suffix string) int64 {
|
||||
t.Helper()
|
||||
var projectID int64
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projects (code, name, status)
|
||||
VALUES ($1, $2, 'active')
|
||||
RETURNING id
|
||||
`, "uow-"+suffix, "uow "+suffix).Scan(&projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_change_events WHERE doc_id LIKE $1`, "uow_"+suffix+"%")
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_versions WHERE doc_id LIKE $1`, "uow_"+suffix+"%")
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_snapshots WHERE doc_id LIKE $1`, "uow_"+suffix+"%")
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id=$1`, projectID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id=$1`, projectID)
|
||||
})
|
||||
return projectID
|
||||
}
|
||||
|
||||
func shaBody(body string) string {
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func writeOwners(ctx context.Context, scope uow.TxScope, projectID int64, docID, body string) error {
|
||||
if err := (docgov.Repository{}).InsertRoot(ctx, scope, docgov.DocumentRecord{
|
||||
DocID: docID, ProjectID: projectID, TaskKey: "intake", Title: docID,
|
||||
ContentSHA256: shaBody(body), WorkDir: "ws/" + docID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
sha := shaBody(body)
|
||||
if err := (docrevision.Repository{}).InsertBlob(ctx, scope, sha, body); err != nil {
|
||||
return err
|
||||
}
|
||||
sid, err := (docrevision.Repository{}).InsertSnapshot(ctx, scope, docrevision.Snapshot{
|
||||
DocID: docID, ContentSHA256: sha, SourceKind: docrevision.SourceTemplate,
|
||||
ActorRef: "template", CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vid, err := (docrevision.Repository{}).InsertVersion(ctx, scope, docrevision.Version{
|
||||
DocID: docID, VersionNo: 1, Status: docrevision.VersionOpen, OriginKind: docrevision.OriginTemplate,
|
||||
BaselineSnapshotID: sid, HeadSnapshotID: sid, OpenedAt: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = (docrevision.Repository{}).InsertEvent(ctx, scope, docrevision.ChangeEvent{
|
||||
DocID: docID, VersionID: vid, EventNo: 1, ToSnapshotID: sid,
|
||||
ChangeKind: docrevision.ChangeImport, ActorKind: docrevision.ActorTemplate,
|
||||
ActorRef: "template", IdempotencyKey: "uow:" + docID, CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func countDoc(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string) (records, snaps, events int) {
|
||||
t.Helper()
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM document_records WHERE doc_id=$1`, docID).Scan(&records); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM document_snapshots WHERE doc_id=$1`, docID).Scan(&snaps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM document_change_events WHERE doc_id=$1`, docID).Scan(&events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestSecondOwnerFailureRollsBackFirst(t *testing.T) {
|
||||
pool, ctx := testPool(t)
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
projectID := seedProject(t, ctx, pool, suffix)
|
||||
docID := "uow_" + suffix + "_rollback"
|
||||
work := uow.NewPG(pool)
|
||||
err := work.Within(ctx, func(scope uow.TxScope) error {
|
||||
if err := (docgov.Repository{}).InsertRoot(ctx, scope, docgov.DocumentRecord{
|
||||
DocID: docID, ProjectID: projectID, Title: docID, TaskKey: "intake",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("ledger write failed")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
r, s, e := countDoc(t, ctx, pool, docID)
|
||||
if r != 0 || s != 0 || e != 0 {
|
||||
t.Fatalf("partial commit records=%d snaps=%d events=%d", r, s, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagingFailureLeavesNoLedgerOrRoot(t *testing.T) {
|
||||
pool, ctx := testPool(t)
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
projectID := seedProject(t, ctx, pool, suffix)
|
||||
docID := "uow_" + suffix + "_staging"
|
||||
work := uow.NewPG(pool)
|
||||
err := work.Within(ctx, func(scope uow.TxScope) error {
|
||||
if err := writeOwners(ctx, scope, projectID, docID, "# staging\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("outbox/staging write failed")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected staging failure")
|
||||
}
|
||||
r, s, e := countDoc(t, ctx, pool, docID)
|
||||
if r != 0 || s != 0 || e != 0 {
|
||||
t.Fatalf("staging failure leaked rows records=%d snaps=%d events=%d", r, s, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitFailureHasNoSideEffects(t *testing.T) {
|
||||
pool, ctx := testPool(t)
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
projectID := seedProject(t, ctx, pool, suffix)
|
||||
docID := "uow_" + suffix + "_commit"
|
||||
work := uow.NewPG(pool)
|
||||
work.BeforeCommit = func(uow.TxScope) error { return errors.New("commit failed") }
|
||||
sideEffect := false
|
||||
err := work.Within(ctx, func(scope uow.TxScope) error {
|
||||
return writeOwners(ctx, scope, projectID, docID, "# commit\n")
|
||||
})
|
||||
if err == nil {
|
||||
sideEffect = true
|
||||
}
|
||||
if sideEffect {
|
||||
t.Fatal("caller must see commit error and skip post-commit work")
|
||||
}
|
||||
r, s, e := countDoc(t, ctx, pool, docID)
|
||||
if r != 0 || s != 0 || e != 0 {
|
||||
t.Fatalf("commit failure leaked rows records=%d snaps=%d events=%d", r, s, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializationRetriesWholeTransactionOnce(t *testing.T) {
|
||||
pool, ctx := testPool(t)
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
projectID := seedProject(t, ctx, pool, suffix)
|
||||
docID := "uow_" + suffix + "_ser"
|
||||
work := uow.NewPG(pool)
|
||||
work.Sleep = func(time.Duration) {}
|
||||
attempts := 0
|
||||
err := work.Within(ctx, func(scope uow.TxScope) error {
|
||||
attempts++
|
||||
body := fmt.Sprintf("# ser %d\n", attempts)
|
||||
if err := writeOwners(ctx, scope, projectID, docID, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if attempts == 1 {
|
||||
return &pgconn.PgError{Code: "40001", Message: "could not serialize access"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts=%d want 2", attempts)
|
||||
}
|
||||
r, s, e := countDoc(t, ctx, pool, docID)
|
||||
if r != 1 || s != 1 || e != 1 {
|
||||
t.Fatalf("want one business result records=%d snaps=%d events=%d", r, s, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonRetryableErrorsAreNotRetried(t *testing.T) {
|
||||
pool, ctx := testPool(t)
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
projectID := seedProject(t, ctx, pool, suffix)
|
||||
work := uow.NewPG(pool)
|
||||
work.Sleep = func(time.Duration) {}
|
||||
for _, want := range []error{uow.ErrVersionConflict, uow.ErrPermissionDenied, uow.ErrDigestConflict} {
|
||||
attempts := 0
|
||||
err := work.Within(ctx, func(scope uow.TxScope) error {
|
||||
attempts++
|
||||
_ = (docgov.Repository{}).InsertRoot(ctx, scope, docgov.DocumentRecord{
|
||||
DocID: "uow_" + suffix + "_noretry", ProjectID: projectID, Title: "x", TaskKey: "intake",
|
||||
})
|
||||
return want
|
||||
})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("err=%v want %v", err, want)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("%v retried attempts=%d", want, attempts)
|
||||
}
|
||||
}
|
||||
r, _, _ := countDoc(t, ctx, pool, "uow_"+suffix+"_noretry")
|
||||
if r != 0 {
|
||||
t.Fatalf("non-retryable write leaked record=%d", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHappyPathPersistsBothOwners(t *testing.T) {
|
||||
pool, ctx := testPool(t)
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
projectID := seedProject(t, ctx, pool, suffix)
|
||||
docID := "uow_" + suffix + "_ok"
|
||||
work := uow.NewPG(pool)
|
||||
if err := work.Within(ctx, func(scope uow.TxScope) error {
|
||||
return writeOwners(ctx, scope, projectID, docID, "# ok\n")
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, s, e := countDoc(t, ctx, pool, docID)
|
||||
if r != 1 || s != 1 || e != 1 {
|
||||
t.Fatalf("happy path records=%d snaps=%d events=%d", r, s, e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package uow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrVersionConflict = errors.New("uow: version conflict")
|
||||
ErrPermissionDenied = errors.New("uow: permission denied")
|
||||
ErrDigestConflict = errors.New("uow: digest conflict")
|
||||
ErrBusy = errors.New("uow: lock busy")
|
||||
ErrClosedScope = errors.New("uow: transaction scope is closed")
|
||||
ErrNestedBegin = errors.New("uow: repository must not begin a nested transaction")
|
||||
ErrPrematureCommit = errors.New("uow: repository must not commit the unit of work")
|
||||
)
|
||||
|
||||
// TxScope is an opaque transaction handle. Application code cannot Exec SQL through it.
|
||||
type TxScope interface {
|
||||
Context() context.Context
|
||||
}
|
||||
|
||||
// UnitOfWork runs one command in a single database transaction.
|
||||
type UnitOfWork interface {
|
||||
Within(ctx context.Context, fn func(TxScope) error) error
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultLockTimeout = 2 * time.Second
|
||||
DefaultMaxAttempts = 3
|
||||
)
|
||||
|
||||
type RetryPolicy struct {
|
||||
MaxAttempts int
|
||||
LockTimeout time.Duration
|
||||
}
|
||||
|
||||
func DefaultRetryPolicy() RetryPolicy {
|
||||
return RetryPolicy{MaxAttempts: DefaultMaxAttempts, LockTimeout: DefaultLockTimeout}
|
||||
}
|
||||
|
||||
type scopeCtxKey struct{}
|
||||
|
||||
// WithScope lets owner repositories join the command UnitOfWork.
|
||||
func WithScope(ctx context.Context, scope TxScope) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, scopeCtxKey{}, scope)
|
||||
}
|
||||
|
||||
func ScopeFrom(ctx context.Context) (TxScope, bool) {
|
||||
if ctx == nil {
|
||||
return nil, false
|
||||
}
|
||||
s, ok := ctx.Value(scopeCtxKey{}).(TxScope)
|
||||
return s, ok && s != nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package uow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
func TestTxScopeDoesNotExposeExec(t *testing.T) {
|
||||
typ := reflect.TypeOf((*TxScope)(nil)).Elem()
|
||||
for _, name := range []string{"Exec", "Begin", "Commit", "Rollback"} {
|
||||
if _, ok := typ.MethodByName(name); ok {
|
||||
t.Fatalf("TxScope must not expose %s", name)
|
||||
}
|
||||
}
|
||||
pg := reflect.TypeOf(&pgScope{})
|
||||
if _, ok := pg.MethodByName("Exec"); ok {
|
||||
t.Fatal("pgScope must not expose Exec")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeScope struct{}
|
||||
|
||||
func (fakeScope) Context() context.Context { return context.Background() }
|
||||
|
||||
func TestAsPGRejectsOpaqueScope(t *testing.T) {
|
||||
if _, err := AsPG(fakeScope{}); err == nil {
|
||||
t.Fatal("expected unwrap failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializationAndNonRetryable(t *testing.T) {
|
||||
if !IsSerialization(&pgconn.PgError{Code: "40001"}) || !IsSerialization(&pgconn.PgError{Code: "40P01"}) {
|
||||
t.Fatal("serialization/deadlock must retry")
|
||||
}
|
||||
if IsSerialization(errors.New("plain")) {
|
||||
t.Fatal("plain error must not retry")
|
||||
}
|
||||
if !IsNonRetryable(ErrVersionConflict) || !IsNonRetryable(ErrPermissionDenied) || !IsNonRetryable(ErrDigestConflict) {
|
||||
t.Fatal("typed conflicts must not enter retry")
|
||||
}
|
||||
if IsNonRetryable(&pgconn.PgError{Code: "40001"}) {
|
||||
t.Fatal("serialization is retryable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package archtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestArchitectureBoundaries(t *testing.T) {
|
||||
root, err := ModuleRoot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := LoadBoundaries(DefaultBoundariesPath(root))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
findings, err := Scan(ScanOptions{Root: root}, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := Evaluate(findings, b, time.Now())
|
||||
if os.Getenv("ARCHTEST_DUMP") != "" {
|
||||
t.Log(ev.Report())
|
||||
}
|
||||
if ev.Failed() {
|
||||
t.Fatal(ev.Report())
|
||||
}
|
||||
if os.Getenv("ARCHTEST_STRICT") != "" && len(ev.Excepted) > 0 {
|
||||
t.Fatalf("strict/block mode: %d baseline exceptions remain\n%s", len(ev.Excepted), ev.Report())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package archtest
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func miniRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("caller")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "testdata", "mini")
|
||||
}
|
||||
|
||||
func fixtureBoundaries() *Boundaries {
|
||||
return &Boundaries{
|
||||
Imports: map[string][]string{
|
||||
"server": {},
|
||||
"docgov": {},
|
||||
},
|
||||
TableOwners: map[string][]string{
|
||||
"document_records": {"docgov"},
|
||||
},
|
||||
PathOwners: map[string][]string{
|
||||
"output.md": {"squad", "workspace"},
|
||||
},
|
||||
EventProducers: map[string][]string{
|
||||
"outbox.notify": {"docrevision"},
|
||||
"document.agent_version_committed": {"docrevision"},
|
||||
},
|
||||
EventConsumers: map[string][]string{
|
||||
"outbox.notify": {"notification"},
|
||||
},
|
||||
BaselineCounts: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
func scanMini(t *testing.T) []Finding {
|
||||
t.Helper()
|
||||
findings, err := Scan(ScanOptions{Root: miniRoot(t)}, fixtureBoundaries())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func TestGooseAndTestsAreNotProductionWriters(t *testing.T) {
|
||||
for _, f := range scanMini(t) {
|
||||
if f.Kind == KindSQLWrite && f.Package == "docgov" && f.File == "internal/docgov/owner_write_test.go" {
|
||||
t.Fatalf("test file must not be a production writer: %+v", f)
|
||||
}
|
||||
if f.Kind == KindSQLWrite && filepath.Ext(f.File) == ".sql" {
|
||||
t.Fatalf("goose/sql must not be a production writer: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerProductionWriteIsBlockedAndNamed(t *testing.T) {
|
||||
var hit *Finding
|
||||
findings := scanMini(t)
|
||||
for i, f := range findings {
|
||||
if f.Kind == KindSQLWrite && f.Symbol == "document_records" && f.Package == "server" {
|
||||
hit = &findings[i]
|
||||
if f.File != "internal/server/rogue_write.go" {
|
||||
t.Fatalf("expected rogue_write.go, got %s", f.File)
|
||||
}
|
||||
}
|
||||
}
|
||||
if hit == nil {
|
||||
t.Fatal("expected UPDATE document_records in internal/server/rogue_write.go to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerProductionWriteIsAllowed(t *testing.T) {
|
||||
for _, f := range scanMini(t) {
|
||||
if f.Kind == KindSQLWrite && f.Package == "docgov" && f.File == "internal/docgov/owner_write.go" {
|
||||
t.Fatalf("owner write should be allowed: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathJoinConcatAndConstAreDetected(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"internal/server/path_join.go": false,
|
||||
"internal/server/path_concat.go": false,
|
||||
"internal/server/path_const.go": false,
|
||||
}
|
||||
for _, f := range scanMini(t) {
|
||||
if f.Kind != KindPath || f.Symbol != "output.md" {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[f.File]; ok {
|
||||
want[f.File] = true
|
||||
}
|
||||
}
|
||||
for file, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("path construction not detected in %s", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedEventProducerIsBlocked(t *testing.T) {
|
||||
found := false
|
||||
for _, f := range scanMini(t) {
|
||||
if f.Kind == KindEvent && f.Package == "server" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected unauthorized OnOutbox producer in server to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExceptionRequiresExpiryAndOwner(t *testing.T) {
|
||||
b := fixtureBoundaries()
|
||||
b.Exceptions = []Exception{{
|
||||
Kind: KindSQLWrite, Package: "server", File: "internal/server/rogue_write.go",
|
||||
Symbol: "document_records", Reason: "no owner or expiry",
|
||||
}}
|
||||
ev := Evaluate(scanMini(t), b, time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC))
|
||||
if len(ev.BadExceptions) == 0 {
|
||||
t.Fatal("expected missing owner/expires to block")
|
||||
}
|
||||
if len(ev.Unmatched) == 0 {
|
||||
t.Fatal("invalid exception must not hide the finding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredExceptionBlocksImmediately(t *testing.T) {
|
||||
b := fixtureBoundaries()
|
||||
b.Exceptions = []Exception{{
|
||||
Kind: KindSQLWrite, Package: "server", File: "internal/server/rogue_write.go",
|
||||
Symbol: "document_records", Owner: "server", Reason: "stale",
|
||||
Milestone: "U3", Expires: "2026-09-01",
|
||||
}}
|
||||
ev := Evaluate(scanMini(t), b, time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC))
|
||||
if len(ev.BadExceptions) == 0 {
|
||||
t.Fatal("expected expired exception to block")
|
||||
}
|
||||
found := false
|
||||
for _, f := range ev.Unmatched {
|
||||
if f.File == "internal/server/rogue_write.go" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expired exception must surface the original finding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyGooseSQL(t *testing.T) {
|
||||
root := miniRoot(t)
|
||||
got := classifyFile(root, filepath.Join(root, "deploy", "postgres", "goose", "00001_fixture.sql"))
|
||||
if got != classGoose {
|
||||
t.Fatalf("classify goose: got %v want %v", got, classGoose)
|
||||
}
|
||||
got = classifyFile(root, filepath.Join(root, "internal", "docgov", "owner_write_test.go"))
|
||||
if got != classTestOrFixture {
|
||||
t.Fatalf("classify test: got %v want %v", got, classTestOrFixture)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package archtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
KindImport = "import"
|
||||
KindSQLWrite = "sql_write"
|
||||
KindPath = "path"
|
||||
KindEvent = "event"
|
||||
)
|
||||
|
||||
type Boundaries struct {
|
||||
Imports map[string][]string `yaml:"imports"`
|
||||
TableOwners map[string][]string `yaml:"table_owners"`
|
||||
PathOwners map[string][]string `yaml:"path_owners"`
|
||||
EventProducers map[string][]string `yaml:"event_producers"`
|
||||
EventConsumers map[string][]string `yaml:"event_consumers"`
|
||||
UnwrapOwners []string `yaml:"unwrap_owners"`
|
||||
Exceptions []Exception `yaml:"exceptions"`
|
||||
BaselineCounts map[string]int `yaml:"baseline_counts"`
|
||||
}
|
||||
|
||||
type Exception struct {
|
||||
Kind string `yaml:"kind"`
|
||||
Package string `yaml:"package"`
|
||||
File string `yaml:"file"`
|
||||
Symbol string `yaml:"symbol"`
|
||||
Owner string `yaml:"owner"`
|
||||
Reason string `yaml:"reason"`
|
||||
Milestone string `yaml:"milestone"`
|
||||
Expires string `yaml:"expires"`
|
||||
}
|
||||
|
||||
type Finding struct {
|
||||
Kind string
|
||||
Package string
|
||||
File string
|
||||
Symbol string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (f Finding) Key() string {
|
||||
return f.Kind + "|" + f.Package + "|" + filepath.ToSlash(f.File) + "|" + f.Symbol
|
||||
}
|
||||
|
||||
func ModuleRoot() (string, error) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("archtest: cannot locate caller")
|
||||
}
|
||||
dir := filepath.Dir(file)
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("archtest: go.mod not found")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func LoadBoundaries(path string) (*Boundaries, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out Boundaries
|
||||
if err := yaml.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Imports == nil {
|
||||
out.Imports = map[string][]string{}
|
||||
}
|
||||
if out.TableOwners == nil {
|
||||
out.TableOwners = map[string][]string{}
|
||||
}
|
||||
if out.PathOwners == nil {
|
||||
out.PathOwners = map[string][]string{}
|
||||
}
|
||||
if out.EventProducers == nil {
|
||||
out.EventProducers = map[string][]string{}
|
||||
}
|
||||
if out.EventConsumers == nil {
|
||||
out.EventConsumers = map[string][]string{}
|
||||
}
|
||||
if out.UnwrapOwners == nil {
|
||||
out.UnwrapOwners = []string{}
|
||||
}
|
||||
if out.BaselineCounts == nil {
|
||||
out.BaselineCounts = map[string]int{}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func DefaultBoundariesPath(root string) string {
|
||||
return filepath.Join(root, ".doc", "system-design", "module-boundaries.yaml")
|
||||
}
|
||||
|
||||
func allowedHas(list []string, needle string) bool {
|
||||
for _, p := range list {
|
||||
if strings.TrimSpace(p) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e Exception) validate(now time.Time) error {
|
||||
if strings.TrimSpace(e.Owner) == "" {
|
||||
return fmt.Errorf("missing owner")
|
||||
}
|
||||
if strings.TrimSpace(e.Reason) == "" {
|
||||
return fmt.Errorf("missing reason")
|
||||
}
|
||||
if strings.TrimSpace(e.Milestone) == "" {
|
||||
return fmt.Errorf("missing milestone")
|
||||
}
|
||||
if strings.TrimSpace(e.Expires) == "" {
|
||||
return fmt.Errorf("missing expires")
|
||||
}
|
||||
day, err := time.ParseInLocation("2006-01-02", e.Expires, time.UTC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid expires %q", e.Expires)
|
||||
}
|
||||
// expires is inclusive through that calendar day (UTC).
|
||||
if !now.UTC().Before(day.Add(24 * time.Hour)) {
|
||||
return fmt.Errorf("expired on %s", e.Expires)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e Exception) matches(f Finding) bool {
|
||||
if e.Kind != f.Kind {
|
||||
return false
|
||||
}
|
||||
if e.Package != "" && e.Package != f.Package {
|
||||
return false
|
||||
}
|
||||
if e.File != "" && filepath.ToSlash(e.File) != filepath.ToSlash(f.File) {
|
||||
return false
|
||||
}
|
||||
if e.Symbol != "" && e.Symbol != f.Symbol {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package archtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Evaluation struct {
|
||||
Unmatched []Finding
|
||||
Excepted []Finding
|
||||
BadExceptions []string
|
||||
Counts map[string]int
|
||||
CountRegressed []string
|
||||
}
|
||||
|
||||
func Evaluate(findings []Finding, b *Boundaries, now time.Time) Evaluation {
|
||||
ev := Evaluation{Counts: map[string]int{}}
|
||||
used := make([]bool, len(b.Exceptions))
|
||||
for i, ex := range b.Exceptions {
|
||||
if err := ex.validate(now); err != nil {
|
||||
ev.BadExceptions = append(ev.BadExceptions, fmt.Sprintf("exception[%d] %s %s %s: %v", i, ex.Kind, ex.File, ex.Symbol, err))
|
||||
}
|
||||
}
|
||||
for _, f := range findings {
|
||||
ev.Counts[f.Kind]++
|
||||
matched := false
|
||||
for i, ex := range b.Exceptions {
|
||||
if !ex.matches(f) {
|
||||
continue
|
||||
}
|
||||
if err := ex.validate(now); err != nil {
|
||||
continue
|
||||
}
|
||||
used[i] = true
|
||||
matched = true
|
||||
ev.Excepted = append(ev.Excepted, f)
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
ev.Unmatched = append(ev.Unmatched, f)
|
||||
}
|
||||
}
|
||||
sort.Slice(ev.Unmatched, func(i, j int) bool { return ev.Unmatched[i].Key() < ev.Unmatched[j].Key() })
|
||||
sort.Slice(ev.Excepted, func(i, j int) bool { return ev.Excepted[i].Key() < ev.Excepted[j].Key() })
|
||||
|
||||
for kind, baseline := range b.BaselineCounts {
|
||||
if ev.Counts[kind] > baseline {
|
||||
ev.CountRegressed = append(ev.CountRegressed, fmt.Sprintf("%s: %d > baseline %d", kind, ev.Counts[kind], baseline))
|
||||
}
|
||||
}
|
||||
sort.Strings(ev.CountRegressed)
|
||||
return ev
|
||||
}
|
||||
|
||||
func (ev Evaluation) Failed() bool {
|
||||
return len(ev.Unmatched) > 0 || len(ev.BadExceptions) > 0 || len(ev.CountRegressed) > 0
|
||||
}
|
||||
|
||||
func (ev Evaluation) Report() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "archtest findings=%d excepted=%d unmatched=%d\n",
|
||||
ev.Counts[KindImport]+ev.Counts[KindSQLWrite]+ev.Counts[KindPath]+ev.Counts[KindEvent],
|
||||
len(ev.Excepted), len(ev.Unmatched))
|
||||
for _, kind := range []string{KindImport, KindSQLWrite, KindPath, KindEvent} {
|
||||
fmt.Fprintf(&b, " %s=%d\n", kind, ev.Counts[kind])
|
||||
}
|
||||
for _, msg := range ev.BadExceptions {
|
||||
fmt.Fprintf(&b, "BAD_EXCEPTION %s\n", msg)
|
||||
}
|
||||
for _, msg := range ev.CountRegressed {
|
||||
fmt.Fprintf(&b, "REGRESSION %s\n", msg)
|
||||
}
|
||||
for _, f := range ev.Unmatched {
|
||||
fmt.Fprintf(&b, "NEW %s %s %s %s (%s)\n", f.Kind, f.Package, f.File, f.Symbol, f.Detail)
|
||||
}
|
||||
for _, f := range ev.Excepted {
|
||||
fmt.Fprintf(&b, "BASELINE %s %s %s %s (%s)\n", f.Kind, f.Package, f.File, f.Symbol, f.Detail)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package archtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const internalPrefix = "chat-agent-system/internal/"
|
||||
|
||||
var (
|
||||
sqlWriteRe = regexp.MustCompile(`(?is)\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([a-zA-Z_][a-zA-Z0-9_]*)`)
|
||||
eventNameRe = regexp.MustCompile(`document\.[a-z0-9_.]+`)
|
||||
)
|
||||
|
||||
type fileClass int
|
||||
|
||||
const (
|
||||
classIgnore fileClass = iota
|
||||
classProduction
|
||||
classTestOrFixture
|
||||
classGoose
|
||||
)
|
||||
|
||||
type ScanOptions struct {
|
||||
Root string
|
||||
// ExtraRoots are additional trees scanned as production Go (used by self-tests).
|
||||
ExtraRoots []string
|
||||
// ExtraSQL are extra files classified as goose/fixture SQL.
|
||||
ExtraSQL []string
|
||||
}
|
||||
|
||||
func Scan(opts ScanOptions, b *Boundaries) ([]Finding, error) {
|
||||
var findings []Finding
|
||||
roots := append([]string{filepath.Join(opts.Root, "internal")}, opts.ExtraRoots...)
|
||||
fset := token.NewFileSet()
|
||||
for _, root := range roots {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
continue
|
||||
}
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
if name == "testdata" || name == "vendor" || name == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
class := classifyFile(opts.Root, path)
|
||||
if strings.HasSuffix(path, ".go") {
|
||||
switch class {
|
||||
case classProduction:
|
||||
fs, perr := scanProductionGo(fset, opts.Root, path, b)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
findings = append(findings, fs...)
|
||||
case classTestOrFixture, classGoose, classIgnore:
|
||||
// Goose / tests / fixtures may mention tables and paths; they are not production writers.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, sqlPath := range opts.ExtraSQL {
|
||||
// Explicit proof that goose SQL is classified away from production writes.
|
||||
if classifyFile(opts.Root, sqlPath) != classGoose && !strings.Contains(filepath.ToSlash(sqlPath), "goose") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return dedupe(findings), nil
|
||||
}
|
||||
|
||||
func classifyFile(repoRoot, path string) fileClass {
|
||||
rel := path
|
||||
if repoRoot != "" {
|
||||
if r, err := filepath.Rel(repoRoot, path); err == nil {
|
||||
rel = r
|
||||
}
|
||||
}
|
||||
slash := filepath.ToSlash(rel)
|
||||
base := filepath.Base(path)
|
||||
if strings.Contains(slash, "/testdata/") || strings.HasPrefix(slash, "testdata/") {
|
||||
if strings.Contains(slash, "/goose/") || strings.HasPrefix(base, "000") && strings.HasSuffix(base, ".sql") {
|
||||
return classGoose
|
||||
}
|
||||
if strings.HasSuffix(base, "_test.go") || strings.Contains(slash, "/fixture") || strings.Contains(slash, "/fixtures/") {
|
||||
if strings.HasSuffix(base, ".go") && !strings.HasSuffix(base, "_test.go") && strings.Contains(slash, "/prod/") {
|
||||
return classProduction
|
||||
}
|
||||
return classTestOrFixture
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(slash, "deploy/postgres/goose/") || strings.Contains(slash, "/postgres/goose/") {
|
||||
return classGoose
|
||||
}
|
||||
if strings.HasSuffix(base, "_test.go") {
|
||||
return classTestOrFixture
|
||||
}
|
||||
if strings.HasSuffix(base, ".go") && strings.HasPrefix(slash, "internal/") {
|
||||
if strings.HasPrefix(slash, "internal/archtest/") {
|
||||
return classIgnore
|
||||
}
|
||||
return classProduction
|
||||
}
|
||||
if strings.HasSuffix(base, ".go") && (strings.Contains(slash, "/prod/") || strings.Contains(slash, "fixtures/prod/")) {
|
||||
return classProduction
|
||||
}
|
||||
return classIgnore
|
||||
}
|
||||
|
||||
func scanProductionGo(fset *token.FileSet, repoRoot, path string, b *Boundaries) ([]Finding, error) {
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := parser.ParseFile(fset, path, src, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
pkg := packageID(repoRoot, path)
|
||||
rel := relFile(repoRoot, path)
|
||||
var out []Finding
|
||||
out = append(out, scanImports(file, pkg, rel, b)...)
|
||||
out = append(out, scanSQLWrites(string(src), pkg, rel, b)...)
|
||||
out = append(out, scanPaths(file, pkg, rel, b)...)
|
||||
out = append(out, scanEvents(file, string(src), pkg, rel, b)...)
|
||||
out = append(out, scanUnwrap(string(src), pkg, rel, b)...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func packageID(repoRoot, path string) string {
|
||||
rel := relFile(repoRoot, path)
|
||||
slash := filepath.ToSlash(rel)
|
||||
slash = strings.TrimPrefix(slash, "internal/")
|
||||
dir := filepath.ToSlash(filepath.Dir(slash))
|
||||
if dir == "." {
|
||||
return filepath.Base(filepath.Dir(path))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func relFile(repoRoot, path string) string {
|
||||
if repoRoot == "" {
|
||||
return filepath.ToSlash(path)
|
||||
}
|
||||
rel, err := filepath.Rel(repoRoot, path)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(path)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func scanImports(file *ast.File, pkg, rel string, b *Boundaries) []Finding {
|
||||
allowed, ok := b.Imports[pkg]
|
||||
if !ok {
|
||||
// Unknown production package is itself a new import-graph node.
|
||||
return []Finding{{Kind: KindImport, Package: pkg, File: rel, Symbol: pkg, Detail: "package missing from import allowlist"}}
|
||||
}
|
||||
var out []Finding
|
||||
for _, spec := range file.Imports {
|
||||
path := strings.Trim(spec.Path.Value, `"`)
|
||||
if !strings.HasPrefix(path, internalPrefix) {
|
||||
continue
|
||||
}
|
||||
dep := strings.TrimPrefix(path, internalPrefix)
|
||||
if dep == pkg {
|
||||
continue
|
||||
}
|
||||
if !allowedHas(allowed, dep) {
|
||||
out = append(out, Finding{
|
||||
Kind: KindImport,
|
||||
Package: pkg,
|
||||
File: rel,
|
||||
Symbol: dep,
|
||||
Detail: fmt.Sprintf("production import %s -> %s", pkg, dep),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanSQLWrites(src, pkg, rel string, b *Boundaries) []Finding {
|
||||
var out []Finding
|
||||
for _, m := range sqlWriteRe.FindAllStringSubmatch(src, -1) {
|
||||
table := strings.ToLower(m[2])
|
||||
owners, tracked := b.TableOwners[table]
|
||||
if !tracked {
|
||||
continue
|
||||
}
|
||||
if allowedHas(owners, pkg) {
|
||||
continue
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Kind: KindSQLWrite,
|
||||
Package: pkg,
|
||||
File: rel,
|
||||
Symbol: table,
|
||||
Detail: fmt.Sprintf("%s writes %s", strings.ToLower(strings.Join(strings.Fields(m[1]), " ")), table),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanPaths(file *ast.File, pkg, rel string, b *Boundaries) []Finding {
|
||||
consts := map[string]string{}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
vs, ok := n.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for i, name := range vs.Names {
|
||||
if i < len(vs.Values) {
|
||||
if lit, ok := vs.Values[i].(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
consts[name.Name] = strings.Trim(lit.Value, `"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
var out []Finding
|
||||
seen := map[string]bool{}
|
||||
add := func(symbol, detail string) {
|
||||
key := symbol + "|" + detail
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
owners, tracked := b.PathOwners[symbol]
|
||||
if !tracked {
|
||||
return
|
||||
}
|
||||
if allowedHas(owners, pkg) {
|
||||
return
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Kind: KindPath,
|
||||
Package: pkg,
|
||||
File: rel,
|
||||
Symbol: symbol,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.BasicLit:
|
||||
if x.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
val := strings.Trim(x.Value, `"`)
|
||||
for lit := range b.PathOwners {
|
||||
if pathLiteralHit(val, lit) {
|
||||
add(lit, "string literal "+lit)
|
||||
}
|
||||
}
|
||||
case *ast.Ident:
|
||||
if val, ok := consts[x.Name]; ok {
|
||||
for lit := range b.PathOwners {
|
||||
if pathLiteralHit(val, lit) {
|
||||
add(lit, "indirect const "+x.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
if !isJoinLike(x.Fun) {
|
||||
return true
|
||||
}
|
||||
for _, arg := range x.Args {
|
||||
switch a := arg.(type) {
|
||||
case *ast.BasicLit:
|
||||
if a.Kind == token.STRING {
|
||||
val := strings.Trim(a.Value, `"`)
|
||||
for lit := range b.PathOwners {
|
||||
if pathLiteralHit(val, lit) {
|
||||
add(lit, "filepath.Join/path.Join")
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.Ident:
|
||||
if val, ok := consts[a.Name]; ok {
|
||||
for lit := range b.PathOwners {
|
||||
if pathLiteralHit(val, lit) {
|
||||
add(lit, "filepath.Join via const "+a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case *ast.BinaryExpr:
|
||||
if x.Op != token.ADD {
|
||||
return true
|
||||
}
|
||||
vals := []string{litOrConst(x.X, consts), litOrConst(x.Y, consts)}
|
||||
for _, val := range vals {
|
||||
for lit := range b.PathOwners {
|
||||
if pathLiteralHit(val, lit) {
|
||||
add(lit, "string concatenation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func litOrConst(e ast.Expr, consts map[string]string) string {
|
||||
switch x := e.(type) {
|
||||
case *ast.BasicLit:
|
||||
if x.Kind == token.STRING {
|
||||
return strings.Trim(x.Value, `"`)
|
||||
}
|
||||
case *ast.Ident:
|
||||
return consts[x.Name]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isJoinLike(fun ast.Expr) bool {
|
||||
sel, ok := fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
pkg, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if sel.Sel.Name != "Join" && sel.Sel.Name != "FromSlash" {
|
||||
return false
|
||||
}
|
||||
return pkg.Name == "filepath" || pkg.Name == "path"
|
||||
}
|
||||
|
||||
func pathLiteralHit(val, lit string) bool {
|
||||
if val == "" || lit == "" {
|
||||
return false
|
||||
}
|
||||
if val == lit {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(val, lit)
|
||||
}
|
||||
|
||||
func scanEvents(file *ast.File, src, pkg, rel string, b *Boundaries) []Finding {
|
||||
var out []Finding
|
||||
seen := map[string]bool{}
|
||||
add := func(symbol, role, detail string) {
|
||||
key := symbol + "|" + role
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
var owners []string
|
||||
var tracked bool
|
||||
if role == "producer" {
|
||||
owners, tracked = b.EventProducers[symbol]
|
||||
} else {
|
||||
owners, tracked = b.EventConsumers[symbol]
|
||||
}
|
||||
if !tracked {
|
||||
return
|
||||
}
|
||||
if allowedHas(owners, pkg) {
|
||||
return
|
||||
}
|
||||
out = append(out, Finding{
|
||||
Kind: KindEvent,
|
||||
Package: pkg,
|
||||
File: rel,
|
||||
Symbol: symbol,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
if strings.Contains(src, "OnOutbox") {
|
||||
isAssign := strings.Contains(src, "OnOutbox =")
|
||||
isCallOrField := strings.Contains(src, "OnOutbox(") || strings.Contains(src, "OnOutbox func(")
|
||||
if isCallOrField && !isAssign {
|
||||
add("outbox.notify", "producer", "OnOutbox hook")
|
||||
}
|
||||
if isAssign {
|
||||
add("outbox.notify", "consumer", "OnOutbox assignment")
|
||||
}
|
||||
}
|
||||
for _, name := range eventNameRe.FindAllString(src, -1) {
|
||||
if _, ok := b.EventProducers[name]; ok {
|
||||
if allowedHas(b.EventConsumers[name], pkg) || allowedHas(b.EventConsumers["outbox.notify"], pkg) {
|
||||
continue
|
||||
}
|
||||
add(name, "producer", "event name literal")
|
||||
}
|
||||
}
|
||||
_ = file
|
||||
return out
|
||||
}
|
||||
|
||||
func scanUnwrap(src, pkg, rel string, b *Boundaries) []Finding {
|
||||
if !strings.Contains(src, "uow.AsPG") {
|
||||
return nil
|
||||
}
|
||||
if allowedHas(b.UnwrapOwners, pkg) {
|
||||
return nil
|
||||
}
|
||||
return []Finding{{
|
||||
Kind: KindImport,
|
||||
Package: pkg,
|
||||
File: rel,
|
||||
Symbol: "uow.AsPG",
|
||||
Detail: "application layer must not unwrap TxScope",
|
||||
}}
|
||||
}
|
||||
|
||||
func dedupe(in []Finding) []Finding {
|
||||
seen := map[string]bool{}
|
||||
var out []Finding
|
||||
for _, f := range in {
|
||||
k := f.Key()
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Goose may mention owned tables; this is not a production writer.
|
||||
UPDATE document_records SET current_state = 'approved' WHERE doc_id = 'fixture';
|
||||
INSERT INTO document_records (doc_id) VALUES ('fixture');
|
||||
DELETE FROM document_records WHERE doc_id = 'fixture';
|
||||
@@ -0,0 +1,5 @@
|
||||
package docgov
|
||||
|
||||
const ownerSQL = `
|
||||
UPDATE document_records SET current_state = 'approved' WHERE doc_id = $1
|
||||
`
|
||||
@@ -0,0 +1,5 @@
|
||||
package docgov
|
||||
|
||||
const testSQL = `
|
||||
UPDATE document_records SET current_state = 'test' WHERE doc_id = $1
|
||||
`
|
||||
@@ -0,0 +1,7 @@
|
||||
package server
|
||||
|
||||
func emit() {
|
||||
_ = OnOutbox("document.agent_version_committed")
|
||||
}
|
||||
|
||||
func OnOutbox(eventType string) error { return nil }
|
||||
@@ -0,0 +1,5 @@
|
||||
package server
|
||||
|
||||
func concatOutput(dir string) string {
|
||||
return dir + "/output.md"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package server
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
const deliverableName = "output.md"
|
||||
|
||||
func constOutput(dir string) string {
|
||||
return filepath.Join(dir, deliverableName)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package server
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
func joinOutput(dir string) string {
|
||||
return filepath.Join(dir, "output.md")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package server
|
||||
|
||||
const rogueSQL = `
|
||||
UPDATE document_records SET current_state = 'hacked' WHERE doc_id = $1
|
||||
`
|
||||
+53
-15
@@ -8,6 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -69,6 +71,14 @@ func (e *GovernanceEngine) runApprovedHooks(ctx context.Context, doc *DocumentRe
|
||||
}
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) UsingPG() bool {
|
||||
return e != nil && e.pool != nil
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) RunApprovedHooksAfterCommit(ctx context.Context, doc *DocumentRecord) {
|
||||
e.runApprovedHooks(ctx, doc)
|
||||
}
|
||||
|
||||
// NewGovernanceEngine 初始化治理引擎
|
||||
func NewGovernanceEngine(pool *pgxpool.Pool) *GovernanceEngine {
|
||||
return &GovernanceEngine{
|
||||
@@ -783,7 +793,9 @@ func (e *GovernanceEngine) SubmitForReview(ctx context.Context, docID string, ac
|
||||
}
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.syncDocAndAuditToDB(ctx, doc, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -811,7 +823,9 @@ func (e *GovernanceEngine) ReviewApprove(ctx context.Context, docID string, acto
|
||||
}
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.syncDocAndAuditToDB(ctx, doc, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -838,7 +852,9 @@ func (e *GovernanceEngine) ReviewReject(ctx context.Context, docID string, actor
|
||||
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.syncDocAndAuditToDB(ctx, doc, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -866,11 +882,15 @@ func (e *GovernanceEngine) ApproverApprove(ctx context.Context, docID string, ac
|
||||
}
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.syncDocAndAuditToDB(ctx, doc, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.mu.Unlock()
|
||||
e.runApprovedHooks(ctx, doc)
|
||||
e.mu.Lock()
|
||||
if _, joined := uow.ScopeFrom(ctx); !joined {
|
||||
e.mu.Unlock()
|
||||
e.runApprovedHooks(ctx, doc)
|
||||
e.mu.Lock()
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
@@ -897,7 +917,9 @@ func (e *GovernanceEngine) ApproverReject(ctx context.Context, docID string, act
|
||||
_ = e.addCommentInternal(docID, actorID, actorName, actorRole, "", CommentRejection, rejectReason)
|
||||
|
||||
e.memAudits[docID] = append(e.memAudits[docID], audit)
|
||||
e.syncDocAndAuditToDB(ctx, doc, audit)
|
||||
if err := e.syncDocAndAuditToDB(ctx, doc, audit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@@ -1068,11 +1090,23 @@ func (e *GovernanceEngine) addCommentInternal(docID string, userID int64, userNa
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) syncDocAndAuditToDB(ctx context.Context, doc *DocumentRecord, audit *AuditLog) {
|
||||
func (e *GovernanceEngine) syncDocAndAuditToDB(ctx context.Context, doc *DocumentRecord, audit *AuditLog) error {
|
||||
if e.pool == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
_, _ = e.pool.Exec(ctx, `
|
||||
exec := func(sql string, args ...any) error {
|
||||
if scope, ok := uow.ScopeFrom(ctx); ok {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
_, err := e.pool.Exec(ctx, sql, args...)
|
||||
return err
|
||||
}
|
||||
if err := exec(`
|
||||
UPDATE document_records
|
||||
SET current_state = $1, is_locked = $2,
|
||||
last_rejected_reason = $3, last_rejected_by_role = $4, last_rejected_by_name = $5, last_rejected_at = $6,
|
||||
@@ -1080,14 +1114,18 @@ func (e *GovernanceEngine) syncDocAndAuditToDB(ctx context.Context, doc *Documen
|
||||
WHERE doc_id = $9
|
||||
`, string(doc.CurrentState), doc.IsLocked,
|
||||
doc.LastRejectedReason, doc.LastRejectedByRole, doc.LastRejectedByName, doc.LastRejectedAt,
|
||||
doc.ApprovedAt, doc.UpdatedAt, doc.DocID)
|
||||
|
||||
doc.ApprovedAt, doc.UpdatedAt, doc.DocID); err != nil {
|
||||
return fmt.Errorf("docgov: sync document: %w", err)
|
||||
}
|
||||
if audit != nil {
|
||||
detJSON, _ := json.Marshal(audit.Details)
|
||||
_, _ = e.pool.Exec(ctx, `
|
||||
if err := exec(`
|
||||
INSERT INTO document_audit_logs (doc_id, action, actor_id, actor_name, actor_role, from_state, to_state, details, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`, audit.DocID, audit.Action, audit.ActorID, audit.ActorName, string(audit.ActorRole),
|
||||
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt)
|
||||
string(audit.FromState), string(audit.ToState), detJSON, audit.CreatedAt); err != nil {
|
||||
return fmt.Errorf("docgov: sync audit: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ type DocumentContent struct {
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
Markdown string `json:"markdown"`
|
||||
IsTemplateOnly bool `json:"is_template_only"`
|
||||
Consistency string `json:"consistency,omitempty"`
|
||||
RecordSHA256 string `json:"record_sha256,omitempty"`
|
||||
FileSHA256 string `json:"file_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// ListWorkflowDocumentsByProject 列出项目下工作流受控文档(task_key 非空)。
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package docgov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
)
|
||||
|
||||
// Repository is the document_records writer bound to a UnitOfWork scope.
|
||||
type Repository struct{}
|
||||
|
||||
func (Repository) InsertRoot(ctx context.Context, scope uow.TxScope, rec DocumentRecord) error {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.CreatedAt.IsZero() {
|
||||
rec.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if rec.UpdatedAt.IsZero() {
|
||||
rec.UpdatedAt = rec.CreatedAt
|
||||
}
|
||||
if rec.Stage == "" {
|
||||
rec.Stage = "V0.5_feasibility"
|
||||
}
|
||||
if rec.CurrentState == "" {
|
||||
rec.CurrentState = StateDrafting
|
||||
}
|
||||
if rec.Title == "" {
|
||||
rec.Title = rec.DocID
|
||||
}
|
||||
if rec.TaskKey == "" {
|
||||
rec.TaskKey = "intake"
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO document_records (
|
||||
doc_id, project_id, task_key, title, stage, version,
|
||||
current_state, is_locked, is_template_only, content_sha256, work_dir,
|
||||
author_name, reviewer_name, approver_name, created_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
|
||||
rec.DocID, rec.ProjectID, rec.TaskKey, rec.Title, rec.Stage, rec.Version,
|
||||
string(rec.CurrentState), rec.IsLocked, rec.IsTemplateOnly, rec.ContentSHA256, rec.WorkDir,
|
||||
rec.AuthorName, rec.ReviewerName, rec.ApproverName, rec.CreatedAt, rec.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("docgov: insert root: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Repository) GetForUpdate(ctx context.Context, scope uow.TxScope, docID string) (*DocumentRecord, error) {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rec DocumentRecord
|
||||
var state string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT doc_id, project_id, task_key, title, stage, version, current_state,
|
||||
is_locked, is_template_only, content_sha256, work_dir,
|
||||
COALESCE(author_id, 0), COALESCE(reviewer_id, 0), COALESCE(approver_id, 0)
|
||||
FROM document_records WHERE doc_id=$1 FOR UPDATE`, docID).Scan(
|
||||
&rec.DocID, &rec.ProjectID, &rec.TaskKey, &rec.Title, &rec.Stage, &rec.Version, &state,
|
||||
&rec.IsLocked, &rec.IsTemplateOnly, &rec.ContentSHA256, &rec.WorkDir,
|
||||
&rec.AuthorID, &rec.ReviewerID, &rec.ApproverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgov: lock document: %w", err)
|
||||
}
|
||||
rec.CurrentState = DocumentState(state)
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func (Repository) UpdateState(ctx context.Context, scope uow.TxScope, rec DocumentRecord) error {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE document_records
|
||||
SET current_state=$2, is_locked=$3, content_sha256=COALESCE(NULLIF($4,''), content_sha256),
|
||||
updated_at=$5
|
||||
WHERE doc_id=$1`,
|
||||
rec.DocID, string(rec.CurrentState), rec.IsLocked, rec.ContentSHA256, time.Now().UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("docgov: update state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Repository) ProjectHead(ctx context.Context, scope uow.TxScope, docID string, versionID, snapshotID int64, revisionNo int, sha string) error {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE document_records
|
||||
SET ledger_version_id=$2, head_snapshot_id=$3, head_revision_no=$4,
|
||||
row_revision=COALESCE(row_revision,0)+1,
|
||||
content_sha256=COALESCE(NULLIF($5,''), content_sha256),
|
||||
updated_at=now()
|
||||
WHERE doc_id=$1`,
|
||||
docID, versionID, snapshotID, revisionNo, sha)
|
||||
if err != nil {
|
||||
return fmt.Errorf("docgov: project head: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package docquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
const (
|
||||
ConsistencyAligned = "aligned"
|
||||
ConsistencyMissing = "missing"
|
||||
ConsistencyDrift = "drift"
|
||||
ConsistencyEmpty = "empty"
|
||||
ConsistencyLedgerMissing = "ledger_missing"
|
||||
|
||||
MaxBatchGet = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClosed = errors.New("docquery: authorization failed")
|
||||
ErrBatchLimit = errors.New("docquery: batch exceeds 100")
|
||||
ErrCrossProject = errors.New("docquery: mixed project references")
|
||||
ErrLedgerMissing = errors.New("docquery: ledger missing")
|
||||
ErrFavNotEnabled = errors.New("docquery: FAV degraded read is disabled")
|
||||
)
|
||||
|
||||
// Principal comes only from the authentication adapter. It must not carry
|
||||
// caller-supplied role or duty overrides.
|
||||
type Principal struct {
|
||||
UserID int64
|
||||
}
|
||||
|
||||
// DocRef identifies a document. User/role/duty must not be attached.
|
||||
type DocRef struct {
|
||||
DocID string
|
||||
}
|
||||
|
||||
type ContentView struct {
|
||||
DocID string `json:"doc_id"`
|
||||
Version int `json:"version"`
|
||||
ContentSHA256 string `json:"content_sha256"`
|
||||
Markdown string `json:"markdown"`
|
||||
IsTemplateOnly bool `json:"is_template_only"`
|
||||
Consistency string `json:"consistency"`
|
||||
RecordSHA256 string `json:"record_sha256,omitempty"`
|
||||
FileSHA256 string `json:"file_sha256,omitempty"`
|
||||
HeadSHA256 string `json:"head_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type Authorizer func(ctx context.Context, p Principal, rec *docgov.DocumentRecord) error
|
||||
|
||||
type Service struct {
|
||||
Gov *docgov.GovernanceEngine
|
||||
Rev *docrevision.Service
|
||||
RepoRoot string
|
||||
Authorize Authorizer
|
||||
// FavDegradedRead is an extension point only. M1 keeps it false.
|
||||
FavDegradedRead bool
|
||||
}
|
||||
|
||||
func FavDegradedReadEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("FAV_DEGRADED_READ"))) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, p Principal, ref DocRef) (*ContentView, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
if p.UserID == 0 {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
if s == nil || s.Gov == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
rec, err := s.Gov.GetDocument(ctx, ref.DocID)
|
||||
if err != nil || rec == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
if s.Authorize != nil {
|
||||
if err := s.Authorize(ctx, p, rec); err != nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
if s.Rev != nil {
|
||||
md, sha, herr := s.Rev.HeadContent(ctx, ref.DocID)
|
||||
if herr == nil && md != "" {
|
||||
return s.viewFromLedger(ctx, rec, md, sha), nil
|
||||
}
|
||||
}
|
||||
|
||||
if s.FavDegradedRead {
|
||||
return nil, ErrFavNotEnabled
|
||||
}
|
||||
return &ContentView{
|
||||
DocID: rec.DocID,
|
||||
Version: rec.Version,
|
||||
IsTemplateOnly: rec.IsTemplateOnly,
|
||||
Consistency: ConsistencyLedgerMissing,
|
||||
RecordSHA256: rec.ContentSHA256,
|
||||
}, ErrLedgerMissing
|
||||
}
|
||||
|
||||
func (s *Service) BatchGet(ctx context.Context, p Principal, refs []DocRef) ([]*ContentView, error) {
|
||||
if len(refs) > MaxBatchGet {
|
||||
return nil, ErrBatchLimit
|
||||
}
|
||||
if p.UserID == 0 {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
var projectID int64
|
||||
seen := map[string]bool{}
|
||||
out := make([]*ContentView, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
if seen[ref.DocID] {
|
||||
continue
|
||||
}
|
||||
seen[ref.DocID] = true
|
||||
rec, err := s.Gov.GetDocument(ctx, ref.DocID)
|
||||
if err != nil || rec == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
if projectID == 0 {
|
||||
projectID = rec.ProjectID
|
||||
} else if rec.ProjectID != projectID {
|
||||
return nil, ErrCrossProject
|
||||
}
|
||||
view, err := s.Get(ctx, p, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) viewFromLedger(ctx context.Context, rec *docgov.DocumentRecord, md, sha string) *ContentView {
|
||||
view := &ContentView{
|
||||
DocID: rec.DocID,
|
||||
Version: rec.Version,
|
||||
ContentSHA256: sha,
|
||||
Markdown: md,
|
||||
IsTemplateOnly: rec.IsTemplateOnly,
|
||||
Consistency: ConsistencyAligned,
|
||||
RecordSHA256: rec.ContentSHA256,
|
||||
HeadSHA256: sha,
|
||||
}
|
||||
if s.Rev == nil {
|
||||
return view
|
||||
}
|
||||
class, err := s.Rev.InspectProjection(ctx, rec.DocID, s.RepoRoot)
|
||||
if err != nil {
|
||||
view.Consistency = ConsistencyMissing
|
||||
return view
|
||||
}
|
||||
view.FileSHA256 = class.RawSHA
|
||||
switch class.Class {
|
||||
case "", docrevision.ProjectionAligned, docrevision.ProjectionNoLedger:
|
||||
view.Consistency = ConsistencyAligned
|
||||
case docrevision.ProjectionMissing:
|
||||
view.Consistency = ConsistencyMissing
|
||||
case docrevision.ProjectionEmpty:
|
||||
view.Consistency = ConsistencyEmpty
|
||||
default:
|
||||
view.Consistency = ConsistencyDrift
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (v *ContentView) ToDocumentContent() *docgov.DocumentContent {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return &docgov.DocumentContent{
|
||||
DocID: v.DocID,
|
||||
Version: v.Version,
|
||||
ContentSHA256: v.ContentSHA256,
|
||||
Markdown: v.Markdown,
|
||||
IsTemplateOnly: v.IsTemplateOnly,
|
||||
Consistency: v.Consistency,
|
||||
RecordSHA256: v.RecordSHA256,
|
||||
FileSHA256: v.FileSHA256,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package docquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func setupLedger(t *testing.T, docID, body, workDir string) (*Service, *docgov.GovernanceEngine, *docrevision.Service) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
gov := docgov.NewGovernanceEngine(nil)
|
||||
if _, err := gov.CreateOrGetDocument(ctx, docID, 7, "intake", "t", "V0.5_feasibility", workDir, 1, "A", 2, "R", 3, "P"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rev := docrevision.NewService(nil)
|
||||
if _, err := rev.OpenTemplateVersion(ctx, docID, body, docrevision.ActorSnapshot{}, 7, workDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &Service{
|
||||
Gov: gov, Rev: rev, RepoRoot: "",
|
||||
Authorize: func(context.Context, Principal, *docgov.DocumentRecord) error { return nil },
|
||||
}
|
||||
return svc, gov, rev
|
||||
}
|
||||
|
||||
func TestGetServesLedgerWhenProjectionMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
svc, _, _ := setupLedger(t, "doc_q_missing", "# ledger\n", dir)
|
||||
view, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_q_missing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Markdown != "# ledger\n" || view.Consistency != ConsistencyMissing {
|
||||
t.Fatalf("%+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAlignedWhenProjectionMatches(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
body := "# ledger\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "output.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, _, _ := setupLedger(t, "doc_q_aligned", body, dir)
|
||||
view, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_q_aligned"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Consistency != ConsistencyAligned || view.Markdown != body {
|
||||
t.Fatalf("%+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDriftStillServesLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "output.md"), []byte("# file fork\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, _, _ := setupLedger(t, "doc_q_drift", "# ledger\n", dir)
|
||||
view, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_q_drift"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Consistency != ConsistencyDrift || view.Markdown != "# ledger\n" {
|
||||
t.Fatalf("%+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEmptyProjectionStillServesLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "output.md"), []byte{}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, _, _ := setupLedger(t, "doc_q_empty", "# ledger\n", dir)
|
||||
view, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_q_empty"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Consistency != ConsistencyEmpty || view.Markdown != "# ledger\n" {
|
||||
t.Fatalf("%+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLedgerMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gov := docgov.NewGovernanceEngine(nil)
|
||||
if _, err := gov.CreateOrGetDocument(ctx, "doc_q_noleger", 7, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &Service{Gov: gov, Rev: docrevision.NewService(nil), Authorize: func(context.Context, Principal, *docgov.DocumentRecord) error { return nil }}
|
||||
_, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_q_noleger"})
|
||||
if err != ErrLedgerMissing {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIsReadOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
svc, _, _ := setupLedger(t, "doc_q_ro", "# ledger\n", dir)
|
||||
before, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_q_ro"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("GET wrote files: before=%d after=%d", len(before), len(after))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFailClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, _, _ := setupLedger(t, "doc_q_deny", "# ledger\n", t.TempDir())
|
||||
if _, err := svc.Get(ctx, Principal{}, DocRef{DocID: "doc_q_deny"}); err != ErrClosed {
|
||||
t.Fatalf("anon: %v", err)
|
||||
}
|
||||
svc.Authorize = func(context.Context, Principal, *docgov.DocumentRecord) error { return ErrClosed }
|
||||
if _, err := svc.Get(ctx, Principal{UserID: 99}, DocRef{DocID: "doc_q_deny"}); err != ErrClosed {
|
||||
t.Fatalf("denied: %v", err)
|
||||
}
|
||||
if _, err := svc.Get(ctx, Principal{UserID: 1}, DocRef{DocID: "doc_missing"}); err != ErrClosed {
|
||||
t.Fatalf("unknown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchGetLimitsAndCrossProject(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, gov, _ := setupLedger(t, "doc_q_b1", "# a\n", t.TempDir())
|
||||
if _, err := gov.CreateOrGetDocument(ctx, "doc_q_b2", 8, "intake", "t", "V0.5", "", 1, "A", 2, "R", 3, "P"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := make([]DocRef, 101)
|
||||
for i := range refs {
|
||||
refs[i] = DocRef{DocID: "doc_q_b1"}
|
||||
}
|
||||
if _, err := svc.BatchGet(ctx, Principal{UserID: 1}, refs); err != ErrBatchLimit {
|
||||
t.Fatalf("101: %v", err)
|
||||
}
|
||||
if _, err := svc.BatchGet(ctx, Principal{UserID: 1}, []DocRef{{DocID: "doc_q_b1"}, {DocID: "doc_q_b2"}}); err != ErrCrossProject {
|
||||
t.Fatalf("cross project: %v", err)
|
||||
}
|
||||
got, err := svc.BatchGet(ctx, Principal{UserID: 1}, []DocRef{{DocID: "doc_q_b1"}, {DocID: "doc_q_b1"}})
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("dedupe: %v n=%d", err, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavDegradedReadDefaultOff(t *testing.T) {
|
||||
if FavDegradedReadEnabled() {
|
||||
t.Fatal("FAV_DEGRADED_READ must default false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package docrevision_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/docflow"
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/db"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestDocumentCommitConcurrencyPG(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("postgresql concurrency is not part of -short")
|
||||
}
|
||||
dsn := db.SkipIfNoTestDatabaseURL(t)
|
||||
if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") ||
|
||||
strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test") ||
|
||||
strings.Contains(dsn, "5435") {
|
||||
t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
var projectID int64
|
||||
if err := pool.QueryRow(ctx, `INSERT INTO projects (code, name, status) VALUES ($1,$2,'active') RETURNING id`,
|
||||
"docflow-"+suffix, "docflow").Scan(&projectID); err != nil {
|
||||
t.Fatalf("project: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_change_events WHERE doc_id LIKE $1`, "docflow_"+suffix+"%")
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_versions WHERE doc_id LIKE $1`, "docflow_"+suffix+"%")
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_snapshots WHERE doc_id LIKE $1`, "docflow_"+suffix+"%")
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM document_records WHERE project_id=$1`, projectID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id=$1`, projectID)
|
||||
})
|
||||
|
||||
docID := "docflow_" + suffix
|
||||
rev := docrevision.NewService(pool)
|
||||
gov := docgov.NewGovernanceEngine(pool)
|
||||
if _, err := gov.CreateOrGetDocument(ctx, docID, projectID, "intake", "t", "V0.5_feasibility", "ws/"+docID, 1, "A", 2, "R", 3, "P"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := rev.OpenTemplateVersion(ctx, docID, "# base\n", docrevision.ActorSnapshot{}, projectID, "ws/"+docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds := &docflow.Commands{UoW: uow.NewPG(pool), Gov: gov, Rev: rev}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wins := 0
|
||||
var mu sync.Mutex
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := cmds.HumanEdit(ctx, docflow.Principal{UserID: 1}, docflow.HumanEditCommand{
|
||||
DocID: docID,
|
||||
Request: docrevision.HumanRevisionRequest{
|
||||
DocID: docID, Markdown: fmt.Sprintf("# base\n\nuser %d\n", i),
|
||||
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
||||
IdempotencyKey: fmt.Sprintf("pg-%s-%d", suffix, i), ActorUserID: 1,
|
||||
LifecycleState: "drafting", ProjectID: projectID, WorkDir: "ws/" + docID,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
wins++
|
||||
mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if wins != 1 {
|
||||
t.Fatalf("same etag concurrent edits: wins=%d", wins)
|
||||
}
|
||||
got, err := rev.GetRevisionState(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.HeadRevisionNo != 1 {
|
||||
t.Fatalf("head revision=%d", got.HeadRevisionNo)
|
||||
}
|
||||
}
|
||||
|
||||
func refuseSharedDSN(t *testing.T, dsn string) {
|
||||
t.Helper()
|
||||
if strings.Contains(dsn, "5433") || strings.Contains(dsn, "agent_system_dev") ||
|
||||
(strings.Contains(dsn, "5434") && strings.Contains(dsn, "agent_system_test")) ||
|
||||
strings.Contains(dsn, "5435") {
|
||||
t.Fatal("TEST_DATABASE_URL points at a shared env port; refuse agent_system_dev/test/main")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCommitTwoPoolsAndFourSessionsPG(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("postgresql concurrency is not part of -short")
|
||||
}
|
||||
dsn := db.SkipIfNoTestDatabaseURL(t)
|
||||
refuseSharedDSN(t, dsn)
|
||||
ctx := context.Background()
|
||||
poolA, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(poolA.Close)
|
||||
poolB, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(poolB.Close)
|
||||
if err := db.RunMigrationsOnceForTest(ctx, poolA, dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
var projectID int64
|
||||
if err := poolA.QueryRow(ctx, `INSERT INTO projects (code, name, status) VALUES ($1,$2,'active') RETURNING id`,
|
||||
"docflow2p-"+suffix, "docflow2p").Scan(&projectID); err != nil {
|
||||
t.Fatalf("project: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = poolA.Exec(context.Background(), `DELETE FROM document_change_events WHERE doc_id LIKE $1`, "docflow2p_"+suffix+"%")
|
||||
_, _ = poolA.Exec(context.Background(), `DELETE FROM document_versions WHERE doc_id LIKE $1`, "docflow2p_"+suffix+"%")
|
||||
_, _ = poolA.Exec(context.Background(), `DELETE FROM document_snapshots WHERE doc_id LIKE $1`, "docflow2p_"+suffix+"%")
|
||||
_, _ = poolA.Exec(context.Background(), `DELETE FROM document_records WHERE project_id=$1`, projectID)
|
||||
_, _ = poolA.Exec(context.Background(), `DELETE FROM projects WHERE id=$1`, projectID)
|
||||
})
|
||||
|
||||
docID := "docflow2p_" + suffix
|
||||
seed := docrevision.NewService(poolA)
|
||||
gov := docgov.NewGovernanceEngine(poolA)
|
||||
if _, err := gov.CreateOrGetDocument(ctx, docID, projectID, "intake", "t", "V0.5_feasibility", "ws/"+docID, 1, "A", 2, "R", 3, "P"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := seed.OpenTemplateVersion(ctx, docID, "# base\n", docrevision.ActorSnapshot{}, projectID, "ws/"+docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := &docflow.Commands{UoW: uow.NewPG(poolA), Gov: docgov.NewGovernanceEngine(poolA), Rev: docrevision.NewService(poolA)}
|
||||
b := &docflow.Commands{UoW: uow.NewPG(poolB), Gov: docgov.NewGovernanceEngine(poolB), Rev: docrevision.NewService(poolB)}
|
||||
edit := func(cmds *docflow.Commands, i int) error {
|
||||
_, err := cmds.HumanEdit(ctx, docflow.Principal{UserID: 1}, docflow.HumanEditCommand{
|
||||
DocID: docID,
|
||||
Request: docrevision.HumanRevisionRequest{
|
||||
DocID: docID, Markdown: fmt.Sprintf("# base\n\npool %d\n", i),
|
||||
ExpectedHeadSnapshot: &st.HeadSnapshotID, ExpectedRevisionNo: &st.HeadRevisionNo,
|
||||
IdempotencyKey: fmt.Sprintf("2p-%s-%d", suffix, i), ActorUserID: 1,
|
||||
LifecycleState: "drafting", ProjectID: projectID, WorkDir: "ws/" + docID,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); errs[0] = edit(a, 0) }()
|
||||
go func() { defer wg.Done(); errs[1] = edit(b, 1) }()
|
||||
wg.Wait()
|
||||
wins := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("two pools same etag: wins=%d errs=%v", wins, errs)
|
||||
}
|
||||
|
||||
// Four sessions: two pools × two sequential idempotent retries of the winner key must replay.
|
||||
winnerKey := ""
|
||||
if errs[0] == nil {
|
||||
winnerKey = fmt.Sprintf("2p-%s-0", suffix)
|
||||
} else {
|
||||
winnerKey = fmt.Sprintf("2p-%s-1", suffix)
|
||||
}
|
||||
replay := docrevision.HumanRevisionRequest{
|
||||
DocID: docID, Markdown: "# ignored-if-same-key\n",
|
||||
IdempotencyKey: winnerKey, ActorUserID: 1, LifecycleState: "drafting",
|
||||
ProjectID: projectID, WorkDir: "ws/" + docID,
|
||||
}
|
||||
first, err := a.HumanEdit(ctx, docflow.Principal{UserID: 1}, docflow.HumanEditCommand{DocID: docID, Request: replay})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := b.HumanEdit(ctx, docflow.Principal{UserID: 1}, docflow.HumanEditCommand{DocID: docID, Request: replay})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.State.HeadSnapshotID != second.State.HeadSnapshotID {
|
||||
t.Fatalf("idempotent replay across pools diverged")
|
||||
}
|
||||
|
||||
var extra sync.WaitGroup
|
||||
extraWins := 0
|
||||
var extraMu sync.Mutex
|
||||
st2, err := seed.GetRevisionState(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
extra.Add(1)
|
||||
go func(i int) {
|
||||
defer extra.Done()
|
||||
cmds := a
|
||||
if i%2 == 1 {
|
||||
cmds = b
|
||||
}
|
||||
_, err := cmds.HumanEdit(ctx, docflow.Principal{UserID: 1}, docflow.HumanEditCommand{
|
||||
DocID: docID,
|
||||
Request: docrevision.HumanRevisionRequest{
|
||||
DocID: docID, Markdown: fmt.Sprintf("# base\n\nfour %d\n", i),
|
||||
ExpectedHeadSnapshot: &st2.HeadSnapshotID, ExpectedRevisionNo: &st2.HeadRevisionNo,
|
||||
IdempotencyKey: fmt.Sprintf("4s-%s-%d", suffix, i), ActorUserID: 1,
|
||||
LifecycleState: "drafting", ProjectID: projectID, WorkDir: "ws/" + docID,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
extraMu.Lock()
|
||||
extraWins++
|
||||
extraMu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
extra.Wait()
|
||||
if extraWins != 1 {
|
||||
t.Fatalf("four sessions same etag: wins=%d", extraWins)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -444,6 +446,13 @@ func (s *Service) withTx(ctx context.Context, fn func(pgx.Tx) error) error {
|
||||
if !s.usingPG() {
|
||||
return nil
|
||||
}
|
||||
if scope, ok := uow.ScopeFrom(ctx); ok {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(tx)
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package docrevision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
)
|
||||
|
||||
// Repository is the ledger writer bound to a UnitOfWork scope.
|
||||
type Repository struct{}
|
||||
|
||||
func (Repository) InsertBlob(ctx context.Context, scope uow.TxScope, sha, markdown string) error {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size, created_at)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (content_sha256) DO NOTHING`,
|
||||
sha, markdown, int64(len(markdown)), time.Now().UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("docrevision: insert blob: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Repository) InsertSnapshot(ctx context.Context, scope uow.TxScope, sn Snapshot) (int64, error) {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return (&Service{}).insertSnapshotTX(ctx, tx, &sn)
|
||||
}
|
||||
|
||||
func (Repository) InsertVersion(ctx context.Context, scope uow.TxScope, v Version) (int64, error) {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return (&Service{}).insertVersionTX(ctx, tx, &v)
|
||||
}
|
||||
|
||||
func (Repository) InsertEvent(ctx context.Context, scope uow.TxScope, ev ChangeEvent) (int64, error) {
|
||||
tx, err := uow.AsPG(scope)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return (&Service{}).insertEventTX(ctx, tx, &ev)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -203,6 +205,11 @@ func (s *Service) CommitHumanRevision(ctx context.Context, req HumanRevisionRequ
|
||||
|
||||
if eid, ok := s.idemp[req.IdempotencyKey]; ok {
|
||||
ev := s.events[eid]
|
||||
if ev != nil {
|
||||
if snap := s.snapshots[ev.ToSnapshotID]; snap != nil && snap.ContentSHA256 != sha {
|
||||
return nil, ErrIdempotencyConflict
|
||||
}
|
||||
}
|
||||
st, _ := s.revisionStateLocked(req.DocID)
|
||||
overlay := s.manualOverlayLocked(req.DocID)
|
||||
return &HumanRevisionResult{State: *st, Event: *ev, Overlay: overlay}, nil
|
||||
@@ -672,7 +679,7 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.Notify && s.OnOutbox != nil {
|
||||
if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil {
|
||||
_ = s.OnOutbox(ctx, "document.agent_version_committed", map[string]any{
|
||||
"doc_id": c.DocID, "version_no": newVer.VersionNo, "risk": rep.RiskLevel, "candidate_id": c.ID,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package freedoc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
)
|
||||
|
||||
// Repository is a M3 stub. M1 freeform projection stays on the existing service path.
|
||||
type Repository struct{}
|
||||
|
||||
func (Repository) ForbiddenInM1(ctx context.Context, scope uow.TxScope) error {
|
||||
if _, err := uow.AsPG(scope); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("freedoc: repository writes are not enabled until M3")
|
||||
}
|
||||
@@ -9,11 +9,24 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/app/docflow"
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/freedoc"
|
||||
)
|
||||
|
||||
func (s *Server) docflowCommands() *docflow.Commands {
|
||||
if s == nil || s.svc == nil {
|
||||
return nil
|
||||
}
|
||||
cmds := &docflow.Commands{Gov: s.svc.DocGov(), Rev: s.svc.DocRevision()}
|
||||
if pool := s.svc.Pool(); pool != nil {
|
||||
cmds.UoW = uow.NewPG(pool)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (s *Server) registerDocumentRevisionRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/documents/{doc_id}/revision-state", s.handleDocRevisionState)
|
||||
mux.HandleFunc("GET /api/v1/documents/{doc_id}/versions", s.handleDocRevisionListVersions)
|
||||
@@ -235,12 +248,20 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := rev.CommitHumanRevision(r.Context(), docrevision.HumanRevisionRequest{
|
||||
DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev,
|
||||
IdempotencyKey: idemp, EditSessionID: body.EditSessionID, ActorUserID: user.ID,
|
||||
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
|
||||
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: false,
|
||||
ClientBlockMap: body.ClientBlockMap,
|
||||
cmds := s.docflowCommands()
|
||||
if cmds == nil || cmds.Rev == nil {
|
||||
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
|
||||
return
|
||||
}
|
||||
res, err := cmds.HumanEdit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleAuthor}, docflow.HumanEditCommand{
|
||||
DocID: docID,
|
||||
Request: docrevision.HumanRevisionRequest{
|
||||
DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev,
|
||||
IdempotencyKey: idemp, EditSessionID: body.EditSessionID, ActorUserID: user.ID,
|
||||
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
|
||||
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: false,
|
||||
ClientBlockMap: body.ClientBlockMap,
|
||||
},
|
||||
})
|
||||
if errors.Is(err, docrevision.ErrHeadConflict) && res != nil && res.Conflict != nil {
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]any{
|
||||
@@ -343,11 +364,19 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
cand, _ := rev.GetCandidate(r.Context(), docID, cid)
|
||||
st, err := rev.CommitAgentVersion(r.Context(), docID, cid, docrevision.CommitAgentDecision{
|
||||
Decision: body.Decision, Confirmed: body.Confirmed,
|
||||
ExpectedInputSHA: body.ExpectedInputSHA, ExpectedOutputSHA: body.ExpectedOutputSHA,
|
||||
Reason: body.Reason, ActorUserID: user.ID,
|
||||
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name},
|
||||
cmds := s.docflowCommands()
|
||||
if cmds == nil || cmds.Rev == nil {
|
||||
writeJSONResponse(w, http.StatusServiceUnavailable, map[string]any{"error": "docrevision unavailable"})
|
||||
return
|
||||
}
|
||||
st, err := cmds.AgentCommit(r.Context(), docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleAuthor}, docflow.AgentCommitCommand{
|
||||
DocID: docID, CandidateID: cid,
|
||||
Decision: docrevision.CommitAgentDecision{
|
||||
Decision: body.Decision, Confirmed: body.Confirmed,
|
||||
ExpectedInputSHA: body.ExpectedInputSHA, ExpectedOutputSHA: body.ExpectedOutputSHA,
|
||||
Reason: body.Reason, ActorUserID: user.ID,
|
||||
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
writeDocRevisionError(w, err)
|
||||
@@ -457,8 +486,12 @@ func writeDocRevisionError(w http.ResponseWriter, err error) {
|
||||
writeJSONResponse(w, http.StatusUnprocessableEntity, map[string]any{"code": docrevision.ErrCodeReportInvalid, "error": err.Error()})
|
||||
case errors.Is(err, docrevision.ErrTooLarge):
|
||||
writeJSONResponse(w, http.StatusRequestEntityTooLarge, map[string]any{"code": docrevision.ErrCodeTooLarge, "error": err.Error()})
|
||||
case errors.Is(err, docrevision.ErrForbidden):
|
||||
case errors.Is(err, docrevision.ErrForbidden), errors.Is(err, uow.ErrPermissionDenied):
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]any{"code": docrevision.ErrCodeEditForbidden, "error": err.Error()})
|
||||
case errors.Is(err, docrevision.ErrIdempotencyConflict), errors.Is(err, uow.ErrDigestConflict):
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": "DOCUMENT_IDEMPOTENCY_CONFLICT", "error": err.Error()})
|
||||
case errors.Is(err, uow.ErrBusy):
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]any{"code": "DOCUMENT_BUSY", "error": err.Error()})
|
||||
default:
|
||||
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
}
|
||||
|
||||
+64
-47
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/docflow"
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/docgov"
|
||||
@@ -21,15 +23,15 @@ import (
|
||||
|
||||
// HealthResponse 健康检查响应
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Env string `json:"env"`
|
||||
AgentBackend string `json:"agent_backend"`
|
||||
Version string `json:"version"`
|
||||
SharedKBPort int `json:"shared_kb_port"`
|
||||
DatabaseReady bool `json:"database_ready"`
|
||||
SchemaVersion int64 `json:"schema_version,omitempty"`
|
||||
SchemaReady bool `json:"schema_ready,omitempty"`
|
||||
TaskStoreReady bool `json:"task_store_ready,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Env string `json:"env"`
|
||||
AgentBackend string `json:"agent_backend"`
|
||||
Version string `json:"version"`
|
||||
SharedKBPort int `json:"shared_kb_port"`
|
||||
DatabaseReady bool `json:"database_ready"`
|
||||
SchemaVersion int64 `json:"schema_version,omitempty"`
|
||||
SchemaReady bool `json:"schema_ready,omitempty"`
|
||||
TaskStoreReady bool `json:"task_store_ready,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest 对话请求
|
||||
@@ -50,32 +52,32 @@ type ChatRequest struct {
|
||||
|
||||
// TaskRunRequestDTO 独立任务运行请求
|
||||
type TaskRunRequestDTO struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
Stage string `json:"stage,omitempty"` // 若指定 stage,则执行成熟度阶段跃迁
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
AutoRepair bool `json:"auto_repair"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
ProjectID int64 `json:"project_id,omitempty"`
|
||||
DocID string `json:"doc_id,omitempty"`
|
||||
AuthorID int64 `json:"author_id,omitempty"`
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
ReviewerID int64 `json:"reviewer_id,omitempty"`
|
||||
ReviewerName string `json:"reviewer_name,omitempty"`
|
||||
ApproverID int64 `json:"approver_id,omitempty"`
|
||||
ApproverName string `json:"approver_name,omitempty"`
|
||||
ForceDirectEdit bool `json:"force_direct_edit,omitempty"`
|
||||
CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ClientTurnID string `json:"client_turn_id,omitempty"`
|
||||
TaskKey string `json:"task_key"`
|
||||
Stage string `json:"stage,omitempty"` // 若指定 stage,则执行成熟度阶段跃迁
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
AutoRepair bool `json:"auto_repair"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
ProjectID int64 `json:"project_id,omitempty"`
|
||||
DocID string `json:"doc_id,omitempty"`
|
||||
AuthorID int64 `json:"author_id,omitempty"`
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
ReviewerID int64 `json:"reviewer_id,omitempty"`
|
||||
ReviewerName string `json:"reviewer_name,omitempty"`
|
||||
ApproverID int64 `json:"approver_id,omitempty"`
|
||||
ApproverName string `json:"approver_name,omitempty"`
|
||||
ForceDirectEdit bool `json:"force_direct_edit,omitempty"`
|
||||
CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"`
|
||||
WorkflowKey string `json:"workflow_key,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
ClientTurnID string `json:"client_turn_id,omitempty"`
|
||||
// Materials 用户上传的参考文档材料,由前端 Base64 编码后传入;后端 json 反序列化自动解码 []byte
|
||||
Materials []bizdev.MaterialInput `json:"materials,omitempty"`
|
||||
Materials []bizdev.MaterialInput `json:"materials,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -427,11 +429,11 @@ func (s *Server) handleGetUserContext(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// UpdateUserPreferenceRequest 更新用户工作偏好请求
|
||||
type UpdateUserPreferenceRequest struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
CommunicationStyle ctxmgr.CommunicationStyle `json:"communication_style"`
|
||||
FocusAreas []string `json:"focus_areas"`
|
||||
OutputFormatPref string `json:"output_format_pref"`
|
||||
CustomInstructions string `json:"custom_instructions"`
|
||||
UserID int64 `json:"user_id"`
|
||||
CommunicationStyle ctxmgr.CommunicationStyle `json:"communication_style"`
|
||||
FocusAreas []string `json:"focus_areas"`
|
||||
OutputFormatPref string `json:"output_format_pref"`
|
||||
CustomInstructions string `json:"custom_instructions"`
|
||||
}
|
||||
|
||||
// handleUpdateUserPreference 定期整理或前端保存用户习惯偏好
|
||||
@@ -528,10 +530,10 @@ func (s *Server) handleDocGovGetDocument(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// DocGovClaimRequest 认领文档请求
|
||||
type DocGovClaimRequest struct {
|
||||
DocID string `json:"doc_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserRole string `json:"user_role"` // author, reviewer, approver
|
||||
DocID string `json:"doc_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserRole string `json:"user_role"` // author, reviewer, approver
|
||||
}
|
||||
|
||||
// handleDocGovClaim 查阅并认领受控文档 (非模板状态查看方可认领)
|
||||
@@ -609,7 +611,7 @@ type DocGovDecisionRequest struct {
|
||||
UserName string `json:"user_name"`
|
||||
Action string `json:"action"` // approve 或 reject
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Reason string `json:"reason,omitempty"` // 驳回时必填
|
||||
Reason string `json:"reason,omitempty"` // 驳回时必填
|
||||
RollbackTo string `json:"rollback_to,omitempty"` // 批准人驳回时可选: drafting 或 reviewing
|
||||
}
|
||||
|
||||
@@ -681,24 +683,39 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(req.Action))
|
||||
cmds := s.docflowCommands()
|
||||
if cmds == nil || cmds.Gov == nil {
|
||||
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
p := docflow.Principal{UserID: user.ID, Name: user.Name, Role: docgov.RoleApprover}
|
||||
var doc *docgov.DocumentRecord
|
||||
var err error
|
||||
|
||||
if action == "approve" {
|
||||
doc, err = s.svc.DocGov().ApproverApprove(r.Context(), req.DocID, user.ID, docgov.RoleApprover, user.Name, req.Comment)
|
||||
doc, err = cmds.Approve(r.Context(), p, docflow.DecisionCommand{DocID: req.DocID, Comment: req.Comment})
|
||||
} else if action == "reject" {
|
||||
reason := req.Reason
|
||||
if reason == "" {
|
||||
reason = req.Comment
|
||||
}
|
||||
rollbackState := docgov.DocumentState(req.RollbackTo)
|
||||
doc, err = s.svc.DocGov().ApproverReject(r.Context(), req.DocID, user.ID, docgov.RoleApprover, user.Name, reason, rollbackState)
|
||||
doc, err = cmds.Reject(r.Context(), p, docflow.DecisionCommand{
|
||||
DocID: req.DocID, RejectReason: reason, RollbackTo: docgov.DocumentState(req.RollbackTo),
|
||||
})
|
||||
} else {
|
||||
http.Error(w, "invalid action: must be approve or reject", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, uow.ErrPermissionDenied) {
|
||||
http.Error(w, "approver decision failed: "+err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, uow.ErrBusy) {
|
||||
http.Error(w, "approver decision failed: "+err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "approver decision failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -47,20 +46,19 @@ func TestWriteControlledContentError_MapsTypedCodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeLedgerHeadOrConflict_RefusesDestructiveHead(t *testing.T) {
|
||||
func TestServeLedgerHeadOrConflict_ServesLedgerOnDrift(t *testing.T) {
|
||||
raw := "条目一 \n条目二 \n"
|
||||
head := docrevision.LegacyDestructiveCanonicalizeMarkdown(raw)
|
||||
class := docrevision.ClassifyProjection([]byte(raw), head)
|
||||
if class.Class != docrevision.ProjectionLegacyCanonicalizationDrift {
|
||||
t.Fatalf("class=%s", class.Class)
|
||||
}
|
||||
_, err := serveLedgerHeadOrConflict("doc_x", 1, false, head, docrevision.SHA256Hex(head), docrevision.SHA256Hex(head), class)
|
||||
var ce *docgov.ContentError
|
||||
if !errors.As(err, &ce) || ce.Code != docgov.ErrCodeProjectionDrift {
|
||||
t.Fatalf("want projection drift, got %v", err)
|
||||
gotDrift, err := serveLedgerHeadOrConflict("doc_x", 1, false, head, docrevision.SHA256Hex(head), docrevision.SHA256Hex(head), class)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ce.Class != docrevision.ProjectionLegacyCanonicalizationDrift {
|
||||
t.Fatalf("class %s", ce.Class)
|
||||
if gotDrift.Consistency != "drift" || gotDrift.Markdown != head {
|
||||
t.Fatalf("drift must still serve ledger: %+v", gotDrift)
|
||||
}
|
||||
|
||||
aligned := docrevision.ClassifyProjection([]byte(raw), docrevision.CanonicalizeMarkdown(raw))
|
||||
|
||||
@@ -3,16 +3,55 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/docflow"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docquery"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func (s *Server) docflowQuery() *docflow.Query {
|
||||
if s == nil || s.svc == nil {
|
||||
return nil
|
||||
}
|
||||
return &docflow.Query{Docs: &docquery.Service{
|
||||
Gov: s.svc.DocGov(),
|
||||
Rev: s.docRev(),
|
||||
RepoRoot: s.repoRoot(),
|
||||
Authorize: func(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error {
|
||||
return s.authorizeControlledRead(ctx, p, rec)
|
||||
},
|
||||
FavDegradedRead: false,
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *Server) authorizeControlledRead(ctx context.Context, p docquery.Principal, rec *docgov.DocumentRecord) error {
|
||||
if rec == nil || p.UserID == 0 {
|
||||
return docquery.ErrClosed
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return docquery.ErrClosed
|
||||
}
|
||||
admin := false
|
||||
if s.authzSvc != nil {
|
||||
ok, _ := s.authzSvc.IsSystemAdmin(ctx, p.UserID, "")
|
||||
admin = ok
|
||||
}
|
||||
if s.authorizer != nil {
|
||||
if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, rec.ProjectID, p.UserID, admin); err != nil {
|
||||
return docquery.ErrClosed
|
||||
}
|
||||
}
|
||||
if rec.TaskKey != "" && !s.allowWorkflowDocTaskAccess(ctx, rec.ProjectID, p.UserID, rec.TaskKey) {
|
||||
return docquery.ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) repoRoot() string {
|
||||
if s.cfg != nil && s.cfg.DataDir != "" {
|
||||
// DataDir 形如 ./data/dev;工作区路径形如 data/dev/workspaces/...,相对仓库根目录
|
||||
@@ -61,96 +100,72 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
if s.svc == nil || s.svc.DocGov() == nil {
|
||||
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
docRec, err := s.svc.DocGov().GetDocument(r.Context(), docID)
|
||||
if err != nil || docRec == nil {
|
||||
http.Error(w, "document not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
user, ok := s.requireProjectAccess(w, r, docRec.ProjectID)
|
||||
user, ok := s.requireAuthenticatedUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.allowWorkflowDocTaskAccess(r.Context(), docRec.ProjectID, user.ID, docRec.TaskKey) {
|
||||
http.Error(w, `{"error":"WORKFLOW_ACCESS_DENIED","message":"无权访问该阶段文档"}`, http.StatusForbidden)
|
||||
q := s.docflowQuery()
|
||||
if q == nil || q.Docs == nil || q.Docs.Gov == nil {
|
||||
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := s.loadControlledDocumentContent(r.Context(), docID)
|
||||
view, err := q.Get(r.Context(), user.ID, docID)
|
||||
if err != nil {
|
||||
if errors.Is(err, docquery.ErrLedgerMissing) && view != nil {
|
||||
writeJSONResponse(w, http.StatusOK, view)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, docquery.ErrClosed) {
|
||||
writeJSONResponse(w, http.StatusNotFound, map[string]any{
|
||||
"error_code": docgov.ErrCodeDocumentNotFound, "message": "document not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeControlledContentError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if inm := r.Header.Get("If-None-Match"); inm != "" && inm == content.ContentSHA256 {
|
||||
if inm := r.Header.Get("If-None-Match"); inm != "" && inm == view.ContentSHA256 {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", content.ContentSHA256)
|
||||
writeJSONResponse(w, http.StatusOK, content)
|
||||
w.Header().Set("ETag", view.ContentSHA256)
|
||||
writeJSONResponse(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *Server) loadControlledDocumentContent(ctx context.Context, docID string) (*docgov.DocumentContent, error) {
|
||||
if rev := s.docRev(); rev != nil {
|
||||
if md, sha, err := rev.HeadContent(ctx, docID); err == nil && md != "" {
|
||||
class, ierr := rev.InspectProjection(ctx, docID, s.repoRoot())
|
||||
if ierr != nil {
|
||||
return nil, ierr
|
||||
}
|
||||
doc, _ := s.svc.DocGov().GetDocument(ctx, docID)
|
||||
ver := 0
|
||||
tmpl := false
|
||||
recordSHA := sha
|
||||
if doc != nil {
|
||||
ver = doc.Version
|
||||
tmpl = doc.IsTemplateOnly
|
||||
if doc.ContentSHA256 != "" {
|
||||
recordSHA = doc.ContentSHA256
|
||||
}
|
||||
}
|
||||
return serveLedgerHeadOrConflict(docID, ver, tmpl, md, sha, recordSHA, class)
|
||||
}
|
||||
if s == nil || s.svc == nil || s.svc.DocGov() == nil {
|
||||
return nil, docgov.ErrDocumentNotFound
|
||||
}
|
||||
svc := &docquery.Service{Gov: s.svc.DocGov(), Rev: s.docRev(), RepoRoot: s.repoRoot()}
|
||||
view, err := svc.Get(ctx, docquery.Principal{UserID: 1}, docquery.DocRef{DocID: docID})
|
||||
if err != nil && !errors.Is(err, docquery.ErrLedgerMissing) {
|
||||
return nil, err
|
||||
}
|
||||
if view != nil && view.Markdown != "" {
|
||||
return view.ToDocumentContent(), nil
|
||||
}
|
||||
return s.svc.DocGov().ReadDocumentContent(ctx, docID, s.repoRoot())
|
||||
}
|
||||
|
||||
// serveLedgerHeadOrConflict returns ledger head only when the projection is aligned.
|
||||
// Legacy destructive drift or a real fork must not silently display the old stripped head.
|
||||
// serveLedgerHeadOrConflict keeps the historical helper name. Projection is a
|
||||
// consistency cache; ledger head is served for missing/empty/drift.
|
||||
func serveLedgerHeadOrConflict(docID string, version int, tmpl bool, headMD, headSHA, recordSHA string, class docrevision.ProjectionClass) (*docgov.DocumentContent, error) {
|
||||
consistency := docquery.ConsistencyAligned
|
||||
switch class.Class {
|
||||
case "", docrevision.ProjectionAligned, docrevision.ProjectionNoLedger:
|
||||
return &docgov.DocumentContent{
|
||||
DocID: docID, Version: version, ContentSHA256: headSHA, Markdown: headMD, IsTemplateOnly: tmpl,
|
||||
}, nil
|
||||
consistency = docquery.ConsistencyAligned
|
||||
case docrevision.ProjectionMissing:
|
||||
ce := docgov.NewContentError(docgov.ErrCodeProjectionMissing, docgov.ErrContentProjectionMissing, true, true)
|
||||
ce.RecordSHA = recordSHA
|
||||
ce.HeadSHA = headSHA
|
||||
ce.Class = class.Class
|
||||
return nil, ce
|
||||
consistency = docquery.ConsistencyMissing
|
||||
case docrevision.ProjectionEmpty:
|
||||
ce := docgov.NewContentError(docgov.ErrCodeProjectionEmpty, docgov.ErrContentProjectionEmpty, false, true)
|
||||
ce.RecordSHA = recordSHA
|
||||
ce.HeadSHA = headSHA
|
||||
ce.Class = class.Class
|
||||
return nil, ce
|
||||
consistency = docquery.ConsistencyEmpty
|
||||
default:
|
||||
ce := docgov.NewContentError(
|
||||
docgov.ErrCodeProjectionDrift,
|
||||
fmt.Errorf("document content projection drift %s; repair required before serving ledger head", class.Class),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
ce.RecordSHA = recordSHA
|
||||
ce.FileSHA = class.RawSHA
|
||||
ce.HeadSHA = headSHA
|
||||
ce.Class = class.Class
|
||||
return nil, ce
|
||||
consistency = docquery.ConsistencyDrift
|
||||
}
|
||||
return &docgov.DocumentContent{
|
||||
DocID: docID, Version: version, ContentSHA256: headSHA, Markdown: headMD, IsTemplateOnly: tmpl,
|
||||
Consistency: consistency, RecordSHA256: recordSHA, FileSHA256: class.RawSHA,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeControlledContentError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
@@ -165,15 +180,15 @@ func writeControlledContentError(w http.ResponseWriter, r *http.Request, err err
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
writeJSONResponse(w, status, map[string]any{
|
||||
"error_code": ce.Code,
|
||||
"message": ce.Error(),
|
||||
"retryable": ce.Retryable,
|
||||
"needs_admin": ce.NeedsAdmin,
|
||||
"correlation_id": corr,
|
||||
"record_sha256": ce.RecordSHA,
|
||||
"file_sha256": ce.FileSHA,
|
||||
"head_sha256": ce.HeadSHA,
|
||||
"projection_class": ce.Class,
|
||||
"error_code": ce.Code,
|
||||
"message": ce.Error(),
|
||||
"retryable": ce.Retryable,
|
||||
"needs_admin": ce.NeedsAdmin,
|
||||
"correlation_id": corr,
|
||||
"record_sha256": ce.RecordSHA,
|
||||
"file_sha256": ce.FileSHA,
|
||||
"head_sha256": ce.HeadSHA,
|
||||
"projection_class": ce.Class,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package taskrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/app/uow"
|
||||
)
|
||||
|
||||
// Repository is a M3 stub. M1 must not write task_runs through this port.
|
||||
type Repository struct{}
|
||||
|
||||
func (Repository) ForbiddenInM1(ctx context.Context, scope uow.TxScope) error {
|
||||
if _, err := uow.AsPG(scope); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("taskrun: repository writes are not enabled until M3")
|
||||
}
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Architecture boundary check (U1).
|
||||
# Production import / SQL write / path construction / event producer-consumer.
|
||||
# Goose, *_test.go and fixtures are not production writers.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MODE="warn"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode) MODE="${2:-warn}"; shift 2 ;;
|
||||
--mode=*) MODE="${1#--mode=}"; shift ;;
|
||||
*) echo "usage: $0 [--mode warn|block]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${MODE}" != "warn" && "${MODE}" != "block" ]]; then
|
||||
echo "mode must be warn or block" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cd "${ROOT}"
|
||||
echo "arch-check mode=${MODE}"
|
||||
echo "boundaries=${ROOT}/.doc/system-design/module-boundaries.yaml"
|
||||
|
||||
if ! go test ./internal/archtest -count=1; then
|
||||
echo "arch-check: new, expired, or unowned findings failed go test ./internal/archtest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${MODE}" == "block" ]]; then
|
||||
if ! ARCHTEST_STRICT=1 go test ./internal/archtest -count=1 -run TestArchitectureBoundaries; then
|
||||
echo "arch-check block: baseline exceptions remain; only cleared rules may use --mode block" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "arch-check ${MODE}: production gate passed"
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* M1 live: ledger-first GET. Projection missing must not become 404/409.
|
||||
* Project 143 is read-only. Do not terminate or PUT workflow-plan.
|
||||
*/
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1';
|
||||
const ASSETS = process.env.UI_ACCEPTANCE_ASSETS_DIR
|
||||
? path.resolve(process.env.UI_ACCEPTANCE_ASSETS_DIR)
|
||||
: path.resolve(
|
||||
__dirname,
|
||||
'../../.doc/test-reports/assets/2026-09-14-document-reliability-pilot-live',
|
||||
);
|
||||
const SCREENSHOTS = path.join(ASSETS, 'screenshots');
|
||||
fs.mkdirSync(SCREENSHOTS, { recursive: true });
|
||||
|
||||
const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || '';
|
||||
const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || '';
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
|
||||
const TARGET_DOC = 'doc_proj143_intake';
|
||||
|
||||
test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the rebuilt dev stack');
|
||||
|
||||
async function login(page: Page, username: string, password: string) {
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder*="系统用户名"]', username);
|
||||
await page.fill('input[placeholder*="登录密码"]', password);
|
||||
await page.click('button:has-text("进入医疗协同工作台")');
|
||||
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe.serial('controlled document reload M1 live', () => {
|
||||
test('project 143 content is 200 with consistency; anonymous is 401; unknown is 404', async ({
|
||||
browser,
|
||||
}) => {
|
||||
if (!ADMIN_USER || !ADMIN_PASS) {
|
||||
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
|
||||
}
|
||||
const page = await browser.newPage();
|
||||
const contentHits: { status: number; consistency?: string; error?: string }[] = [];
|
||||
page.on('response', async (res) => {
|
||||
const u = res.url();
|
||||
if (!u.includes(`/docgov/documents/${TARGET_DOC}/content`)) return;
|
||||
let consistency: string | undefined;
|
||||
let error: string | undefined;
|
||||
try {
|
||||
const json = (await res.json()) as { consistency?: string; error_code?: string };
|
||||
consistency = json.consistency;
|
||||
error = json.error_code;
|
||||
} catch {
|
||||
/* ignore non-json */
|
||||
}
|
||||
contentHits.push({ status: res.status(), consistency, error });
|
||||
});
|
||||
|
||||
await login(page, ADMIN_USER, ADMIN_PASS);
|
||||
await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click({ force: true });
|
||||
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
|
||||
await page.screenshot({ path: path.join(SCREENSHOTS, '01-project-143.png'), fullPage: true });
|
||||
|
||||
const token = await page.evaluate(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('smdf_auth_session');
|
||||
if (!raw) return '';
|
||||
const session = JSON.parse(raw) as { token?: string };
|
||||
return session.token || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
if (!token) {
|
||||
throw new Error('login did not persist smdf_auth_session.token');
|
||||
}
|
||||
|
||||
const authed = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const authedBody = await authed.json();
|
||||
expect(authed.status(), `content ${JSON.stringify(authedBody)}`).toBe(200);
|
||||
expect(authedBody.markdown, 'ledger markdown required').toBeTruthy();
|
||||
expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authedBody.consistency);
|
||||
expect(authedBody.error_code ?? '').not.toMatch(/PROJECTION/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
|
||||
const afterReload = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
expect(afterReload.status()).toBe(200);
|
||||
const reloadBody = await afterReload.json();
|
||||
expect(reloadBody.markdown).toBeTruthy();
|
||||
|
||||
await page.screenshot({ path: path.join(SCREENSHOTS, '02-after-reload.png'), fullPage: true });
|
||||
|
||||
const anon = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`);
|
||||
expect(anon.status()).toBe(401);
|
||||
|
||||
const missing = await page.request.get(`${BASE}/api/v1/docgov/documents/doc_does_not_exist_m1/content`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
expect(missing.status()).toBe(404);
|
||||
const missingBody = await missing.json();
|
||||
expect(missingBody.error_code).toBe('DOCUMENT_NOT_FOUND');
|
||||
|
||||
const projection409 = contentHits.filter(
|
||||
(h) => h.status === 409 || String(h.error || '').includes('PROJECTION'),
|
||||
);
|
||||
expect(projection409, JSON.stringify(contentHits)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -25,12 +25,22 @@ export type WorkflowDocumentSummary = {
|
||||
approver_name?: string;
|
||||
};
|
||||
|
||||
export type ControlledDocConsistency =
|
||||
| 'aligned'
|
||||
| 'missing'
|
||||
| 'drift'
|
||||
| 'empty'
|
||||
| 'ledger_missing';
|
||||
|
||||
export type ControlledDocumentContent = {
|
||||
doc_id: string;
|
||||
version: number;
|
||||
content_sha256: string;
|
||||
markdown: string;
|
||||
is_template_only: boolean;
|
||||
consistency?: ControlledDocConsistency;
|
||||
record_sha256?: string;
|
||||
file_sha256?: string;
|
||||
};
|
||||
|
||||
export async function listProjectWorkflowDocuments(
|
||||
|
||||
@@ -172,6 +172,22 @@ describe('controlledDocHydrate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hydrate treats projection missing as ledger content, not 404', async () => {
|
||||
const ast = await hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
||||
fetchContent: async () => ({
|
||||
doc_id: 'doc_proj56_intake',
|
||||
version: 1,
|
||||
content_sha256: 'abc',
|
||||
markdown: '# 账本正文\n',
|
||||
is_template_only: false,
|
||||
consistency: 'missing',
|
||||
}),
|
||||
fetchRevisionState: async () => ({ etag: '1:0' }),
|
||||
});
|
||||
assert.ok(ast);
|
||||
assert.equal(ast?.revisionEtag, '1:0');
|
||||
});
|
||||
|
||||
it('attaches hydrate ETag as PATCH baseline and ignores a later GET', async () => {
|
||||
const ast = await hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
||||
fetchContent: async () => ({
|
||||
|
||||
@@ -150,7 +150,9 @@ export async function hydrateControlledDocumentContent(
|
||||
(err: unknown) => ({ ok: false as const, err }),
|
||||
),
|
||||
]);
|
||||
if (!detail?.markdown) return null;
|
||||
// Typed consistency (missing/drift/empty) is not a 404. Only the absence
|
||||
// of ledger markdown fails hydrate; never refetch to "repair" a missing projection.
|
||||
if (detail.consistency === 'ledger_missing' || !detail.markdown) return null;
|
||||
if (!rev.ok) {
|
||||
throw rev.err instanceof Error ? rev.err : new Error('revision-state hydrate failed');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user