Files
orchestra/internal/domain/decision.go
T
kami 7f12c7fc37 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>
2026-08-26 18:31:20 +04:00

232 lines
7.4 KiB
Go

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 ""
}