77a2b323fa
The API was in a restart loop, exiting with `invalid event: until_ns required`. ValidateEvent compared until_ns against time.Now() for TaskLeased and TaskLeaseRenewed, so a lease event that was valid when written failed validation once it expired. store.Open replays the log tail after the snapshot and log.Fatal's on the first invalid event, so the coordinator refused its own history and could not start. Validation of a durable event must be time-independent. Well-formedness is this function's question; freshness belongs to Store.Lease and Store.ExpireLeases, which compute until_ns themselves. Latent since the field was introduced. It needed a renewal in the post-snapshot tail plus a restart after that renewal expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
12 KiB
Go
327 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// A durable event must validate the same way forever. Comparing until_ns
|
|
// against the current clock made every lease event fail once it expired, so
|
|
// replaying the log after a restart refused the store's own history and the
|
|
// coordinator could not start. Found live: the API entered a restart loop
|
|
// logging "invalid event: until_ns required".
|
|
func TestLeaseEventsValidateAfterTheyExpire(t *testing.T) {
|
|
past := float64(time.Now().Add(-24 * time.Hour).UnixNano())
|
|
for _, e := range []Event{
|
|
{ID: "a", Type: "TaskLeased", TaskID: "t", Version: 2, Surface: "system", Payload: mustPayload(map[string]any{
|
|
"harness_id": "h1", "until_ns": past, "expected_version": 1,
|
|
})},
|
|
{ID: "b", Type: "TaskLeaseRenewed", TaskID: "t", Version: 3, Surface: "system", Payload: mustPayload(map[string]any{
|
|
"harness_id": "h1", "until_ns": past, "expected_version": 2,
|
|
})},
|
|
} {
|
|
if err := ValidateEvent(e); err != nil {
|
|
t.Fatalf("%s failed validation after expiry: %v", e.Type, err)
|
|
}
|
|
}
|
|
// Well-formedness is still checked.
|
|
if err := ValidateEvent(Event{ID: "c", Type: "TaskLeaseRenewed", TaskID: "t", Version: 3, Surface: "system", Payload: mustPayload(map[string]any{
|
|
"harness_id": "h1", "expected_version": 2,
|
|
})}); err == nil {
|
|
t.Fatal("a renewal with no until_ns was accepted")
|
|
}
|
|
}
|
|
|
|
func mustPayload(v map[string]any) []byte {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|