From 5a5a91dd7f4a30bfaeaea59c161725fd31fd66ab Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 05:15:41 +0000 Subject: [PATCH] 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. --- .doc/ops/CURRENT.md | 1 + ...26-09-20-proposed-authoritative-reports.md | 53 ++++++ .doc/status/FEATURE-MATRIX.md | 2 +- internal/docrevision/pg_mutate.go | 30 +++- internal/docrevision/pg_store.go | 14 ++ internal/docrevision/report_disposition.go | 156 ++++++++++++++++++ .../report_disposition_lift_test.go | 19 +++ .../docrevision/report_disposition_test.go | 150 +++++++++++++++++ internal/docrevision/service.go | 71 ++++++-- internal/docrevision/types.go | 21 ++- 10 files changed, 501 insertions(+), 16 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-20-proposed-authoritative-reports.md create mode 100644 internal/docrevision/report_disposition.go create mode 100644 internal/docrevision/report_disposition_lift_test.go create mode 100644 internal/docrevision/report_disposition_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 2fd6ecbd..16baa9c4 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -15,6 +15,7 @@ | 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-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 | diff --git a/.doc/ops/short-term/2026-09-20-proposed-authoritative-reports.md b/.doc/ops/short-term/2026-09-20-proposed-authoritative-reports.md new file mode 100644 index 00000000..c575d0d7 --- /dev/null +++ b/.doc/ops/short-term/2026-09-20-proposed-authoritative-reports.md @@ -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` diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 31f25203..cd8eb5ac 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -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` | | 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` | -| 文档版本 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` | | 前端工作台 | 三栏 + 块文档 + 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` | diff --git a/internal/docrevision/pg_mutate.go b/internal/docrevision/pg_mutate.go index a15d63a3..9d93a0c4 100644 --- a/internal/docrevision/pg_mutate.go +++ b/internal/docrevision/pg_mutate.go @@ -143,7 +143,7 @@ func (s *Service) insertContentProjectionTX(ctx context.Context, tx pgx.Tx, docI 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() { return nil } @@ -175,6 +175,11 @@ func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snap return err } s.adoptCandidateID(c, cid) + for _, old := range superseded { + if err := s.updateReportFactsTX(ctx, tx, old); err != nil { + return err + } + } return nil }) } @@ -190,6 +195,29 @@ func (s *Service) persistAgentCommitLocked(ctx context.Context, root *DocRootPoi if err := s.updateVersionTX(ctx, tx, oldVer); err != nil { 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) if err != nil { return err diff --git a/internal/docrevision/pg_store.go b/internal/docrevision/pg_store.go index a710c122..eff7da5c 100644 --- a/internal/docrevision/pg_store.go +++ b/internal/docrevision/pg_store.go @@ -200,6 +200,7 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { 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) } + liftReportDisposition(&rep) cp := rep s.reports[cp.ID] = &cp if cp.ID > s.nextRepID { @@ -415,6 +416,19 @@ func (s *Service) insertReportTX(ctx context.Context, tx pgx.Tx, rep *ChangeRepo 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) { actorRaw, _ := json.Marshal(c.ActorSnapshot) if actorRaw == nil { diff --git a/internal/docrevision/report_disposition.go b/internal/docrevision/report_disposition.go new file mode 100644 index 00000000..4468b3a0 --- /dev/null +++ b/internal/docrevision/report_disposition.go @@ -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) + } + } +} diff --git a/internal/docrevision/report_disposition_lift_test.go b/internal/docrevision/report_disposition_lift_test.go new file mode 100644 index 00000000..afe1cc83 --- /dev/null +++ b/internal/docrevision/report_disposition_lift_test.go @@ -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") + } +} diff --git a/internal/docrevision/report_disposition_test.go b/internal/docrevision/report_disposition_test.go new file mode 100644 index 00000000..96a63d16 --- /dev/null +++ b/internal/docrevision/report_disposition_test.go @@ -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) + } + } +} diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index 5e0cdd22..bd6227cd 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -522,14 +522,21 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe if NeedsConfirmation(rep.RiskLevel) { status = CandidateNeedsConfirmation } - if *root.HeadSnapshotID != req.InputSnapshotID { - status = CandidateStale - } rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil }) rep.DocID = req.DocID rep.FromSnapshotID = req.InputSnapshotID rep.ToSnapshotID = outSnap.ID 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 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(), } 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) return nil, nil, err } @@ -601,6 +608,23 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat } if *root.HeadSnapshotID != c.InputSnapshotID { 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 } if root.LifecycleState != "" && root.LifecycleState != "drafting" { @@ -625,8 +649,19 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat now := time.Now().UTC() c.Status = CandidateRejected c.DecidedAt = &now + if c.ChangeReportID != nil { + s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportRejected) + } 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) return nil, err } @@ -643,6 +678,9 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat now := time.Now().UTC() c.Status = CandidateCommitted c.DecidedAt = &now + if c.ChangeReportID != nil { + s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportCommitted) + } if s.usingPG() { if err := s.withTx(ctx, func(tx pgx.Tx) error { eid, err := s.insertEventTX(ctx, tx, ev) @@ -650,7 +688,13 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat return err } 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 { s.forgetDocLocked(docID) return nil, err @@ -670,7 +714,9 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat origin = OriginAgentSquad } 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.HeadSnapshotID = &outSnap.ID root.HeadRevisionNo = 0 @@ -690,16 +736,17 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat return nil, err } - if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil { - // document.agent_version_committed is owned by events.EventSink (U6). - for _, imp := range rep.DocumentImpacts { + if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil && auth.ID != 0 { + // document.agent_version_committed is owned by events.EventSink (U6) and is not emitted here. + // 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{ "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{ - "doc_id": c.DocID, "report_id": rep.ID, + "doc_id": c.DocID, "report_id": auth.ID, }) } } diff --git a/internal/docrevision/types.go b/internal/docrevision/types.go index 5e2c46bf..0a62220f 100644 --- a/internal/docrevision/types.go +++ b/internal/docrevision/types.go @@ -56,6 +56,17 @@ const ( AnalysisUnassessed = "unassessed" 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" ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN" ErrCodeBusy = "DOCUMENT_BUSY" @@ -196,8 +207,14 @@ type ChangeReport struct { PromptVersion string `json:"prompt_version,omitempty"` DeterministicFacts map[string]any `json:"deterministic_facts"` CorrectsReportID *int64 `json:"corrects_report_id,omitempty"` - RawJSON string `json:"-"` - CreatedAt time.Time `json:"created_at"` + // Authority is proposed on a candidate estimate and authoritative on the formal-commit successor. + 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 {