fix(docgov): defer fact-source update until high-risk commit

Materialize needs_confirmation restores ledger head to output.md and
document_records hash; only committed heads update DocGov. Decision
handler projects after commit or reject. Stage gates DocGov proposals
via PendingProposal, not bare RequiresAuthorApproval.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 09:51:15 +00:00
co-authored by Cursor
parent 87971fc953
commit 4f5a6188a7
6 changed files with 522 additions and 45 deletions
+115 -15
View File
@@ -733,27 +733,23 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil {
_ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir)
sha := sha256.Sum256([]byte(res.MarkdownBody))
shaStr := hex.EncodeToString(sha[:])
_ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false)
if appliedProposalID != "" {
_ = s.docGov.MarkProposalApplied(ctx, appliedProposalID)
dataRoot := "."
if s.cfg != nil && s.cfg.DataDir != "" {
dataRoot = s.cfg.DataDir
}
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest
}
// DOCREV: first generation uses empty prior→agent output so a deterministic change report
// is always visible; subsequent runs candidate against current head. Never static fiction.
// Materialize failure must not be swallowed as task success.
// DOCREV: materialize first; only committed heads may update DocGov hash / proposal applied.
// Squad may have already written candidate body to workDir/output.md — pending must restore ledger head.
if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" {
actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed}
if _, matErr := s.docRev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
mat, matErr := s.docRev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey),
ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody,
ProjectID: req.ProjectID, WorkDir: res.WorkDir,
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor,
AutoCommitUserID: req.UserID,
}); matErr != nil {
})
if matErr != nil {
reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr)
return &TaskRunResult{
TaskKey: res.TaskKey,
@@ -772,6 +768,104 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
DocRecord: docRec,
}, matErr
}
if mat != nil && mat.Candidate != nil && mat.Candidate.Status == docrevision.CandidateNeedsConfirmation {
if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil {
reason := fmt.Sprintf("高风险待确认:恢复账本 head 到事实源失败: %v", err)
return &TaskRunResult{
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
Success: false, ErrorReason: reason, DocRecord: docRec,
}, err
}
headSHA := ""
if mat.State != nil {
headSHA = mat.State.HeadSHA256
}
if headSHA != "" {
if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil {
reason := fmt.Sprintf("高风险待确认:恢复 document_records 哈希失败: %v", err)
return &TaskRunResult{
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
Success: false, ErrorReason: reason, DocRecord: docRec,
}, err
}
}
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest
}
risk := ""
if mat.Report != nil {
risk = mat.Report.RiskLevel
}
var pendCand, pendRep int64
if mat.Candidate != nil {
pendCand = mat.Candidate.ID
if mat.Candidate.ChangeReportID != nil {
pendRep = *mat.Candidate.ChangeReportID
}
}
note := fmt.Sprintf(
"高风险变更(%s)待编制人确认:候选 #%d。正式正文仍为当前账本 head,候选正文仅保留在不可变快照中;请打开「待确认报告」确认提交或拒绝。",
risk, pendCand,
)
return &TaskRunResult{
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir,
BackendUsed: res.BackendUsed, Duration: res.Duration, Success: true,
QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief,
DocRecord: docRec,
// RequiresAuthorApproval signals "needs human decision"; distinguish from DocGov
// proposal intercept via PendingAgentCandidateID (no PendingProposal).
RequiresAuthorApproval: true,
PendingAgentCandidateID: pendCand, PendingChangeReportID: pendRep,
PendingRiskLevel: risk, PendingConfirmationNote: note,
}, nil
}
// Committed (auto-commit or ready path): project head then update DocGov.
if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil {
reason := fmt.Sprintf("账本已提交但工作区投影失败: %v", err)
return &TaskRunResult{
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
Success: false, ErrorReason: reason, DocRecord: docRec,
}, err
}
headSHA := ""
if mat != nil && mat.State != nil {
headSHA = mat.State.HeadSHA256
}
if headSHA == "" {
sha := sha256.Sum256([]byte(res.MarkdownBody))
headSHA = hex.EncodeToString(sha[:])
}
if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil {
reason := fmt.Sprintf("账本已提交但 DocGov 哈希投影失败: %v", err)
return &TaskRunResult{
TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody,
WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration,
Success: false, ErrorReason: reason, DocRecord: docRec,
}, err
}
if appliedProposalID != "" {
_ = s.docGov.MarkProposalApplied(ctx, appliedProposalID)
}
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest
}
} else {
// No docrevision: preserve prior best-effort hash update (CLI/legacy).
sha := sha256.Sum256([]byte(res.MarkdownBody))
shaStr := hex.EncodeToString(sha[:])
_ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false)
if appliedProposalID != "" {
_ = s.docGov.MarkProposalApplied(ctx, appliedProposalID)
}
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest
}
}
}
@@ -786,9 +880,14 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
IsStale: false,
})
}
if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 {
// Stale propagation only after fact source committed (not while pending confirmation).
if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 &&
(s.docRev == nil || (docRec != nil && docRec.ContentSHA256 != "")) {
sha := sha256.Sum256([]byte(res.MarkdownBody))
shaStr := hex.EncodeToString(sha[:])
if docRec != nil && docRec.ContentSHA256 != "" {
shaStr = docRec.ContentSHA256
}
_, _ = s.projectWorkflow.CheckAndPropagateStale(ctx, docID, shaStr, "上游系统需求规格书已更新,下游验证方案需复核")
}
@@ -939,7 +1038,8 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
}, err
}
if res != nil && res.RequiresAuthorApproval {
// DocGov claimed-doc proposal intercept only — not high-risk revision pending confirmation.
if res != nil && res.IsProposalIntercept() {
return &StageRunResult{
Stage: req.Stage,
StageTitle: targetMeta.Title,
+150
View File
@@ -0,0 +1,150 @@
package bizdev
import (
"fmt"
"strings"
"chat-agent-system/internal/squad"
)
// Terminal task statuses projected to chat / workflow_runs.
const (
TaskStatusCompleted = "completed"
TaskStatusFailed = "failed"
TaskStatusQualityFailed = "quality_failed"
TaskStatusPendingAuthorConfirmation = "pending_author_confirmation"
)
// AwaitsAgentConfirmation reports high/critical revision candidate awaiting author decision.
// Distinct from DocGov change-proposal intercept (PendingProposal).
func (r *TaskRunResult) AwaitsAgentConfirmation() bool {
return r != nil && r.PendingAgentCandidateID > 0
}
// IsProposalIntercept reports claimed-document DocGov change-proposal interception.
func (r *TaskRunResult) IsProposalIntercept() bool {
return r != nil && r.PendingProposal != nil && r.RequiresAuthorApproval
}
// TerminalStatus maps TaskRunResult to a conversation/workflow terminal status.
// Pending agent confirmation is never "completed" until commit+projection.
func (r *TaskRunResult) TerminalStatus() string {
if r == nil || !r.Success {
return TaskStatusFailed
}
if r.AwaitsAgentConfirmation() {
return TaskStatusPendingAuthorConfirmation
}
if r.QualityReport != nil && !r.QualityReport.Passed {
return TaskStatusQualityFailed
}
return TaskStatusCompleted
}
// CompletionSummary builds the assistant-visible completion text for sync and async finalizers.
func (r *TaskRunResult) CompletionSummary() string {
if r == nil {
return "任务执行失败。"
}
title := r.ArtifactTitle
if title == "" && r.TaskKey != "" {
title = squad.TaskArtifactTitle(r.TaskKey)
}
if title == "" {
title = "交付文档"
}
status := r.TerminalStatus()
var b strings.Builder
switch status {
case TaskStatusPendingAuthorConfirmation:
fmt.Fprintf(&b, "《%s》文档候选已生成,待编制人确认。\n\n", title)
b.WriteString("当前状态:**待编制人确认**")
if r.PendingRiskLevel != "" {
fmt.Fprintf(&b, "(风险:%s)", r.PendingRiskLevel)
}
b.WriteString("\n正式正文仍为账本当前 head;候选正文仅保留在不可变快照中。请打开文档「待确认报告」确认提交或拒绝后,正式正文才会更新。")
if r.PendingConfirmationNote != "" {
fmt.Fprintf(&b, "\n%s", r.PendingConfirmationNote)
}
fmt.Fprintf(&b, "\n候选 ID:%d", r.PendingAgentCandidateID)
if r.PendingChangeReportID > 0 {
fmt.Fprintf(&b, " · 变更报告:%d", r.PendingChangeReportID)
}
case TaskStatusQualityFailed:
fmt.Fprintf(&b, "《%s》已生成。\n\n", title)
b.WriteString("当前状态:**质量检查未通过,待整改**")
if r.QualityReport != nil {
fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score)
if len(r.QualityReport.Violations) > 0 {
fmt.Fprintf(&b, "\n主要问题:%s", strings.Join(r.QualityReport.Violations, ";"))
}
}
case TaskStatusCompleted:
fmt.Fprintf(&b, "《%s》已生成。\n\n", title)
b.WriteString("当前状态:**已完成**")
if r.QualityReport != nil && r.QualityReport.Passed {
fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score)
}
default:
fmt.Fprintf(&b, "《%s》未成功落盘。\n\n", title)
if r.ErrorReason != "" {
fmt.Fprintf(&b, "执行未成功:%s", r.ErrorReason)
} else {
b.WriteString("任务执行失败。")
}
}
if r.DocRecord != nil && r.DocRecord.DocID != "" {
fmt.Fprintf(&b, "\n\n📄 受控文档:`%s`", r.DocRecord.DocID)
}
return b.String()
}
// ArtifactMetadata returns chat metadata fields for a clickable document entry.
func (r *TaskRunResult) ArtifactMetadata() map[string]any {
if r == nil {
return nil
}
docID := ""
if r.DocRecord != nil {
docID = r.DocRecord.DocID
}
if docID == "" {
return nil
}
title := r.ArtifactTitle
if title == "" && r.TaskKey != "" {
title = squad.TaskArtifactTitle(r.TaskKey)
}
out := map[string]any{
"doc_id": docID,
"title": title,
"task_key": r.TaskKey,
}
if r.WorkDir != "" {
out["workspace_id"] = r.WorkDir
}
if r.DocRecord != nil && r.DocRecord.ContentSHA256 != "" {
out["sha256"] = r.DocRecord.ContentSHA256
}
if r.QualityReport != nil {
out["quality_passed"] = r.QualityReport.Passed
out["quality_score"] = r.QualityReport.Score
if len(r.QualityReport.Violations) > 0 {
out["quality_violations"] = r.QualityReport.Violations
}
}
if r.AwaitsAgentConfirmation() {
out["pending_agent_candidate_id"] = r.PendingAgentCandidateID
if r.PendingChangeReportID > 0 {
out["pending_change_report_id"] = r.PendingChangeReportID
}
if r.PendingRiskLevel != "" {
out["pending_risk_level"] = r.PendingRiskLevel
}
out["awaiting_author_confirmation"] = true
}
return out
}
+8
View File
@@ -90,7 +90,15 @@ type TaskRunResult struct {
AdvisorBrief *AdvisorBriefing `json:"advisor_brief,omitempty"`
DocRecord *docgov.DocumentRecord `json:"doc_record,omitempty"`
PendingProposal *docgov.ChangeProposal `json:"pending_proposal,omitempty"`
// RequiresAuthorApproval: human must decide. Disambiguate:
// - PendingProposal != nil → DocGov claimed-doc change proposal
// - PendingAgentCandidateID > 0 → high-risk revision candidate (ledger not committed)
RequiresAuthorApproval bool `json:"requires_author_approval,omitempty"`
// Pending agent revision confirmation (high/critical): fact source stays on ledger head.
PendingAgentCandidateID int64 `json:"pending_agent_candidate_id,omitempty"`
PendingChangeReportID int64 `json:"pending_change_report_id,omitempty"`
PendingRiskLevel string `json:"pending_risk_level,omitempty"`
PendingConfirmationNote string `json:"pending_confirmation_note,omitempty"`
}
// StageRunRequest 运行指定成熟度阶段请求
@@ -0,0 +1,161 @@
package docrevision_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
)
func TestPendingHighRisk_DoesNotAdvanceFactSource_UntilConfirm(t *testing.T) {
ctx := context.Background()
dataDir := t.TempDir()
workDir := filepath.Join(dataDir, "ws-pending")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
docID := "doc_fact_pending"
oldMD := "# base\n\n法规引用 GB9706\n"
newMD := "# base\n\n" // deletes 法规 → high risk
// Simulate squad already writing unconfirmed body to fact source.
if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte(newMD), 0o644); err != nil {
t.Fatal(err)
}
oldSHA := sha256.Sum256([]byte(docrevision.CanonicalizeMarkdown(oldMD)))
oldSHAStr := hex.EncodeToString(oldSHA[:])
newSHA := sha256.Sum256([]byte(docrevision.CanonicalizeMarkdown(newMD)))
newSHAStr := hex.EncodeToString(newSHA[:])
eng := docgov.NewGovernanceEngine(nil)
if _, err := eng.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "a", 2, "r", 3, "p"); err != nil {
t.Fatal(err)
}
_ = eng.MarkDocumentContentUpdated(ctx, docID, oldSHAStr, false)
rev := docrevision.NewService(nil)
rev.DataDir = dataDir
st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
mat, err := rev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
DocID: docID, RunID: "run-pending-fact", OutputMarkdown: newMD,
ProjectID: 1, WorkDir: workDir, ActorKind: docrevision.ActorAgentSquad,
AutoCommitUserID: 1,
})
if err != nil {
t.Fatalf("materialize: %v", err)
}
if mat.Candidate == nil || mat.Candidate.Status != docrevision.CandidateNeedsConfirmation {
t.Fatalf("want needs_confirmation, got %+v", mat.Candidate)
}
// Restore fact source from ledger head (bizdev contract).
if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil {
t.Fatal(err)
}
if err := eng.MarkDocumentContentUpdated(ctx, docID, st.HeadSHA256, false); err != nil {
t.Fatal(err)
}
gotMD, err := os.ReadFile(filepath.Join(workDir, "output.md"))
if err != nil {
t.Fatal(err)
}
if docrevision.CanonicalizeMarkdown(string(gotMD)) != docrevision.CanonicalizeMarkdown(oldMD) {
t.Fatalf("pending must restore output.md to ledger head; got %q want old", string(gotMD))
}
doc, err := eng.GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.ContentSHA256 != st.HeadSHA256 {
t.Fatalf("document_records hash must stay on head while pending: got %s want %s", doc.ContentSHA256, st.HeadSHA256)
}
if doc.ContentSHA256 == newSHAStr {
t.Fatal("document_records must not carry unconfirmed candidate hash")
}
// Confirm → project new head.
st2, err := rev.CommitAgentVersion(ctx, docID, mat.Candidate.ID, docrevision.CommitAgentDecision{
Decision: "commit", Confirmed: true, ActorUserID: 1, ExpectedInputSHA: st.HeadSHA256,
})
if err != nil {
t.Fatal(err)
}
if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil {
t.Fatal(err)
}
if err := eng.MarkDocumentContentUpdated(ctx, docID, st2.HeadSHA256, false); err != nil {
t.Fatal(err)
}
gotAfter, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if docrevision.CanonicalizeMarkdown(string(gotAfter)) != docrevision.CanonicalizeMarkdown(newMD) {
t.Fatalf("after confirm output.md must be candidate body, got %q", string(gotAfter))
}
doc2, _ := eng.GetDocument(ctx, docID)
if doc2.ContentSHA256 != st2.HeadSHA256 {
t.Fatalf("after confirm hash want %s got %s", st2.HeadSHA256, doc2.ContentSHA256)
}
}
func TestPendingHighRisk_RejectKeepsOldHead(t *testing.T) {
ctx := context.Background()
dataDir := t.TempDir()
workDir := filepath.Join(dataDir, "ws-reject")
_ = os.MkdirAll(workDir, 0o755)
docID := "doc_fact_reject"
oldMD := "# base\n\n法规引用 YY0505\n"
newMD := "# base\n\n"
_ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte(newMD), 0o644)
eng := docgov.NewGovernanceEngine(nil)
_, _ = eng.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "a", 2, "r", 3, "p")
rev := docrevision.NewService(nil)
rev.DataDir = dataDir
st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{}, 1, workDir)
if err != nil {
t.Fatal(err)
}
mat, err := rev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
DocID: docID, RunID: "run-rej", OutputMarkdown: newMD, WorkDir: workDir,
ActorKind: docrevision.ActorAgentSquad, AutoCommitUserID: 1,
})
if err != nil {
t.Fatal(err)
}
if mat.Candidate.Status != docrevision.CandidateNeedsConfirmation {
t.Fatalf("want needs_confirmation got %s", mat.Candidate.Status)
}
_ = rev.ProjectOutputMarkdown(docID, dataDir)
_ = eng.MarkDocumentContentUpdated(ctx, docID, st.HeadSHA256, false)
st2, err := rev.CommitAgentVersion(ctx, docID, mat.Candidate.ID, docrevision.CommitAgentDecision{
Decision: "reject", Confirmed: true, ActorUserID: 1,
})
if err != nil {
t.Fatal(err)
}
if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil {
t.Fatal(err)
}
_ = eng.MarkDocumentContentUpdated(ctx, docID, st2.HeadSHA256, false)
got, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if docrevision.CanonicalizeMarkdown(string(got)) != docrevision.CanonicalizeMarkdown(oldMD) {
t.Fatalf("reject must keep old head on disk, got %q", string(got))
}
if st2.CurrentVersionNo != 1 {
t.Fatalf("reject must not advance version, got V%d", st2.CurrentVersionNo)
}
if st2.PendingCandidateID != nil {
t.Fatalf("pending must clear after reject")
}
}
+31
View File
@@ -388,6 +388,16 @@ func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMateriali
return nil, err
}
if strings.TrimSpace(req.WorkDir) != "" {
if err := s.SetDocWorkDir(ctx, req.DocID, req.WorkDir); err != nil {
return nil, err
}
st, err = s.GetRevisionState(ctx, req.DocID)
if err != nil {
return nil, err
}
}
cand, rep, err := s.CreateAgentCandidate(ctx, AgentCandidateRequest{
DocID: req.DocID, RunID: req.RunID, ProposalID: req.ProposalID,
InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: req.OutputMarkdown, ReportJSON: req.ReportJSON,
@@ -844,6 +854,27 @@ func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerI
return s.mustStateLocked(docID), nil
}
// SetDocWorkDir updates the in-memory ledger root workDir used by ProjectOutputMarkdown.
func (s *Service) SetDocWorkDir(ctx context.Context, docID, workDir string) error {
if err := requireDocID(docID); err != nil {
return err
}
if strings.TrimSpace(workDir) == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.hydrateDocLocked(ctx, docID); err != nil {
return err
}
root := s.roots[docID]
if root == nil {
return ErrNotFound
}
root.WorkDir = workDir
return nil
}
// ProjectOutputMarkdown writes DB head to workDir/output.md (compatibility projection).
func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error {
s.mu.Lock()
@@ -331,6 +331,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
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,
@@ -341,6 +342,32 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
writeDocRevisionError(w, err)
return
}
// Always project ledger head to fact source after decision:
// commit → new head; reject → keep/restore previous head (never leave squad draft on disk).
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "候选决策已写入账本,但工作区 output.md 投影失败;请重试决策或人工修复。",
"state": st,
})
return
}
if s.svc != nil && s.svc.DocGov() != nil && st != nil {
if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, st.HeadSHA256, false); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "候选决策已写入账本,但 document_records 哈希投影失败;请重试。",
"state": st,
})
return
}
if body.Decision == "commit" && cand != nil && strings.TrimSpace(cand.ProposalID) != "" {
_ = s.svc.DocGov().MarkProposalApplied(r.Context(), cand.ProposalID)
}
}
writeJSONResponse(w, http.StatusOK, st)
}