fix(docgov): assert edit duty before lease; project via SaveDocumentContent

Unauthorized actors no longer grab leases; controlled PATCH projects through
DocGov so author_last_edited and audit survive, with idempotent retry on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 09:32:08 +00:00
co-authored by Cursor
parent 645c4b67b0
commit 7b48ca2ecc
4 changed files with 122 additions and 28 deletions
+17 -13
View File
@@ -287,28 +287,32 @@ func (e *GovernanceEngine) MarkProposalApplied(ctx context.Context, proposalID s
return nil
}
// AssertHumanCanEdit acquires/renews the human lease and checks author duty + drafting lifecycle
// without writing document body. Used by revision PATCH before ledger mutation.
// AssertHumanCanEdit checks author duty + drafting lifecycle BEFORE acquiring/renewing
// the human lease, so unauthorized actors never become lease holders.
func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error {
e.mu.Lock()
doc, err := e.getDocLocked(ctx, docID)
if err != nil {
e.mu.Unlock()
return err
}
if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil {
e.mu.Unlock()
return err
}
e.mu.Unlock()
holderID := fmt.Sprintf("%d", actorID)
if actorID <= 0 {
holderID = "human_anon"
}
if _, err := e.AcquireLease(ctx, LeaseAcquireRequest{
_, err = e.AcquireLease(ctx, LeaseAcquireRequest{
DocID: docID,
HolderType: LeaseHolderHuman,
HolderID: holderID,
TTL: DefaultHumanLeaseTTL,
}); err != nil {
return err
}
e.mu.Lock()
defer e.mu.Unlock()
doc, err := e.getDocLocked(ctx, docID)
if err != nil {
return err
}
return e.sm.CanEditDocumentContent(doc, actorID, actorRole)
})
return err
}
// SaveDocumentContent 人工正文保存:校验可编辑 + 获取/续 human 租约 + 乐观哈希 + 落盘
+34
View File
@@ -85,3 +85,37 @@ func TestSameHolderRenewLease(t *testing.T) {
t.Fatalf("expected renewed TTL, expires=%v", doc.LeaseExpiresAt)
}
}
func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) {
engine := docgov.NewGovernanceEngine(nil)
ctx := context.Background()
docID := "doc_assert_order_001"
_, err := engine.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "",
1, "作者", 2, "审核", 3, "批准")
if err != nil {
t.Fatal(err)
}
// Non-author must fail without becoming lease holder.
if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor); err == nil {
t.Fatal("non-author AssertHumanCanEdit must fail")
}
doc, err := engine.GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "99" {
t.Fatal("unauthorized actor must not hold human lease")
}
if err := engine.AssertHumanCanEdit(ctx, docID, 1, docgov.RoleAuthor); err != nil {
t.Fatalf("author AssertHumanCanEdit: %v", err)
}
doc, err = engine.GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if !doc.HasActiveLease(time.Now()) || doc.LeaseHolderType != docgov.LeaseHolderHuman || doc.LeaseHolderID != "1" {
t.Fatalf("author should hold human lease after assert, got type=%s id=%s", doc.LeaseHolderType, doc.LeaseHolderID)
}
}
+20 -14
View File
@@ -238,9 +238,26 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
return
}
// 3) Project fact-source AFTER ledger commit. Projection failure returns error (no fake 200).
// Ledger remains authoritative; client should retry projection via re-save or admin repair.
if kind == "controlled" || workDir != "" {
// 3) Project fact-source AFTER ledger commit. Controlled docs use SaveDocumentContent so
// author_last_edited / audit trail stay consistent; failure → DOCUMENT_PROJECTION_FAILED
// (ledger remains authoritative; same Idempotency-Key retry re-projects).
if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil {
if _, err := s.svc.DocGov().SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
DocID: docID,
ActorID: user.ID,
ActorRole: docgov.RoleAuthor,
ActorName: user.Name,
MarkdownBody: body.Markdown,
}); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但 DocGov 正文/审计投影失败;请用同一 Idempotency-Key 重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
} else if workDir != "" {
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
@@ -250,17 +267,6 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
})
return
}
if s.svc != nil && s.svc.DocGov() != nil {
if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false); err != nil {
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但 document_records 哈希投影失败;请重试保存。",
"state": res.State,
})
return
}
}
}
if kind == "freeform" && s.svc != nil && s.svc.FreeformDoc() != nil {
md := body.Markdown
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strconv"
"testing"
"time"
"chat-agent-system/internal/docrevision"
)
@@ -19,7 +20,7 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(
ctx := context.Background()
dataRoot := srv.dataRoot()
// WorkDir whose parent is a regular file → MkdirAll fails after ledger commit.
// WorkDir whose parent is a regular file → SaveDocumentContent MkdirAll fails after ledger commit.
blocker := filepath.Join(dataRoot, "proj-block")
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
@@ -100,4 +101,53 @@ func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
if after.HeadRevisionNo != before {
t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo)
}
doc, err := srv.svc.DocGov().GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "1" {
t.Fatal("failed AssertHumanCanEdit must not leave non-author admin holding the lease")
}
}
func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
workDir := filepath.Join(srv.dataRoot(), "ws-audit")
_ = os.MkdirAll(workDir, 0o755)
docID := "doc_proj1_patch_audit"
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
token := loginTestAdmin(t, srv)
md := "# base\n\nbody\n\nhuman edit\n"
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
bytes.NewReader([]byte(`{"markdown":`+strconv.Quote(md)+`}`)))
withBearerAuth(req, token)
req.Header.Set("Idempotency-Key", "patch-audit-k1")
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200 got %d %s", w.Code, w.Body.String())
}
doc, err := srv.svc.DocGov().GetDocument(ctx, docID)
if err != nil {
t.Fatal(err)
}
if doc.AuthorLastEditedAt == nil {
t.Fatal("SaveDocumentContent projection must set author_last_edited_at")
}
if !doc.AuthorClaimed {
t.Fatal("author_claimed should be true after human save projection")
}
if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil {
t.Fatalf("output.md should exist after SaveDocumentContent: %v", err)
}
}