package domain import ( "fmt" "strings" "time" ) // EventTaskSubmitted records that a reviewed change reached the human. It is // deliberately not a completion: submission means the work is in the human's // hands, and completion means the change shipped. const EventTaskSubmitted = "TaskSubmitted" // EventTaskChangesRequested records that the human sent a submitted change // back. The submission it names is not removed: sha A was reviewed, submitted, // and rejected, and that history is what explains sha B. const EventTaskChangesRequested = "TaskChangesRequested" // CompletionReceipt is the evidence that a submission shipped. Merge strategy // varies, so a squash or merge commit means MergeSHA rarely equals // SubmittedSHA. What establishes completion is that the bound pull request // merged while carrying the submitted commit, not sha equality. type CompletionReceipt struct { SubmissionRef string `json:"submission_ref"` PR ExternalRef `json:"pr"` SubmittedSHA string `json:"submitted_sha"` MergeSHA string `json:"merge_sha,omitempty"` MergedAt time.Time `json:"merged_at"` } // GateResult is one quality-gate run, bound to the commit it ran against. A // gate result with no commit is a claim, not evidence. type GateResult struct { Command string `json:"command"` ExitCode int `json:"exit_code"` SHA string `json:"sha"` Output string `json:"output,omitempty"` } func (g GateResult) Passed() bool { return g.ExitCode == 0 && len(g.SHA) == 40 } // ExternalRef identifies a pull request in the forge that holds it. type ExternalRef struct { Provider string `json:"provider"` ID string `json:"id"` URL string `json:"url,omitempty"` } // SubmissionRef is the durable record of what was submitted. Every field binds // the submission to one commit, so a later change cannot inherit it. type SubmissionRef struct { ResultSHA string `json:"result_sha"` RemoteRef string `json:"remote_ref"` PR ExternalRef `json:"pr"` GateRef string `json:"gate_ref,omitempty"` ReviewRef string `json:"review_ref,omitempty"` PacketRef string `json:"packet_ref,omitempty"` } // SubmissionCheck is why a task may or may not be submitted. Reasons are // listed rather than summarised: "not eligible" alone sends an operator // reading code. type SubmissionCheck struct { Eligible bool `json:"eligible"` Reasons []string `json:"reasons,omitempty"` } // CheckSubmission is the whole eligibility rule, as one pure function of the // task, the current commit, and the gate run. // // The invariant that matters most: gate sha, review sha, and head sha must be // the same commit. Anything changing after review makes submission ineligible // immediately, with no state to clear and no flag to go stale. func CheckSubmission(task Task, headSHA string, gate GateResult) SubmissionCheck { var reasons []string add := func(format string, args ...any) { reasons = append(reasons, fmt.Sprintf(format, args...)) } phase := task.WorkPhase if phase == "" { phase = WorkPhaseFrame } if phase != WorkPhaseReview { add("work phase is %s, not review", phase) } if task.State == StateBlocked || task.State == StateNeedsAttention { add("task is %s (%s)", task.State, task.BlockReason) } if task.State == StateCompleted || task.State == StateFailed { add("task is already %s", task.State) } if task.DecisionRequest != nil { add("a human decision is still outstanding") } if len(headSHA) != 40 { add("head commit is not anchored") } if !gate.Passed() { add("quality gate %q exited %d", gate.Command, gate.ExitCode) } else if gate.SHA != headSHA { add("quality gate ran against %s, not the current head", short(gate.SHA)) } switch { case task.Review == nil: add("no independent review has been recorded") case task.Review.ResultSHA != headSHA: add("the review is for %s, not the current head", short(task.Review.ResultSHA)) case task.Review.Blocking > 0: add("%d unresolved blocker or important review findings", task.Review.Blocking) } return SubmissionCheck{Eligible: len(reasons) == 0, Reasons: reasons} } // RequirePhaseArtifacts reports the project-policy half of eligibility: a // project whose path includes research or plan must have sealed them. func (t Task) RequirePhaseArtifacts(path []WorkPhase) []string { var missing []string for _, phase := range path { switch phase { case WorkPhaseResearch: if t.ResearchRef == "" { missing = append(missing, "the project's path includes research but none was sealed") } case WorkPhasePlan: if t.PlanRef == "" { missing = append(missing, "the project's path includes plan but none was sealed") } } } return missing } // Submitted reports whether this task already has a submission for exactly // this commit, which is what makes a repeated submission idempotent. func (t Task) Submitted(headSHA string) bool { return t.Submission != nil && t.Submission.ResultSHA == headSHA } func ValidateTaskSubmitted(p map[string]any) error { if v, ok := p["result_sha"].(string); !ok || len(v) != 40 { return fmt.Errorf("%w: result_sha invalid", ErrInvalid) } if v, ok := p["remote_ref"].(string); !ok || strings.TrimSpace(v) == "" { return fmt.Errorf("%w: remote_ref required", ErrInvalid) } pr, ok := p["pr"].(map[string]any) if !ok { return fmt.Errorf("%w: pr required", ErrInvalid) } for _, k := range []string{"provider", "id"} { if v, ok := pr[k].(string); !ok || strings.TrimSpace(v) == "" { return fmt.Errorf("%w: pr.%s required", ErrInvalid, k) } } for _, k := range []string{"gate_ref", "review_ref", "packet_ref"} { if v, ok := p[k]; ok { if s, _ := v.(string); s != "" { if err := requiredHash(map[string]any{k: s}, k); err != nil { return err } } } } return nil } func short(sha string) string { if len(sha) > 12 { return sha[:12] } if sha == "" { return "an unknown commit" } return sha }