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