v3 workflow: intent, phases, review, submission, enforcement, burn-in

The v3 stack, previously an uncommitted working tree, plus this session's two
units and the burn-in instrument. This commit is the burn-in build identity:
coordinator and worker must both report this revision before a task is created.

Workflow (earlier sessions, uncommitted until now): human decision events and
reduction, source cursors and reconcile-before-launch, turn-boundary
reconciliation, internal/agentctx as the single renderer, ace-fca phases with
sealed artifacts, the trajectory gate, bounded grilling, independent review,
task pr enforcement, and human review reflection.

Capability restrictions at the agent boundary: an authz.Agent surface at
GatedWrite may ask and may not act. It also fixes two bugs the unit exposed --
gated surfaces could not reach the two endpoints written for them, and
RequestHumanDecision would block an unowned task while rejecting a question
from the session that did own it.

Turn-boundary reconcile-failure escalation: a streak of consecutive failures
asks the session to hand off, fenced on the lease epoch, with reconcile_failure
as a real handoff reason. The worker was dropping the coordinator's verdict on
the floor; it now acts on it.

Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to
<worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md
is the runbook. deploy/build.sh stamps both binaries from one commit.

go build, go vet and go test ./... pass, 20 packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 18:31:20 +04:00
parent 97a9c65302
commit 7f12c7fc37
78 changed files with 16417 additions and 352 deletions
+231
View File
@@ -0,0 +1,231 @@
package domain
import (
"encoding/json"
"fmt"
"sort"
"time"
)
// Event types carrying human authority. A decision is a durable fact about
// what the operator has decided, never a lifecycle transition: neither type
// moves task state, and neither is readable from handoff prose.
const (
EventHumanDecisionRecorded = "HumanDecisionRecorded"
EventHumanDecisionSuperseded = "HumanDecisionSuperseded"
)
type HumanDecisionKind string
const (
HumanDecisionAnswer HumanDecisionKind = "answer"
HumanDecisionChoice HumanDecisionKind = "decision"
HumanDecisionCorrection HumanDecisionKind = "correction"
HumanDecisionConstraint HumanDecisionKind = "constraint"
)
func (k HumanDecisionKind) Valid() bool {
switch k {
case HumanDecisionAnswer, HumanDecisionChoice, HumanDecisionCorrection, HumanDecisionConstraint:
return true
}
return false
}
// HumanDecisionSource records where the decision was observed. Provenance is
// mandatory so a decision can always be traced back to a human utterance.
type HumanDecisionSource struct {
Provider string `json:"provider"`
ExternalID string `json:"external_id,omitempty"`
}
type HumanDecision struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Kind HumanDecisionKind `json:"kind"`
// Subject names the area under decision. It deliberately does not imply
// replacement: two decisions may share a subject and both stay effective.
// Retiring a decision requires naming it in Supersedes, or a standalone
// HumanDecisionSuperseded.
Subject string `json:"subject"`
Value string `json:"value"`
Supersedes []string `json:"supersedes,omitempty"`
Source HumanDecisionSource `json:"source"`
At time.Time `json:"at"`
}
// EffectiveIntent is the reduced authority for one task: the original
// contract, unmodified, plus the human decisions that are still standing.
// Rendering the two into a prompt is BuildContext's job, not the reducer's.
type EffectiveIntent struct {
Task Task `json:"task"`
Decisions []HumanDecision `json:"decisions"`
}
// Decision returns the standing decision with the given ID.
func (i EffectiveIntent) Decision(id string) (HumanDecision, bool) {
for _, d := range i.Decisions {
if d.ID == id {
return d, true
}
}
return HumanDecision{}, false
}
type humanDecisionPayload struct {
DecisionID string `json:"decision_id"`
Kind HumanDecisionKind `json:"kind"`
Subject string `json:"subject"`
Value string `json:"value"`
Supersedes []string `json:"supersedes"`
Source HumanDecisionSource `json:"source"`
}
// equal reports whether two records describe the same decision. At is part of
// the comparison because it participates in the canonical output order.
func (d HumanDecision) equal(o HumanDecision) bool {
if d.ID != o.ID || d.TaskID != o.TaskID || d.Kind != o.Kind || d.Subject != o.Subject ||
d.Value != o.Value || d.Source != o.Source || !d.At.Equal(o.At) || len(d.Supersedes) != len(o.Supersedes) {
return false
}
for i := range d.Supersedes {
if d.Supersedes[i] != o.Supersedes[i] {
return false
}
}
return true
}
// ReduceIntent folds a task's decision events into the standing set.
//
// The result depends only on the set of events, not on their order in the
// log: supersession is explicit, so a late-appended older decision can never
// silently override a newer correction. Events for other tasks are ignored,
// which is also what makes a cross-task supersedes reference read as an
// unknown target and be rejected.
//
// Errors are returned rather than skipped. A log that cannot be reduced is a
// log whose authority is ambiguous, and guessing is how a stale instruction
// reaches an agent.
func ReduceIntent(task Task, events []Event) (EffectiveIntent, error) {
byID := map[string]HumanDecision{}
var ids []string
adjacency := map[string][]string{}
superseded := map[string]bool{}
var targets []string
for _, e := range events {
if e.TaskID != task.ID {
continue
}
switch e.Type {
case EventHumanDecisionRecorded:
var p humanDecisionPayload
if err := json.Unmarshal(e.Payload, &p); err != nil {
return EffectiveIntent{}, fmt.Errorf("%w: decision payload in event %s: %v", ErrInvalid, e.ID, err)
}
if p.DecisionID == "" {
return EffectiveIntent{}, fmt.Errorf("%w: decision_id required in event %s", ErrInvalid, e.ID)
}
if !p.Kind.Valid() {
return EffectiveIntent{}, fmt.Errorf("%w: decision %s has kind %q", ErrInvalid, p.DecisionID, p.Kind)
}
d := HumanDecision{
ID: p.DecisionID,
TaskID: e.TaskID,
Kind: p.Kind,
Subject: p.Subject,
Value: p.Value,
Supersedes: p.Supersedes,
Source: p.Source,
At: e.At,
}
// Replaying the same decision is a no-op. Reusing one ID for two
// different decisions is not: first-wins would make the result
// depend on encounter order, which is the property this reducer
// exists to guarantee. Reject it instead.
if prior, seen := byID[p.DecisionID]; seen {
if prior.equal(d) {
continue
}
return EffectiveIntent{}, fmt.Errorf("%w: decision %q recorded twice with different content (event %s)", ErrInvalid, p.DecisionID, e.ID)
}
byID[p.DecisionID] = d
ids = append(ids, p.DecisionID)
adjacency[p.DecisionID] = append(adjacency[p.DecisionID], p.Supersedes...)
targets = append(targets, p.Supersedes...)
case EventHumanDecisionSuperseded:
var p struct {
DecisionID string `json:"decision_id"`
}
if err := json.Unmarshal(e.Payload, &p); err != nil {
return EffectiveIntent{}, fmt.Errorf("%w: supersede payload in event %s: %v", ErrInvalid, e.ID, err)
}
if p.DecisionID == "" {
return EffectiveIntent{}, fmt.Errorf("%w: decision_id required in event %s", ErrInvalid, e.ID)
}
targets = append(targets, p.DecisionID)
}
}
for _, target := range targets {
if _, ok := byID[target]; !ok {
return EffectiveIntent{}, fmt.Errorf("%w: supersedes references unknown decision %q for task %s", ErrInvalid, target, task.ID)
}
superseded[target] = true
}
if cycle := findCycle(ids, adjacency); cycle != "" {
return EffectiveIntent{}, fmt.Errorf("%w: supersession cycle through decision %q", ErrInvalid, cycle)
}
out := EffectiveIntent{Task: task}
for _, id := range ids {
if !superseded[id] {
out.Decisions = append(out.Decisions, byID[id])
}
}
// Canonical order, so two logs holding the same events render the same
// context regardless of append order.
sort.Slice(out.Decisions, func(a, b int) bool {
x, y := out.Decisions[a], out.Decisions[b]
if !x.At.Equal(y.At) {
return x.At.Before(y.At)
}
return x.ID < y.ID
})
return out, nil
}
// findCycle returns a decision ID on a supersession cycle, or "" if the graph
// is acyclic. A cycle would otherwise mark every decision on it superseded
// and drop the whole chain from the effective set without a trace.
func findCycle(ids []string, adjacency map[string][]string) string {
const (
open = 1
done = 2
)
mark := map[string]int{}
var walk func(string) string
walk = func(id string) string {
switch mark[id] {
case open:
return id
case done:
return ""
}
mark[id] = open
for _, next := range adjacency[id] {
if hit := walk(next); hit != "" {
return hit
}
}
mark[id] = done
return ""
}
for _, id := range ids {
if hit := walk(id); hit != "" {
return hit
}
}
return ""
}
+169
View File
@@ -0,0 +1,169 @@
package domain
import (
"fmt"
"strings"
)
// DecisionRequest is a bounded question to the human. It exists because some
// ambiguity cannot be resolved by reading the repository, and guessing would
// waste a session or ship the wrong behaviour.
//
// Grilling is not a mode here. It is one blocker, one question, one answer,
// and the answer arrives through the ordinary human-decision mechanism. The
// bounds are what keep it from becoming an interview.
type DecisionRequest struct {
Question string `json:"question"`
Why string `json:"why"`
Options []DecisionOption `json:"options,omitempty"`
Evidence []string `json:"evidence,omitempty"`
}
// DecisionOption is one way forward, with the cost of taking it. A request
// without options is legal: sometimes the honest question is open.
type DecisionOption struct {
ID string `json:"id"`
Description string `json:"description"`
Tradeoff string `json:"tradeoff,omitempty"`
}
const (
maxRequestField = 500
maxRequestOption = 4
maxRequestFacts = 8
)
func (r DecisionRequest) Validate() error {
if err := requestLine("question", r.Question, true); err != nil {
return err
}
if err := requestLine("why", r.Why, true); err != nil {
return err
}
if len(r.Options) > maxRequestOption {
return fmt.Errorf("%w: at most %d options", ErrInvalid, maxRequestOption)
}
if len(r.Evidence) > maxRequestFacts {
return fmt.Errorf("%w: at most %d evidence lines", ErrInvalid, maxRequestFacts)
}
seen := map[string]bool{}
for i, o := range r.Options {
if err := requestLine(fmt.Sprintf("options[%d].id", i), o.ID, true); err != nil {
return err
}
if seen[o.ID] {
return fmt.Errorf("%w: duplicate option id %q", ErrInvalid, o.ID)
}
seen[o.ID] = true
if err := requestLine(fmt.Sprintf("options[%d].description", i), o.Description, true); err != nil {
return err
}
if err := requestLine(fmt.Sprintf("options[%d].tradeoff", i), o.Tradeoff, false); err != nil {
return err
}
}
for i, e := range r.Evidence {
if err := requestLine(fmt.Sprintf("evidence[%d]", i), e, true); err != nil {
return err
}
}
return nil
}
// requestLine enforces the single-line, bounded shape. A multi-line field
// would let a request carry the transcript this type exists to exclude.
func requestLine(field, v string, required bool) error {
s := strings.TrimSpace(v)
if s == "" {
if required {
return fmt.Errorf("%w: %s is required", ErrInvalid, field)
}
return nil
}
if len(s) > maxRequestField {
return fmt.Errorf("%w: %s exceeds %d characters", ErrInvalid, field, maxRequestField)
}
if strings.ContainsAny(s, "\n\r") {
return fmt.Errorf("%w: %s must be a single line", ErrInvalid, field)
}
return nil
}
// Render is the human-facing form, delivered in the blocker field that
// notification surfaces already read.
func (r DecisionRequest) Render() string {
var b strings.Builder
b.WriteString("Human decision required.\n")
fmt.Fprintf(&b, "\nQuestion: %s\n", r.Question)
fmt.Fprintf(&b, "Why it blocks: %s\n", r.Why)
if len(r.Options) > 0 {
b.WriteString("\nOptions:\n")
for _, o := range r.Options {
if o.Tradeoff != "" {
fmt.Fprintf(&b, "- %s: %s (tradeoff: %s)\n", o.ID, o.Description, o.Tradeoff)
} else {
fmt.Fprintf(&b, "- %s: %s\n", o.ID, o.Description)
}
}
}
if len(r.Evidence) > 0 {
b.WriteString("\nEvidence:\n")
for _, e := range r.Evidence {
fmt.Fprintf(&b, "- %s\n", e)
}
}
b.WriteString("\nReply with your decision. Any reply is recorded as a decision and resumes the task.\n")
return b.String()
}
// DeferredFinding is a real observation that is not this task's business. It
// is recorded outside agent context so a discovery neither derails the task
// nor evaporates into a promise the next session cannot see.
type DeferredFinding struct {
Summary string `json:"summary"`
Why string `json:"why"`
}
// EventDeferredFindingRecorded keeps a deferred finding in the log without
// putting it in front of an agent.
const EventDeferredFindingRecorded = "DeferredFindingRecorded"
func (f DeferredFinding) Validate() error {
if err := requestLine("summary", f.Summary, true); err != nil {
return err
}
return requestLine("why", f.Why, true)
}
// decodeDecisionRequest reads the request out of a generic event payload.
// Validation lives on the type, so the wire form and the projection agree.
func decodeDecisionRequest(m map[string]any) DecisionRequest {
var r DecisionRequest
r.Question, _ = m["question"].(string)
r.Why, _ = m["why"].(string)
if list, ok := m["options"].([]any); ok {
for _, item := range list {
o, ok := item.(map[string]any)
if !ok {
continue
}
var opt DecisionOption
opt.ID, _ = o["id"].(string)
opt.Description, _ = o["description"].(string)
opt.Tradeoff, _ = o["tradeoff"].(string)
r.Options = append(r.Options, opt)
}
}
if list, ok := m["evidence"].([]any); ok {
for _, item := range list {
if s, ok := item.(string); ok {
r.Evidence = append(r.Evidence, s)
}
}
}
return r
}
// DecodeDecisionRequest is decodeDecisionRequest for callers outside this
// package (the store's projection).
func DecodeDecisionRequest(m map[string]any) DecisionRequest { return decodeDecisionRequest(m) }
+291
View File
@@ -0,0 +1,291 @@
package domain
import (
"encoding/json"
"errors"
"testing"
"time"
)
func decisionEvent(t *testing.T, id, taskID string, at time.Time, p map[string]any) Event {
t.Helper()
b, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
e := Event{ID: id, Type: EventHumanDecisionRecorded, TaskID: taskID, At: at, Payload: b, Surface: "web", SchemaVersion: CurrentEventSchema}
if err := ValidateEvent(e); err != nil {
t.Fatalf("event %s should validate: %v", id, err)
}
return e
}
func decision(t *testing.T, id, taskID string, at time.Time, kind HumanDecisionKind, subject, value string, supersedes ...string) Event {
t.Helper()
p := map[string]any{
"decision_id": id,
"kind": string(kind),
"subject": subject,
"value": value,
"source": map[string]any{"provider": "web", "external_id": "c1"},
}
if len(supersedes) > 0 {
p["supersedes"] = supersedes
}
return decisionEvent(t, "e-"+id, taskID, at, p)
}
var t0 = time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC)
// The one that matters: a correction outranks both the original contract and
// whatever the handoff says the next step is.
func TestCorrectionOverridesContractAndHandoff(t *testing.T) {
task := Task{
ID: "task-1",
State: StateLeased,
Description: "implement a",
Acceptance: []string{"a works"},
// Handoff prose says "next: implement a". It must not reach authority.
HandoffRef: "0000000000000000000000000000000000000000000000000000000000000000",
}
events := []Event{decision(t, "d1", "task-1", t0, HumanDecisionCorrection, "strategy", "use b")}
got, err := ReduceIntent(task, events)
if err != nil {
t.Fatal(err)
}
if got.Task.Description != "implement a" || got.Task.Acceptance[0] != "a works" {
t.Fatalf("contract must survive unmodified, got %+v", got.Task)
}
if len(got.Decisions) != 1 {
t.Fatalf("want 1 standing decision, got %d", len(got.Decisions))
}
if d := got.Decisions[0]; d.Value != "use b" || d.Subject != "strategy" || d.Kind != HumanDecisionCorrection {
t.Fatalf("standing decision = %+v", d)
}
if got.Task.HandoffRef != task.HandoffRef {
t.Fatal("reducer must not rewrite handoff fields")
}
}
func TestExplicitSupersessionRetiresPredecessor(t *testing.T) {
events := []Event{
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "strategy", "use a"),
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use b", "d1"),
}
got, err := ReduceIntent(Task{ID: "task-1"}, events)
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 1 || got.Decisions[0].ID != "d2" {
t.Fatalf("want only d2 standing, got %+v", got.Decisions)
}
}
// Same subject, no supersedes: both stand. Inferring replacement from subject
// is exactly the ambiguity the explicit edge exists to avoid.
func TestSameSubjectWithoutSupersedesKeepsBoth(t *testing.T) {
events := []Event{
decision(t, "d1", "task-1", t0, HumanDecisionConstraint, "strategy", "no new deps"),
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionConstraint, "strategy", "stdlib only"),
}
got, err := ReduceIntent(Task{ID: "task-1"}, events)
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 2 {
t.Fatalf("want both standing, got %+v", got.Decisions)
}
}
func TestStandaloneSupersededEventRetracts(t *testing.T) {
b, _ := json.Marshal(map[string]any{"decision_id": "d1"})
retract := Event{ID: "e-retract", Type: EventHumanDecisionSuperseded, TaskID: "task-1", At: t0.Add(time.Hour), Payload: b, Surface: "web", SchemaVersion: CurrentEventSchema}
if err := ValidateEvent(retract); err != nil {
t.Fatal(err)
}
events := []Event{decision(t, "d1", "task-1", t0, HumanDecisionAnswer, "q", "yes"), retract}
got, err := ReduceIntent(Task{ID: "task-1"}, events)
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 0 {
t.Fatalf("want nothing standing, got %+v", got.Decisions)
}
}
// Log order must not change the answer. Every permutation of a supersession
// chain reduces to the same standing set, including the one where the
// superseding decision is appended before its target.
func TestReductionIsOrderIndependent(t *testing.T) {
d1 := decision(t, "d1", "task-1", t0, HumanDecisionChoice, "strategy", "use a")
d2 := decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use b", "d1")
d3 := decision(t, "d3", "task-1", t0.Add(2*time.Hour), HumanDecisionConstraint, "deps", "stdlib only")
for _, order := range [][]Event{
{d1, d2, d3}, {d3, d2, d1}, {d2, d1, d3}, {d2, d3, d1}, {d3, d1, d2}, {d1, d3, d2},
} {
got, err := ReduceIntent(Task{ID: "task-1"}, order)
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 2 || got.Decisions[0].ID != "d2" || got.Decisions[1].ID != "d3" {
t.Fatalf("order %v reduced to %+v", ids(order), got.Decisions)
}
}
}
func ids(events []Event) []string {
out := make([]string, 0, len(events))
for _, e := range events {
out = append(out, e.ID)
}
return out
}
func TestDuplicateReplayIsIdempotent(t *testing.T) {
d1 := decision(t, "d1", "task-1", t0, HumanDecisionAnswer, "q", "yes")
got, err := ReduceIntent(Task{ID: "task-1"}, []Event{d1, d1, d1})
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 1 {
t.Fatalf("want 1 decision, got %d", len(got.Decisions))
}
}
func TestConflictingDuplicateDecisionIDRejected(t *testing.T) {
a := decision(t, "d17", "task-1", t0, HumanDecisionChoice, "strategy", "use a")
b := decision(t, "d17", "task-1", t0, HumanDecisionChoice, "strategy", "use b")
if _, err := ReduceIntent(Task{ID: "task-1"}, []Event{a, b}); !errors.Is(err, ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
// Reversing the two must fail the same way. Order must never decide.
if _, err := ReduceIntent(Task{ID: "task-1"}, []Event{b, a}); !errors.Is(err, ErrInvalid) {
t.Fatalf("reversed: want ErrInvalid, got %v", err)
}
// A differing timestamp is also a conflict, because At orders the output.
c := decision(t, "d17", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use a")
if _, err := ReduceIntent(Task{ID: "task-1"}, []Event{a, c}); !errors.Is(err, ErrInvalid) {
t.Fatalf("timestamp conflict: want ErrInvalid, got %v", err)
}
// An identical replay, including supersedes, still reduces cleanly.
base := decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a")
sup := decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1")
got, err := ReduceIntent(Task{ID: "task-1"}, []Event{base, sup, sup, base})
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 1 || got.Decisions[0].ID != "d2" {
t.Fatalf("standing set = %+v", got.Decisions)
}
}
func TestUnknownSupersedesTargetRejected(t *testing.T) {
events := []Event{decision(t, "d1", "task-1", t0, HumanDecisionChoice, "strategy", "use b", "ghost")}
if _, err := ReduceIntent(Task{ID: "task-1"}, events); !errors.Is(err, ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
}
// A cross-task reference is an unknown target, not a silent no-op: the other
// task's decision is invisible to this reduction and cannot be retired here.
func TestCannotSupersedeAnotherTasksDecision(t *testing.T) {
events := []Event{
decision(t, "other", "task-2", t0, HumanDecisionChoice, "strategy", "use a"),
decision(t, "d1", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "strategy", "use b", "other"),
}
if _, err := ReduceIntent(Task{ID: "task-1"}, events); !errors.Is(err, ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
// And the other task's own reduction is unaffected by task-1's events.
got, err := ReduceIntent(Task{ID: "task-2"}, events)
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 1 || got.Decisions[0].ID != "other" {
t.Fatalf("task-2 standing set = %+v", got.Decisions)
}
}
func TestSupersessionCyclesRejected(t *testing.T) {
for name, events := range map[string][]Event{
"self": {decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "v", "d1")},
"pair": {
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a", "d2"),
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1"),
},
"three": {
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a", "d3"),
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1"),
decision(t, "d3", "task-1", t0.Add(2*time.Hour), HumanDecisionChoice, "s", "c", "d2"),
},
} {
if _, err := ReduceIntent(Task{ID: "task-1"}, events); !errors.Is(err, ErrInvalid) {
t.Fatalf("%s cycle: want ErrInvalid, got %v", name, err)
}
}
}
// A transitive chain leaves only the head standing.
func TestTransitiveChainKeepsOnlyHead(t *testing.T) {
events := []Event{
decision(t, "d1", "task-1", t0, HumanDecisionChoice, "s", "a"),
decision(t, "d2", "task-1", t0.Add(time.Hour), HumanDecisionChoice, "s", "b", "d1"),
decision(t, "d3", "task-1", t0.Add(2*time.Hour), HumanDecisionChoice, "s", "c", "d2"),
}
got, err := ReduceIntent(Task{ID: "task-1"}, events)
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 1 || got.Decisions[0].ID != "d3" {
t.Fatalf("standing set = %+v", got.Decisions)
}
}
// Lifecycle events are inert to the reducer, so authority cannot be smuggled
// in through a release, a pickup, or an amendment.
func TestLifecycleEventsCarryNoAuthority(t *testing.T) {
amend, _ := json.Marshal(map[string]any{"description": "implement a instead"})
events := []Event{
{ID: "e1", Type: "TaskAmended", TaskID: "task-1", At: t0, Payload: amend, Surface: "system"},
decision(t, "d1", "task-1", t0.Add(time.Hour), HumanDecisionCorrection, "strategy", "use b"),
}
got, err := ReduceIntent(Task{ID: "task-1", Description: "implement a"}, events)
if err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 1 || got.Decisions[0].Value != "use b" {
t.Fatalf("standing set = %+v", got.Decisions)
}
}
func TestDecisionEventValidation(t *testing.T) {
base := func() map[string]any {
return map[string]any{
"decision_id": "d1", "kind": "correction", "subject": "strategy", "value": "use b",
"source": map[string]any{"provider": "web"},
}
}
if err := ValidatePayload(EventHumanDecisionRecorded, base()); err != nil {
t.Fatalf("valid payload rejected: %v", err)
}
for name, mutate := range map[string]func(map[string]any){
"no decision_id": func(p map[string]any) { delete(p, "decision_id") },
"no subject": func(p map[string]any) { delete(p, "subject") },
"no value": func(p map[string]any) { delete(p, "value") },
"bad kind": func(p map[string]any) { p["kind"] = "vibes" },
"no source": func(p map[string]any) { delete(p, "source") },
"no provider": func(p map[string]any) { p["source"] = map[string]any{} },
"supersedes str": func(p map[string]any) { p["supersedes"] = "d0" },
"supersedes nil": func(p map[string]any) { p["supersedes"] = []any{""} },
} {
p := base()
mutate(p)
if err := ValidatePayload(EventHumanDecisionRecorded, p); !errors.Is(err, ErrInvalid) {
t.Fatalf("%s: want ErrInvalid, got %v", name, err)
}
}
if err := ValidatePayload(EventHumanDecisionSuperseded, map[string]any{}); !errors.Is(err, ErrInvalid) {
t.Fatalf("empty supersede payload: want ErrInvalid, got %v", err)
}
}
+133 -5
View File
@@ -41,6 +41,9 @@ const (
// completion, explicitly release it, or renew it while an operator
// investigates; expiry remains the only automatic reclaim.
StateNeedsAttention TaskState = "needs_attention"
// StateInReview is a submitted change waiting on the human. It is not
// completion: an agent never decides that a change shipped.
StateInReview TaskState = "in_review"
)
// BlockReason is the machine-readable diagnosis for a TaskBlocked event.
@@ -56,14 +59,24 @@ const (
BlockReasonHandoffValidation BlockReason = "handoff_validation"
BlockReasonOperator BlockReason = "operator_block"
BlockReasonSystem BlockReason = "system_error"
BlockReasonUnknown BlockReason = "unknown"
// BlockReasonTrajectoryGate is a deliberate stop, not a fault: the plan is
// sealed and Orchestra is waiting for the human to confirm the direction.
BlockReasonTrajectoryGate BlockReason = "trajectory_gate"
// BlockReasonHumanDecision is a bounded question the repository could not
// answer. BlockReasonOperatorRequired is what a task becomes when it has
// spent its question budget: an operator looks at it rather than the
// agent asking again.
BlockReasonHumanDecision BlockReason = "human_decision"
BlockReasonOperatorRequired BlockReason = "operator_required"
BlockReasonUnknown BlockReason = "unknown"
)
func (r BlockReason) Valid() bool {
switch r {
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired,
BlockReasonApproval, BlockReasonHandoffValidation, BlockReasonOperator,
BlockReasonSystem, BlockReasonUnknown:
BlockReasonSystem, BlockReasonUnknown, BlockReasonTrajectoryGate,
BlockReasonHumanDecision, BlockReasonOperatorRequired:
return true
}
return false
@@ -158,7 +171,47 @@ type Task struct {
NextRetryAt time.Time `json:"next_retry_at,omitempty"`
FailureClass string `json:"failure_class,omitempty"`
LifecyclePhase string `json:"lifecycle_phase,omitempty"`
LastError string `json:"last_error,omitempty"`
// WorkPhase is the cognitive phase (frame/research/plan/implement/review),
// orthogonal to State and LifecyclePhase. Empty means frame.
WorkPhase WorkPhase `json:"work_phase,omitempty"`
// DecisionRequest is the question this task is currently blocked on. It is
// cleared when the task leaves the blocked state, because the answer then
// stands on its own as a decision and the log still holds the question.
DecisionRequest *DecisionRequest `json:"decision_request,omitempty"`
// ReviewTargetSHA is the commit the current review phase was entered
// against. A review of any other commit is not a review of this work.
ReviewTargetSHA string `json:"review_target_sha,omitempty"`
// Review is the last independent review, bound to the commit it was
// performed against. A review is never a free-floating pass: when the code
// moves, ResultSHA no longer matches and the review describes a tree that
// does not exist any more.
Review *ReviewRef `json:"review,omitempty"`
// Submission is the durable record of the change handed to the human.
Submission *SubmissionRef `json:"submission,omitempty"`
// ResearchRef and PlanRef are the sealed artifacts of the phases already
// finished. The next phase reads these, never the session that wrote them.
ResearchRef string `json:"research_ref,omitempty"`
PlanRef string `json:"plan_ref,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ReviewRef binds a sealed review artifact to one commit.
type ReviewRef struct {
ArtifactRef string `json:"artifact_ref"`
ResultSHA string `json:"result_sha"`
// Blocking is the count of blocker and important findings, projected so a
// completion check does not have to read the artifact to know the answer.
Blocking int `json:"blocking"`
}
// EventReviewRecorded seals one independent review. Orchestra emits it; the
// reviewing session only supplies the findings.
const EventReviewRecorded = "ReviewRecorded"
// ReviewSatisfied reports whether this task holds an accepted review of the
// exact commit named. It is the mechanical half of completion eligibility.
func (t Task) ReviewSatisfied(resultSHA string) bool {
return t.Review != nil && t.Review.ResultSHA == resultSHA && t.Review.Blocking == 0
}
type Event struct {
@@ -198,7 +251,7 @@ func ValidateEvent(e Event) error {
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
return fmt.Errorf("%w: surface required", ErrInvalid)
}
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true}
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true, EventHumanDecisionRecorded: true, EventHumanDecisionSuperseded: true, EventWorkPhaseChanged: true, EventDeferredFindingRecorded: true, EventReviewRecorded: true, EventTaskSubmitted: true, EventTaskChangesRequested: true}
if !allowed[e.Type] {
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
}
@@ -343,6 +396,15 @@ func ValidatePayload(typ string, p map[string]any) error {
if v, ok := p["pane_state"]; ok && v != "open" && v != "closed" && v != "unreachable" && v != "unknown" {
return fmt.Errorf("%w: pane_state invalid", ErrInvalid)
}
if v, ok := p["decision_request"]; ok {
m, ok := v.(map[string]any)
if !ok {
return fmt.Errorf("%w: decision_request must be an object", ErrInvalid)
}
if err := decodeDecisionRequest(m).Validate(); err != nil {
return err
}
}
case "TaskAmended":
if len(p) == 0 {
return fmt.Errorf("%w: amendment cannot be empty", ErrInvalid)
@@ -362,7 +424,7 @@ func ValidatePayload(typ string, p map[string]any) error {
return fmt.Errorf("%w: state must be a string", ErrInvalid)
}
switch TaskState(s) {
case StateQueued, StateLeased, StateCompleted, StateFailed, StateBlocked, StateNeedsAttention:
case StateQueued, StateLeased, StateCompleted, StateFailed, StateBlocked, StateNeedsAttention, StateInReview:
default:
return fmt.Errorf("%w: state invalid", ErrInvalid)
}
@@ -391,6 +453,72 @@ func ValidatePayload(typ string, p map[string]any) error {
if _, ok := p["items"]; !ok {
return fmt.Errorf("%w: items required", ErrInvalid)
}
case EventHumanDecisionRecorded:
for _, k := range []string{"decision_id", "kind", "subject", "value"} {
if err := requiredString(k); err != nil {
return err
}
}
if kind, _ := p["kind"].(string); !HumanDecisionKind(kind).Valid() {
return fmt.Errorf("%w: kind invalid", ErrInvalid)
}
src, ok := p["source"].(map[string]any)
if !ok {
return fmt.Errorf("%w: source required", ErrInvalid)
}
if v, ok := src["provider"].(string); !ok || strings.TrimSpace(v) == "" {
return fmt.Errorf("%w: source.provider required", ErrInvalid)
}
if v, ok := p["supersedes"]; ok {
list, ok := v.([]any)
if !ok {
return fmt.Errorf("%w: supersedes must be an array", ErrInvalid)
}
for _, item := range list {
if s, ok := item.(string); !ok || strings.TrimSpace(s) == "" {
return fmt.Errorf("%w: supersedes entries must be decision ids", ErrInvalid)
}
}
}
case EventHumanDecisionSuperseded:
if err := requiredString("decision_id"); err != nil {
return err
}
case EventWorkPhaseChanged:
return ValidateWorkPhaseChanged(p)
case EventTaskSubmitted:
return ValidateTaskSubmitted(p)
case EventTaskChangesRequested:
if v, ok := p["submitted_sha"].(string); !ok || len(v) != 40 {
return fmt.Errorf("%w: submitted_sha invalid", ErrInvalid)
}
if v, ok := p["submission_event"].(string); !ok || strings.TrimSpace(v) == "" {
return fmt.Errorf("%w: submission_event required", ErrInvalid)
}
ids, ok := p["decision_ids"].([]any)
if !ok || len(ids) == 0 {
return fmt.Errorf("%w: decision_ids required", ErrInvalid)
}
for _, id := range ids {
if s, ok := id.(string); !ok || strings.TrimSpace(s) == "" {
return fmt.Errorf("%w: decision_ids entries must be ids", ErrInvalid)
}
}
case EventReviewRecorded:
if err := requiredHash(p, "artifact_ref"); err != nil {
return err
}
if v, ok := p["result_sha"].(string); !ok || len(v) != 40 {
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
}
if v, ok := p["blocking"].(float64); !ok || v < 0 || v != float64(int(v)) {
return fmt.Errorf("%w: blocking invalid", ErrInvalid)
}
case EventDeferredFindingRecorded:
f := DeferredFinding{}
f.Summary, _ = p["summary"].(string)
f.Why, _ = p["why"].(string)
return f.Validate()
}
return nil
}
+174
View File
@@ -0,0 +1,174 @@
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
}
+90
View File
@@ -0,0 +1,90 @@
package domain
import "fmt"
// WorkPhase is the cognitive phase of a task. It is orthogonal to TaskState:
// a task can be leased in any phase, and a phase change is not a lifecycle
// transition. Keeping them separate is what stops a rotation from looking
// like progress and a failed experiment from looking like a failed task.
type WorkPhase string
const (
WorkPhaseFrame WorkPhase = "frame"
WorkPhaseResearch WorkPhase = "research"
WorkPhasePlan WorkPhase = "plan"
WorkPhaseImplement WorkPhase = "implement"
WorkPhaseReview WorkPhase = "review"
)
// EventWorkPhaseChanged is emitted by Orchestra, never by an agent. An agent
// asks for a phase change through the approval surface and Orchestra decides.
const EventWorkPhaseChanged = "WorkPhaseChanged"
func (p WorkPhase) Valid() bool {
switch p {
case WorkPhaseFrame, WorkPhaseResearch, WorkPhasePlan, WorkPhaseImplement, WorkPhaseReview:
return true
}
return false
}
// legalPhaseTransitions is the full set of moves Orchestra may make. A
// project's declared path is a subset of this, checked where the registry is
// visible. Skipping ahead is allowed, going backwards is not, except for
// review sending work back to implement.
var legalPhaseTransitions = map[WorkPhase][]WorkPhase{
WorkPhaseFrame: {WorkPhaseResearch, WorkPhaseImplement},
WorkPhaseResearch: {WorkPhasePlan, WorkPhaseImplement},
WorkPhasePlan: {WorkPhaseImplement},
WorkPhaseImplement: {WorkPhaseReview},
WorkPhaseReview: {WorkPhaseImplement},
}
// CanTransitionPhase reports whether Orchestra may move from one phase to
// another. An empty from is treated as frame, the phase every task starts in.
func CanTransitionPhase(from, to WorkPhase) bool {
if from == "" {
from = WorkPhaseFrame
}
if !from.Valid() || !to.Valid() {
return false
}
for _, allowed := range legalPhaseTransitions[from] {
if allowed == to {
return true
}
}
return false
}
// ValidateWorkPhaseChanged checks the payload shape. Whether the transition
// is legal from the task's current phase is checked at the append boundary,
// where the current phase is visible.
func ValidateWorkPhaseChanged(p map[string]any) error {
phase, _ := p["phase"].(string)
if !WorkPhase(phase).Valid() {
return fmt.Errorf("%w: phase invalid", ErrInvalid)
}
if v, ok := p["from"]; ok {
s, ok := v.(string)
if !ok || !WorkPhase(s).Valid() {
return fmt.Errorf("%w: from invalid", ErrInvalid)
}
}
// A sealed artifact is what makes the next phase's context cheap. It is
// required when leaving research or plan, because those phases exist to
// produce one.
if v, ok := p["result_sha"]; ok {
s, ok := v.(string)
if !ok || len(s) != 40 {
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
}
}
if v, ok := p["artifact_ref"]; ok {
s, _ := v.(string)
if err := requiredHash(map[string]any{"artifact_ref": s}, "artifact_ref"); err != nil {
return err
}
}
return nil
}