feat(docrevision): keep candidate estimates separate from committed reports

A candidate used the same report later attached to the version, so a preview could be treated as the formal result. Proposed reports now close on replace or reject, and only the commit writes an authoritative successor.
This commit is contained in:
weichen
2026-09-20 05:15:41 +00:00
parent db1706cd24
commit 5a5a91dd7f
10 changed files with 501 additions and 16 deletions
+1
View File
@@ -15,6 +15,7 @@
| owner | task | branch | worktree | mutex | status | | owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------| |-------|------|--------|----------|-------|--------|
| Cursor-Auto | 2026-09-20-proposed-authoritative-reports | feature/capability-enablement-impact-plan | - | docgov | done |
| Cursor-Auto | 2026-09-20-pi-narrative-clipped-diff | feature/capability-enablement-impact-plan | - | docgov | done | | Cursor-Auto | 2026-09-20-pi-narrative-clipped-diff | feature/capability-enablement-impact-plan | - | docgov | done |
| Cursor-Auto | 2026-09-20-pi-reasoning-profile | feature/capability-enablement-impact-plan | - | pi-agent | done | | Cursor-Auto | 2026-09-20-pi-reasoning-profile | feature/capability-enablement-impact-plan | - | pi-agent | done |
| Cursor-Auto | 2026-09-20-semantic-summary-persistence | feature/capability-enablement-impact-plan | - | chat+lineage-version | done | | Cursor-Auto | 2026-09-20-semantic-summary-persistence | feature/capability-enablement-impact-plan | - | chat+lineage-version | done |
@@ -0,0 +1,53 @@
---
title: "候选变更报告与正式提交报告分开"
status: done
created: 2026-09-20
updated: 2026-09-20
owner: Cursor-Auto
branch: feature/capability-enablement-impact-plan
worktree: "-"
mutex: docgov
---
# 候选变更报告与正式提交报告分开
## 目标
Agent 候选只持久化 `proposed` 预估报告,不发下游影响或正式提交事件。正式提交才生成 authoritative 后继报告,并让版本指向后继而不是预估。
## 范围
- 报告增加 `authority`、`lifecycle`、`successor_report_id`,并写入已有 `deterministic_facts`,避免在未占集成槽时改表。
- 新的有效候选把上一份仍 active 的预估标为 superseded。
- 拒绝标 rejected;提交时基线已变则标 expired。这两种以及 superseded、已提交的预估都不能再出建议。
- 正式提交复制一份 authoritative 报告;下游影响通知使用这份报告。`document.agent_version_committed` 仍只由 docflow 的 EventSink 在提交命令里发出。
## 非目标
- 不把人工修订、恢复、导入改成先预估再提交。
- 不打开 `PI_CHANGE_REPORT_NARRATIVE`,不实现 trace_reviewer,不做 T2 物理删除。
- 不新增 `document.committed` 事件名,避免和现有投影消费者分叉。
- 不重启容器,不占集成槽位。
## 步骤
1. [x] 候选写入 proposed,提交写入 authoritative 后继
2. [x] 单测覆盖替代、拒绝、版本不指向预估、候选阶段不发下游事件
3. [x] 同步 FEATURE-MATRIX 与 CURRENT
## 验收标准
- [x] `go test ./internal/docrevision/` 通过
- [x] 预估报告在替代或拒绝后 `AllowsSuggestedActions` 为 false
## 相关链接
- Checklist:
- Handoff:
- Test report: 本切片为账本单测,未做真实聊天或浏览器验收
## 证据 / 备注
- `internal/docrevision/report_disposition.go`
- `TestProposedReportStaysInactiveUntilCommit`
- `TestRejectedProposedReportCannotSuggest`
+1 -1
View File
@@ -61,7 +61,7 @@
| 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md) | **before**:L1 + UI 仍克隆医疗模板。**after W5a**:catalog 驱动创建、软件不挂医疗 key、docs_only 真 PI 建议 delivery 且不自动激活。**SWBOOT-10 仍 stub**(W5b);本行不升 done | `workflow-design` | | 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md) | **before**:L1 + UI 仍克隆医疗模板。**after W5a**:catalog 驱动创建、软件不挂医疗 key、docs_only 真 PI 建议 delivery 且不自动激活。**SWBOOT-10 仍 stub**(W5b);本行不升 done | `workflow-design` |
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` |
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` |
| 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。项目追踪索引和影响报告仍见整改方案 Track T | `docgov` | | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告。项目追踪索引见整改方案 Track T | `docgov` |
| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` | | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` |
| 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [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);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` | | PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [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);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` |
+29 -1
View File
@@ -143,7 +143,7 @@ func (s *Service) insertContentProjectionTX(ctx context.Context, tx pgx.Tx, docI
return err return err
} }
func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snapshot, rep *ChangeReport, c *AgentCandidate) error { func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snapshot, rep *ChangeReport, c *AgentCandidate, superseded []*ChangeReport) error {
if !s.usingPG() { if !s.usingPG() {
return nil return nil
} }
@@ -175,6 +175,11 @@ func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snap
return err return err
} }
s.adoptCandidateID(c, cid) s.adoptCandidateID(c, cid)
for _, old := range superseded {
if err := s.updateReportFactsTX(ctx, tx, old); err != nil {
return err
}
}
return nil return nil
}) })
} }
@@ -190,6 +195,29 @@ func (s *Service) persistAgentCommitLocked(ctx context.Context, root *DocRootPoi
if err := s.updateVersionTX(ctx, tx, oldVer); err != nil { if err := s.updateVersionTX(ctx, tx, oldVer); err != nil {
return err return err
} }
if newVer.ChangeReportID != nil {
if auth := s.reports[*newVer.ChangeReportID]; auth != nil && auth.Authority == ReportAuthoritative {
rid, err := s.insertReportTX(ctx, tx, auth)
if err != nil {
return err
}
s.adoptReportID(auth, rid)
newVer.ChangeReportID = &auth.ID
if c.ChangeReportID != nil {
if proposed := s.reports[*c.ChangeReportID]; proposed != nil {
proposed.SuccessorReportID = &auth.ID
}
}
for _, other := range s.reports {
if other == nil || other.DocID != root.DocID || other.Authority != ReportProposed {
continue
}
if err := s.updateReportFactsTX(ctx, tx, other); err != nil {
return err
}
}
}
}
vid, err := s.insertVersionTX(ctx, tx, newVer) vid, err := s.insertVersionTX(ctx, tx, newVer)
if err != nil { if err != nil {
return err return err
+14
View File
@@ -200,6 +200,7 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error {
if err := json.Unmarshal(factRaw, &rep.DeterministicFacts); err != nil { if err := json.Unmarshal(factRaw, &rep.DeterministicFacts); err != nil {
return fmt.Errorf("hydrateDocLocked: unmarshal deterministic_facts for doc %s report %d: %w", docID, rep.ID, err) return fmt.Errorf("hydrateDocLocked: unmarshal deterministic_facts for doc %s report %d: %w", docID, rep.ID, err)
} }
liftReportDisposition(&rep)
cp := rep cp := rep
s.reports[cp.ID] = &cp s.reports[cp.ID] = &cp
if cp.ID > s.nextRepID { if cp.ID > s.nextRepID {
@@ -415,6 +416,19 @@ func (s *Service) insertReportTX(ctx context.Context, tx pgx.Tx, rep *ChangeRepo
return id, err return id, err
} }
func (s *Service) updateReportFactsTX(ctx context.Context, tx pgx.Tx, rep *ChangeReport) error {
if rep == nil || rep.ID == 0 {
return nil
}
stampReportDisposition(rep)
factRaw, _ := json.Marshal(rep.DeterministicFacts)
if factRaw == nil {
factRaw = []byte("{}")
}
_, err := tx.Exec(ctx, `UPDATE document_change_reports SET deterministic_facts=$2::jsonb WHERE id=$1`, rep.ID, factRaw)
return err
}
func (s *Service) insertCandidateTX(ctx context.Context, tx pgx.Tx, c *AgentCandidate) (int64, error) { func (s *Service) insertCandidateTX(ctx context.Context, tx pgx.Tx, c *AgentCandidate) (int64, error) {
actorRaw, _ := json.Marshal(c.ActorSnapshot) actorRaw, _ := json.Marshal(c.ActorSnapshot)
if actorRaw == nil { if actorRaw == nil {
+156
View File
@@ -0,0 +1,156 @@
package docrevision
// AllowsSuggestedActions is true only for the report a user may still act on.
// A proposed estimate stops being actionable once it is superseded, rejected,
// expired, or replaced by an authoritative successor. The successor itself remains actionable.
func (r *ChangeReport) AllowsSuggestedActions() bool {
if r == nil {
return false
}
switch {
case r.Authority == ReportProposed && r.Lifecycle == ReportActive:
return true
case r.Authority == ReportAuthoritative && r.Lifecycle == ReportCommitted:
return true
default:
return false
}
}
func stampReportDisposition(rep *ChangeReport) {
if rep == nil {
return
}
if rep.DeterministicFacts == nil {
rep.DeterministicFacts = map[string]any{}
}
if rep.Authority != "" {
rep.DeterministicFacts["report_authority"] = rep.Authority
}
if rep.Lifecycle != "" {
rep.DeterministicFacts["report_lifecycle"] = rep.Lifecycle
}
if rep.SuccessorReportID != nil {
rep.DeterministicFacts["successor_report_id"] = *rep.SuccessorReportID
}
}
func liftReportDisposition(rep *ChangeReport) {
if rep == nil || rep.DeterministicFacts == nil {
return
}
if rep.Authority == "" {
if a, ok := rep.DeterministicFacts["report_authority"].(string); ok {
rep.Authority = a
}
}
if rep.Lifecycle == "" {
if life, ok := rep.DeterministicFacts["report_lifecycle"].(string); ok {
rep.Lifecycle = life
}
}
if rep.SuccessorReportID == nil {
if id, ok := factInt64(rep.DeterministicFacts["successor_report_id"]); ok {
rep.SuccessorReportID = &id
}
}
}
func factInt64(v any) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case int:
return int64(n), true
case float64:
return int64(n), true
case jsonNumber:
i, err := n.Int64()
return i, err == nil
default:
return 0, false
}
}
// jsonNumber is the subset of encoding/json.Number used after a UseNumber decode.
type jsonNumber interface {
Int64() (int64, error)
}
func cloneChangeReport(src *ChangeReport) *ChangeReport {
if src == nil {
return nil
}
cp := *src
cp.ID = 0
cp.SuccessorReportID = nil
cp.Summary = append([]string(nil), src.Summary...)
cp.RiskItems = append([]RiskItem(nil), src.RiskItems...)
cp.DocumentImpacts = append([]DocImpact(nil), src.DocumentImpacts...)
cp.OpenQuestions = append([]string(nil), src.OpenQuestions...)
cp.DeterministicFacts = map[string]any{}
for k, v := range src.DeterministicFacts {
cp.DeterministicFacts[k] = v
}
if src.ProjectImpacts != nil {
cp.ProjectImpacts = map[string]any{}
for k, v := range src.ProjectImpacts {
cp.ProjectImpacts[k] = v
}
}
return &cp
}
func (s *Service) assignReportIDLocked(rep *ChangeReport) {
rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil })
s.reports[rep.ID] = rep
}
// supersedeActiveProposedLocked closes earlier estimates for the same document.
// The new candidate is the only active proposed report.
func (s *Service) supersedeActiveProposedLocked(docID string) []*ChangeReport {
var out []*ChangeReport
for _, rep := range s.reports {
if rep == nil || rep.DocID != docID || rep.Authority != ReportProposed || rep.Lifecycle != ReportActive {
continue
}
rep.Lifecycle = ReportSuperseded
stampReportDisposition(rep)
out = append(out, rep)
}
return out
}
func (s *Service) sealProposedWithSuccessorLocked(proposed *ChangeReport) *ChangeReport {
auth := cloneChangeReport(proposed)
auth.Authority = ReportAuthoritative
auth.Lifecycle = ReportCommitted
auth.SuccessorReportID = nil
auth.CreatedAt = proposed.CreatedAt
s.assignReportIDLocked(auth)
proposed.Lifecycle = ReportCommitted
proposed.SuccessorReportID = &auth.ID
stampReportDisposition(proposed)
stampReportDisposition(auth)
return auth
}
func (s *Service) closeProposedLocked(rep *ChangeReport, lifecycle string) {
if rep == nil || rep.Authority != ReportProposed || rep.Lifecycle != ReportActive {
return
}
rep.Lifecycle = lifecycle
stampReportDisposition(rep)
}
func (s *Service) expireOtherProposedLocked(docID string, keep int64) {
for _, rep := range s.reports {
if rep == nil || rep.ID == keep || rep.DocID != docID {
continue
}
if rep.Authority == ReportProposed && rep.Lifecycle == ReportActive {
rep.Lifecycle = ReportExpired
stampReportDisposition(rep)
}
}
}
@@ -0,0 +1,19 @@
package docrevision
import "testing"
func TestLiftReportDisposition_ReadsPersistedFacts(t *testing.T) {
id := int64(42)
rep := &ChangeReport{DeterministicFacts: map[string]any{
"report_authority": ReportProposed,
"report_lifecycle": ReportCommitted,
"successor_report_id": float64(id),
}}
liftReportDisposition(rep)
if rep.Authority != ReportProposed || rep.Lifecycle != ReportCommitted || rep.SuccessorReportID == nil || *rep.SuccessorReportID != id {
t.Fatalf("lifted = %+v", rep)
}
if rep.AllowsSuggestedActions() {
t.Fatal("committed proposed report must not accept new actions")
}
}
@@ -0,0 +1,150 @@
package docrevision_test
import (
"context"
"testing"
"chat-agent-system/internal/docrevision"
)
func TestProposedReportStaysInactiveUntilCommit(t *testing.T) {
svc := docrevision.NewService(nil)
svc.Notify = true
var events []string
committed := false
svc.OnOutbox = func(_ context.Context, eventType string, payload map[string]any) error {
events = append(events, eventType)
if !committed && (eventType == "document.downstream_impact_detected" || eventType == "document.agent_version_committed") {
t.Fatalf("candidate path emitted %s payload=%v", eventType, payload)
}
return nil
}
ctx := context.Background()
docID := "doc_proposed_split"
st, err := svc.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
if err != nil {
t.Fatal(err)
}
first, firstRep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: docID, RunID: "run-a", InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: "# base\n\nbody changed\n",
ActorKind: docrevision.ActorAgent,
})
if err != nil || first == nil || firstRep == nil {
t.Fatalf("first candidate: cand=%v rep=%v err=%v", first, firstRep, err)
}
if firstRep.Authority != docrevision.ReportProposed || firstRep.Lifecycle != docrevision.ReportActive || !firstRep.AllowsSuggestedActions() {
t.Fatalf("first report = %+v", firstRep)
}
for _, ev := range events {
if ev != "document.agent_candidate_ready" {
t.Fatalf("unexpected event before commit: %s", ev)
}
}
second, secondRep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: docID, RunID: "run-b", InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: "# base\n\nbody changed again\n",
ActorKind: docrevision.ActorAgent,
})
if err != nil || second == nil {
t.Fatalf("second candidate: %v err=%v", second, err)
}
again, err := svc.GetChangeReport(ctx, docID, firstRep.ID)
if err != nil {
t.Fatal(err)
}
if again.Lifecycle != docrevision.ReportSuperseded || again.AllowsSuggestedActions() {
t.Fatalf("older estimate must be closed: %+v", again)
}
if secondRep.Lifecycle != docrevision.ReportActive || !secondRep.AllowsSuggestedActions() {
t.Fatalf("newer estimate must stay active: %+v", secondRep)
}
events = nil
committed = true
st2, err := svc.CommitAgentVersion(ctx, docID, second.ID, docrevision.CommitAgentDecision{
Decision: "commit", ActorUserID: 1, ExpectedInputSHA: st.HeadSHA256,
})
if err != nil {
t.Fatal(err)
}
if st2.CurrentVersionNo != 2 {
t.Fatalf("version = %+v", st2)
}
committedProposed, err := svc.GetChangeReport(ctx, docID, secondRep.ID)
if err != nil {
t.Fatal(err)
}
if committedProposed.Lifecycle != docrevision.ReportCommitted || committedProposed.AllowsSuggestedActions() {
t.Fatalf("proposed report must stop accepting actions: %+v", committedProposed)
}
if committedProposed.SuccessorReportID == nil || *committedProposed.SuccessorReportID == secondRep.ID {
t.Fatalf("missing distinct successor: %+v", committedProposed)
}
auth, err := svc.GetChangeReport(ctx, docID, *committedProposed.SuccessorReportID)
if err != nil {
t.Fatal(err)
}
if auth.Authority != docrevision.ReportAuthoritative || !auth.AllowsSuggestedActions() {
t.Fatalf("authoritative report = %+v", auth)
}
versions, err := svc.ListVersions(ctx, docID)
if err != nil {
t.Fatal(err)
}
var sawAuth bool
for _, v := range versions {
if v.ChangeReportID == nil {
continue
}
if *v.ChangeReportID == secondRep.ID {
t.Fatal("version must not point at the proposed estimate")
}
if *v.ChangeReportID == auth.ID {
sawAuth = true
}
}
if !sawAuth {
t.Fatalf("version missing authoritative report id %d in %+v", auth.ID, versions)
}
for _, ev := range events {
if ev == "document.agent_version_committed" {
t.Fatal("docrevision must not emit the formal commit event itself")
}
}
}
func TestRejectedProposedReportCannotSuggest(t *testing.T) {
svc := docrevision.NewService(nil)
ctx := context.Background()
docID := "doc_proposed_reject"
st, err := svc.OpenTemplateVersion(ctx, docID, "# base\n\n法规引用 GB9706\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
if err != nil {
t.Fatal(err)
}
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: docID, RunID: "run-reject", InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: "# base\n\n",
ActorKind: docrevision.ActorAgent,
})
if cand == nil || rep == nil {
t.Fatalf("cand=%v rep=%v err=%v", cand, rep, err)
}
if _, err := svc.CommitAgentVersion(ctx, docID, cand.ID, docrevision.CommitAgentDecision{Decision: "reject", ActorUserID: 1}); err != nil {
t.Fatal(err)
}
got, err := svc.GetChangeReport(ctx, docID, rep.ID)
if err != nil {
t.Fatal(err)
}
if got.Lifecycle != docrevision.ReportRejected || got.AllowsSuggestedActions() {
t.Fatalf("rejected report = %+v", got)
}
versions, _ := svc.ListVersions(ctx, docID)
for _, v := range versions {
if v.VersionNo > 1 {
t.Fatalf("reject must not open a new version: %+v", versions)
}
}
}
+59 -12
View File
@@ -522,14 +522,21 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
if NeedsConfirmation(rep.RiskLevel) { if NeedsConfirmation(rep.RiskLevel) {
status = CandidateNeedsConfirmation status = CandidateNeedsConfirmation
} }
if *root.HeadSnapshotID != req.InputSnapshotID {
status = CandidateStale
}
rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil }) rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil })
rep.DocID = req.DocID rep.DocID = req.DocID
rep.FromSnapshotID = req.InputSnapshotID rep.FromSnapshotID = req.InputSnapshotID
rep.ToSnapshotID = outSnap.ID rep.ToSnapshotID = outSnap.ID
rep.CreatedAt = time.Now().UTC() rep.CreatedAt = time.Now().UTC()
rep.Authority = ReportProposed
rep.Lifecycle = ReportActive
var superseded []*ChangeReport
if *root.HeadSnapshotID != req.InputSnapshotID {
status = CandidateStale
rep.Lifecycle = ReportExpired
} else {
superseded = s.supersedeActiveProposedLocked(req.DocID)
}
stampReportDisposition(rep)
s.reports[rep.ID] = rep s.reports[rep.ID] = rep
c := &AgentCandidate{ c := &AgentCandidate{
@@ -539,7 +546,7 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(), ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(),
} }
s.candidates[c.ID] = c s.candidates[c.ID] = c
if err := s.persistAgentCandidateLocked(ctx, outSnap, rep, c); err != nil { if err := s.persistAgentCandidateLocked(ctx, outSnap, rep, c, superseded); err != nil {
s.forgetDocLocked(req.DocID) s.forgetDocLocked(req.DocID)
return nil, nil, err return nil, nil, err
} }
@@ -601,6 +608,23 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
} }
if *root.HeadSnapshotID != c.InputSnapshotID { if *root.HeadSnapshotID != c.InputSnapshotID {
c.Status = CandidateStale c.Status = CandidateStale
if c.ChangeReportID != nil {
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportExpired)
}
if s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
if c.ChangeReportID == nil {
return nil
}
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
}); err != nil {
s.forgetDocLocked(docID)
return nil, err
}
}
return nil, ErrCandidateStale return nil, ErrCandidateStale
} }
if root.LifecycleState != "" && root.LifecycleState != "drafting" { if root.LifecycleState != "" && root.LifecycleState != "drafting" {
@@ -625,8 +649,19 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
now := time.Now().UTC() now := time.Now().UTC()
c.Status = CandidateRejected c.Status = CandidateRejected
c.DecidedAt = &now c.DecidedAt = &now
if c.ChangeReportID != nil {
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportRejected)
}
if s.usingPG() { if s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error { return s.updateCandidateTX(ctx, tx, c) }); err != nil { if err := s.withTx(ctx, func(tx pgx.Tx) error {
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
if c.ChangeReportID == nil {
return nil
}
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
}); err != nil {
s.forgetDocLocked(docID) s.forgetDocLocked(docID)
return nil, err return nil, err
} }
@@ -643,6 +678,9 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
now := time.Now().UTC() now := time.Now().UTC()
c.Status = CandidateCommitted c.Status = CandidateCommitted
c.DecidedAt = &now c.DecidedAt = &now
if c.ChangeReportID != nil {
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportCommitted)
}
if s.usingPG() { if s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error { if err := s.withTx(ctx, func(tx pgx.Tx) error {
eid, err := s.insertEventTX(ctx, tx, ev) eid, err := s.insertEventTX(ctx, tx, ev)
@@ -650,7 +688,13 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
return err return err
} }
s.adoptEventID(ev, eid) s.adoptEventID(ev, eid)
return s.updateCandidateTX(ctx, tx, c) if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
}
if c.ChangeReportID == nil {
return nil
}
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
}); err != nil { }); err != nil {
s.forgetDocLocked(docID) s.forgetDocLocked(docID)
return nil, err return nil, err
@@ -670,7 +714,9 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
origin = OriginAgentSquad origin = OriginAgentSquad
} }
newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID) newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID)
newVer.ChangeReportID = c.ChangeReportID auth := s.sealProposedWithSuccessorLocked(rep)
s.expireOtherProposedLocked(c.DocID, rep.ID)
newVer.ChangeReportID = &auth.ID
root.LedgerVersionID = &newVer.ID root.LedgerVersionID = &newVer.ID
root.HeadSnapshotID = &outSnap.ID root.HeadSnapshotID = &outSnap.ID
root.HeadRevisionNo = 0 root.HeadRevisionNo = 0
@@ -690,16 +736,17 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
return nil, err return nil, err
} }
if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil { if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil && auth.ID != 0 {
// document.agent_version_committed is owned by events.EventSink (U6). // document.agent_version_committed is owned by events.EventSink (U6) and is not emitted here.
for _, imp := range rep.DocumentImpacts { // Downstream notices belong to the authoritative report, never to the proposed estimate.
for _, imp := range auth.DocumentImpacts {
_ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{ _ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{
"doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo, "doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo,
}) })
} }
if rep.AnalysisStatus == AnalysisUnassessed || rep.AnalysisStatus == AnalysisFallback { if auth.AnalysisStatus == AnalysisUnassessed || auth.AnalysisStatus == AnalysisFallback {
_ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{ _ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{
"doc_id": c.DocID, "report_id": rep.ID, "doc_id": c.DocID, "report_id": auth.ID,
}) })
} }
} }
+19 -2
View File
@@ -56,6 +56,17 @@ const (
AnalysisUnassessed = "unassessed" AnalysisUnassessed = "unassessed"
AnalysisCorrected = "corrected" AnalysisCorrected = "corrected"
// Report authority separates a candidate estimate from the report written at formal commit.
ReportProposed = "proposed"
ReportAuthoritative = "authoritative"
// Proposed reports stay actionable only while active.
ReportActive = "active"
ReportSuperseded = "superseded"
ReportRejected = "rejected"
ReportExpired = "expired"
ReportCommitted = "committed"
ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT" ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT"
ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN" ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN"
ErrCodeBusy = "DOCUMENT_BUSY" ErrCodeBusy = "DOCUMENT_BUSY"
@@ -196,8 +207,14 @@ type ChangeReport struct {
PromptVersion string `json:"prompt_version,omitempty"` PromptVersion string `json:"prompt_version,omitempty"`
DeterministicFacts map[string]any `json:"deterministic_facts"` DeterministicFacts map[string]any `json:"deterministic_facts"`
CorrectsReportID *int64 `json:"corrects_report_id,omitempty"` CorrectsReportID *int64 `json:"corrects_report_id,omitempty"`
RawJSON string `json:"-"` // Authority is proposed on a candidate estimate and authoritative on the formal-commit successor.
CreatedAt time.Time `json:"created_at"` Authority string `json:"authority,omitempty"`
// Lifecycle is active, superseded, rejected, expired, or committed.
Lifecycle string `json:"lifecycle,omitempty"`
// SuccessorReportID points from a committed proposed report to its authoritative report.
SuccessorReportID *int64 `json:"successor_report_id,omitempty"`
RawJSON string `json:"-"`
CreatedAt time.Time `json:"created_at"`
} }
type RiskItem struct { type RiskItem struct {