package docgov import ( "context" "encoding/json" "fmt" "github.com/jackc/pgx/v5" "chat-agent-system/internal/app/uow" ) func (e *GovernanceEngine) freezeReviewSnapshotLocked(ctx context.Context, doc *DocumentRecord) error { if doc == nil { return ErrDocumentNotFound } if e.pool != nil { var head *int64 if err := e.pool.QueryRow(ctx, `SELECT head_snapshot_id FROM document_records WHERE doc_id=$1`, doc.DocID).Scan(&head); err == nil && head != nil && *head > 0 { doc.ReviewSnapshotID = *head } if doc.ReviewSnapshotID <= 0 { return nil } _, err := e.pool.Exec(ctx, `UPDATE document_records SET review_snapshot_id=$1 WHERE doc_id=$2`, doc.ReviewSnapshotID, doc.DocID) return err } if doc.ReviewSnapshotID <= 0 { doc.ReviewSnapshotID = 1 } return nil } // LedgerMarkdown returns immutable snapshot markdown. It never reads workspace files. func (e *GovernanceEngine) LedgerMarkdown(ctx context.Context, snapshotID int64) (string, error) { if e.pool == nil || snapshotID <= 0 { return "", fmt.Errorf("docgov: ledger snapshot unavailable") } var md string err := e.pool.QueryRow(ctx, ` SELECT b.content_markdown FROM document_snapshots s JOIN document_content_blobs b ON b.content_sha256 = s.content_sha256 WHERE s.id = $1 `, snapshotID).Scan(&md) return md, err } func (e *GovernanceEngine) persistApproveAndFAV(ctx context.Context, doc *DocumentRecord, audit *AuditLog) error { if e.pool == nil { return e.syncDocAndAuditToDB(ctx, doc, audit) } // Join the ambient unit of work when present. Opening a second // connection here deadlocks against the caller's FOR UPDATE row lock on // document_records (docflow Commands.run) — the UPDATE below blocks until // statement timeout (SQLSTATE 57014). When a scope exists the uow owns // the transaction boundary and commit; FAV rows join the same tx. if scope, ok := uow.ScopeFrom(ctx); ok { tx, err := uow.AsPG(scope) if err != nil { return err } if err := syncDocAndAuditTX(ctx, tx, doc, audit); err != nil { return err } if e.OnFinalArtifactTX != nil { md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID) if err := e.OnFinalArtifactTX(ctx, tx, doc, []byte(md), doc.ReviewSnapshotID); err != nil { return err } } return nil } if e.OnFinalArtifactTX == nil { return e.syncDocAndAuditToDB(ctx, doc, audit) } tx, err := e.pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if err := syncDocAndAuditTX(ctx, tx, doc, audit); err != nil { return err } md, _ := e.LedgerMarkdown(ctx, doc.ReviewSnapshotID) if err := e.OnFinalArtifactTX(ctx, tx, doc, []byte(md), doc.ReviewSnapshotID); err != nil { return err } return tx.Commit(ctx) } func syncDocAndAuditTX(ctx context.Context, tx pgx.Tx, doc *DocumentRecord, audit *AuditLog) error { _, err := tx.Exec(ctx, ` 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, approved_at = $7, updated_at = $8, review_snapshot_id = NULLIF($9, 0) WHERE doc_id = $10 `, string(doc.CurrentState), doc.IsLocked, doc.LastRejectedReason, doc.LastRejectedByRole, doc.LastRejectedByName, doc.LastRejectedAt, doc.ApprovedAt, doc.UpdatedAt, doc.ReviewSnapshotID, doc.DocID) if err != nil { return err } if audit == nil { return nil } detJSON, _ := json.Marshal(audit.Details) _, err = tx.Exec(ctx, ` 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) return err }