package docflow import ( "context" "fmt" "strings" "time" "chat-agent-system/internal/app/uow" "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/events" ) type Action string const ( ActionEdit Action = "document.edit" ActionApprove Action = "document.approve" ActionReject Action = "document.reject" ActionCommit Action = "document.agent_commit" ) type Principal struct { UserID int64 Name string Role vocabulary.UserRole } type HumanEditCommand struct { DocID string Request docrevision.HumanRevisionRequest } type AgentCommitCommand struct { DocID string CandidateID int64 Decision docrevision.CommitAgentDecision } type DecisionCommand struct { DocID string Comment string RejectReason string RollbackTo vocabulary.DocumentState } type Commands struct { UoW uow.UnitOfWork Gov *docgov.GovernanceEngine Rev *docrevision.Service Auth Authorizer Events EventSink Environment string } type EventSink interface { Append(ctx context.Context, env events.Envelope, consumers []string) error } type Authorizer interface { Authorize(ctx context.Context, p Principal, rec *docgov.DocumentRecord, action Action) error } type dutyAuthorizer struct{} func DutyAuthorizer() Authorizer { return dutyAuthorizer{} } func (dutyAuthorizer) Authorize(_ context.Context, p Principal, rec *docgov.DocumentRecord, action Action) error { if rec == nil { return uow.ErrPermissionDenied } switch action { case ActionEdit, ActionCommit: if rec.AuthorID != 0 && p.UserID != 0 && rec.AuthorID != p.UserID { return uow.ErrPermissionDenied } case ActionApprove, ActionReject: if rec.ApproverID != 0 && p.UserID != 0 && rec.ApproverID != p.UserID { return uow.ErrPermissionDenied } default: return uow.ErrPermissionDenied } return nil } func (c *Commands) run(ctx context.Context, docID string, p Principal, action Action, fn func(context.Context, uow.TxScope) error) error { if c == nil { return fmt.Errorf("docflow: commands required") } auth := c.authorizer() if c.UoW == nil { if err := c.authorizeUnlocked(ctx, docID, p, action); err != nil { return err } return fn(ctx, nil) } return c.UoW.Within(ctx, func(scope uow.TxScope) error { ctx = uow.WithScope(ctx, scope) if docID != "" && c.Gov != nil && c.Gov.UsingPG() { rec, err := (docgov.Repository{}).GetForUpdate(ctx, scope, docID) if err != nil { if action == ActionApprove || action == ActionReject { return err } } else if err := auth.Authorize(ctx, p, rec, action); err != nil { return err } } return fn(ctx, scope) }) } func (c *Commands) authorizer() Authorizer { if c != nil && c.Auth != nil { return c.Auth } return DutyAuthorizer() } func (c *Commands) authorizeUnlocked(ctx context.Context, docID string, p Principal, action Action) error { if docID == "" || c.Gov == nil { return nil } rec, err := c.Gov.GetDocument(ctx, docID) if err != nil || rec == nil { if action == ActionApprove || action == ActionReject { if err != nil { return err } return uow.ErrPermissionDenied } return nil } return c.authorizer().Authorize(ctx, p, rec, action) } func (c *Commands) HumanEdit(ctx context.Context, p Principal, cmd HumanEditCommand) (*docrevision.HumanRevisionResult, error) { var out *docrevision.HumanRevisionResult err := c.run(ctx, cmd.DocID, p, ActionEdit, func(ctx context.Context, _ uow.TxScope) error { res, err := c.Rev.CommitHumanRevision(ctx, cmd.Request) out = res return err }) return out, err } func (c *Commands) AgentCommit(ctx context.Context, p Principal, cmd AgentCommitCommand) (*docrevision.RevisionState, error) { var out *docrevision.RevisionState err := c.run(ctx, cmd.DocID, p, ActionCommit, func(ctx context.Context, _ uow.TxScope) error { st, err := c.Rev.CommitAgentVersion(ctx, cmd.DocID, cmd.CandidateID, cmd.Decision) out = st if err != nil || out == nil { return err } rec, err := c.document(ctx, cmd.DocID) if err != nil { return err } body := []byte(fmt.Sprintf(`{"doc_id":%q,"version_no":%d}`, cmd.DocID, out.CurrentVersionNo)) if err := c.emit(ctx, rec, events.EventVersionCommitted, fmt.Sprintf("document:%s:v%d", cmd.DocID, out.CurrentVersionNo), fmt.Sprintf("commit:%s:%d", cmd.DocID, out.CurrentVersionNo), "document", rec.DocID, int64(out.CurrentVersionNo), p, body); err != nil { return err } return c.emitDownstream(ctx, rec, p, out, cmd.CandidateID) }) return out, err } func (c *Commands) Approve(ctx context.Context, p Principal, cmd DecisionCommand) (*docgov.DocumentRecord, error) { var out *docgov.DocumentRecord err := c.run(ctx, cmd.DocID, p, ActionApprove, func(ctx context.Context, _ uow.TxScope) error { doc, err := c.Gov.ApproverApprove(ctx, cmd.DocID, p.UserID, p.Role, p.Name, cmd.Comment) out = doc if err != nil || out == nil { return err } body := []byte(fmt.Sprintf(`{"doc_id":%q,"project_id":%d,"task_key":%q}`, out.DocID, out.ProjectID, out.TaskKey)) return c.emit(ctx, out, events.EventApproved, fmt.Sprintf("document:%s:approved:%d", out.DocID, out.ID), fmt.Sprintf("approved:%s:%d", out.DocID, out.ID), "document_approval", out.DocID, int64(out.ID), p, body) }) if err == nil && out != nil && c.UoW != nil && c.Gov != nil { c.Gov.RunApprovedHooksAfterCommit(ctx, out) } return out, err } func (c *Commands) MaterializeAgent(ctx context.Context, p Principal, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) { var out *docrevision.AgentMaterializeResult err := c.run(ctx, req.DocID, p, ActionCommit, func(ctx context.Context, _ uow.TxScope) error { res, err := c.Rev.MaterializeAgentOutput(ctx, req) out = res if err != nil || res == nil || res.Candidate == nil { return err } rec, recErr := c.document(ctx, req.DocID) if recErr != nil { return recErr } if res.Candidate.Status == docrevision.CandidateNeedsConfirmation { risk := "" if res.Report != nil { risk = res.Report.RiskLevel } body := []byte(fmt.Sprintf(`{"doc_id":%q,"candidate_id":%d,"risk":%q}`, req.DocID, res.Candidate.ID, risk)) return c.emit(ctx, rec, events.EventCandidateReady, fmt.Sprintf("document:%s:candidate:%d", req.DocID, res.Candidate.ID), fmt.Sprintf("candidate:%s:%d", req.DocID, res.Candidate.ID), "document_candidate", rec.DocID, int64(res.Candidate.ID), p, body) } if res.Candidate.Status == docrevision.CandidateCommitted && res.State != nil { body := []byte(fmt.Sprintf(`{"doc_id":%q,"version_no":%d}`, req.DocID, res.State.CurrentVersionNo)) if err := c.emit(ctx, rec, events.EventVersionCommitted, fmt.Sprintf("document:%s:v%d", req.DocID, res.State.CurrentVersionNo), fmt.Sprintf("commit:%s:%d", req.DocID, res.State.CurrentVersionNo), "document", rec.DocID, int64(res.State.CurrentVersionNo), p, body); err != nil { return err } return c.emitDownstream(ctx, rec, p, res.State, res.Candidate.ID) } return nil }) return out, err } // MaterializeAgentOutput satisfies bizdev.AgentOutputMaterializer so the squad // path uses the unique Commands instance instead of calling docrevision directly. func (c *Commands) MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) { return c.MaterializeAgent(ctx, Principal{UserID: req.AutoCommitUserID}, req) } func (c *Commands) Reject(ctx context.Context, p Principal, cmd DecisionCommand) (*docgov.DocumentRecord, error) { var out *docgov.DocumentRecord err := c.run(ctx, cmd.DocID, p, ActionReject, func(ctx context.Context, _ uow.TxScope) error { doc, err := c.Gov.ApproverReject(ctx, cmd.DocID, p.UserID, p.Role, p.Name, cmd.RejectReason, cmd.RollbackTo) out = doc return err }) return out, err } func (c *Commands) document(ctx context.Context, docID string) (*docgov.DocumentRecord, error) { if c == nil || c.Gov == nil { return nil, events.ErrInvalidScope } rec, err := c.Gov.GetDocument(ctx, docID) if err != nil { return nil, err } if rec == nil || rec.ProjectID <= 0 { return nil, events.ErrInvalidScope } return rec, nil } func (c *Commands) emit(ctx context.Context, rec *docgov.DocumentRecord, eventName, eventID, idempotency, aggregateType, aggregateID string, version int64, p Principal, payload []byte) error { if c == nil || c.Events == nil { return nil } if rec == nil || rec.ProjectID <= 0 || strings.TrimSpace(c.Environment) == "" { return events.ErrInvalidScope } if aggregateType == "" { aggregateType = "document" } if aggregateID == "" { aggregateID = rec.DocID } env := events.Envelope{ EventID: eventID, EventName: eventName, SchemaVersion: events.EnvelopeSchema, Environment: c.Environment, ProjectID: rec.ProjectID, AggregateType: aggregateType, AggregateID: aggregateID, AggregateVersion: version, ActorRef: fmt.Sprintf("user:%d", p.UserID), IdempotencyKey: idempotency, PayloadDigest: events.Digest(payload), Producer: "docflow", OccurredAt: time.Now().UTC(), Payload: payload, } return c.Events.Append(ctx, env, events.LiveConsumers(eventName)) } func (c *Commands) emitDownstream(ctx context.Context, rec *docgov.DocumentRecord, p Principal, st *docrevision.RevisionState, candidateID int64) error { if c == nil || c.Rev == nil || st == nil || rec == nil || candidateID == 0 { return nil } cand, err := c.Rev.GetCandidate(ctx, rec.DocID, candidateID) if err != nil || cand == nil || cand.ChangeReportID == nil { return nil } rep, err := c.Rev.GetChangeReport(ctx, rec.DocID, *cand.ChangeReportID) if err != nil || rep == nil { return nil } for _, imp := range rep.DocumentImpacts { body := []byte(fmt.Sprintf(`{"doc_id":%q,"impacted_doc_id":%q,"version_no":%d}`, rec.DocID, imp.DocID, st.CurrentVersionNo)) if err := c.emit(ctx, rec, events.EventDownstreamImpact, fmt.Sprintf("document:%s:impact:%s:v%d", rec.DocID, imp.DocID, st.CurrentVersionNo), fmt.Sprintf("impact:%s:%s:%d", rec.DocID, imp.DocID, st.CurrentVersionNo), "document_impact", rec.DocID+":"+imp.DocID, int64(st.CurrentVersionNo), p, body); err != nil { return err } } if rep.AnalysisStatus == docrevision.AnalysisUnassessed || rep.AnalysisStatus == docrevision.AnalysisFallback { body := []byte(fmt.Sprintf(`{"doc_id":%q,"report_id":%d}`, rec.DocID, rep.ID)) return c.emit(ctx, rec, events.EventChangeReportDegraded, fmt.Sprintf("document:%s:report:%d", rec.DocID, rep.ID), fmt.Sprintf("degraded:%s:%d", rec.DocID, rep.ID), "document_report", rec.DocID, int64(rep.ID), p, body) } return nil }