diff --git a/AUDIT.md b/AUDIT.md index 4055f78..e2a9a46 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -947,6 +947,34 @@ a source of transcript/tool-call data this repo doesn't have yet. `go build ./...`, `go vet ./...`, `go test ./...` all pass. +## S8 — closed, 2026-07-27 + +No compensation-event mechanism existed — §3.1's own invariant ("a wrong +event is never edited; a compensating event is appended and replay sees +both") had nothing implementing it. `TaskAmended` was the closest analog but +only merges metadata fields forward with no reference to what it's +correcting and no way to touch `State`. + +Added a new event type, `TaskCorrected`, generalizing that gap rather than +special-casing it: payload requires `corrects` (the `id` of the event being +repaired) plus at least one field to change — `state` (validated against the +same enum as `domain.TaskState`) and/or the existing amend-style fields +(`title`/`description`/`inherent_priority`/`due`). `domain.ValidatePayload` +checks shape; `Store.Append` checks that `corrects` actually names an event +belonging to the same task in the log (returning `ErrInvalid` otherwise) — +existence can only be checked where the log is visible, not in the +shape-only validator. `store.apply`'s new `TaskCorrected` branch clears +`Lease` whenever the corrected state isn't `leased`, matching every other +terminal-state branch. No new authz surface rule was needed — it slots into +the existing `TaskAmended`-shaped FullControl/GatedWrite policy unchanged. + +Covered by `TestTaskCorrected` (`internal/store/store_test.go`): a mistaken +`TaskFailed` is reverted to `queued` by an appended `TaskCorrected` +referencing it, a correction naming an unknown/foreign event is rejected, +and both the original wrong event and its correction survive a full +snapshot+replay reopen (the log is never edited, only appended to). `go +build ./...`, `go vet ./...`, `go test ./...` all pass. + ### Design consequences (not yet implemented) 1. **Percentages are a level, not a delta.** diff --git a/internal/domain/domain.go b/internal/domain/domain.go index a061cf3..96f2bdf 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -101,7 +101,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, "TaskReleased": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "QuotaReported": true, "StandupAdvisory": true} + allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskReleased": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true} if !allowed[e.Type] { return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type) } @@ -189,6 +189,29 @@ func ValidatePayload(typ string, p map[string]any) error { if len(p) == 0 { return fmt.Errorf("%w: amendment cannot be empty", ErrInvalid) } + case "TaskCorrected": + // §3.1: "a wrong event is never edited; a compensating event is + // appended and replay sees both." `corrects` names the event this one + // reverses/repairs — existence against the log is checked in + // Store.Append, where the log is visible; ValidatePayload only knows + // shape. + if err := requiredString("corrects"); err != nil { + return err + } + if v, ok := p["state"]; ok { + s, ok := v.(string) + if !ok { + return fmt.Errorf("%w: state must be a string", ErrInvalid) + } + switch TaskState(s) { + case StateQueued, StateLeased, StateCompleted, StateFailed, StateBlocked: + default: + return fmt.Errorf("%w: state invalid", ErrInvalid) + } + } + if len(p) < 2 { + return fmt.Errorf("%w: correction must change at least one field", ErrInvalid) + } case "ApprovalRequested": for _, k := range []string{"subject_ref", "options"} { if _, ok := p[k]; !ok { diff --git a/internal/store/store.go b/internal/store/store.go index 79d7066..b568525 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -169,6 +169,27 @@ func (s *Store) apply(e domain.Event) error { t.Due = &d } } + case "TaskCorrected": + if v, ok := p["title"].(string); ok { + t.Title = v + } + if v, ok := p["description"].(string); ok { + t.Description = v + } + if v, ok := p["inherent_priority"].(float64); ok { + t.InherentPriority = int(v) + } + if v, ok := p["due"].(string); ok { + if d, err := time.Parse(time.RFC3339, v); err == nil { + t.Due = &d + } + } + if v, ok := p["state"].(string); ok { + t.State = domain.TaskState(v) + if t.State != domain.StateLeased { + t.Lease = nil + } + } } t.Version = e.Version s.tasks[e.TaskID] = t @@ -230,6 +251,21 @@ func (s *Store) Append(e domain.Event) error { return domain.ErrConflict } } + if e.Type == "TaskCorrected" { + var p map[string]any + _ = json.Unmarshal(e.Payload, &p) + corrects, _ := p["corrects"].(string) + found := false + for _, prior := range s.events { + if prior.ID == corrects && prior.TaskID == e.TaskID { + found = true + break + } + } + if !found { + return fmt.Errorf("%w: corrects references unknown event %q for this task", domain.ErrInvalid, corrects) + } + } global := e.Type == "QuotaReported" || e.Type == "StandupAdvisory" || e.Type == "ApprovalGranted" || e.Type == "ApprovalDenied" if !taskExists && e.Type != "TaskCreated" && !global { return domain.ErrNotFound diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9024852..58930f2 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -86,6 +86,56 @@ func TestTaskAmendedAppliesAllFields(t *testing.T) { } } +// TestTaskCorrected guards S8: the spec (§3.1) requires corrections to be +// compensating events appended on top of a wrong one, never an edit of the +// log — a wrongly-emitted terminal state (e.g. a mistaken TaskFailed) must be +// repairable by appending a new event that references the one it corrects, +// with both surviving in the replayed log. +func TestTaskCorrected(t *testing.T) { + dir := t.TempDir() + s, err := Open(dir) + if err != nil { + t.Fatal(err) + } + if err := s.Append(created("e1")); err != nil { + t.Fatal(err) + } + failPayload, _ := json.Marshal(map[string]any{"reason": "mistaken failure"}) + failEvt := domain.Event{ID: "e2", Type: "TaskFailed", TaskID: "task-1", Version: 2, Payload: failPayload, Surface: string(authz.System)} + if err := s.Append(failEvt); err != nil { + t.Fatal(err) + } + if tk := s.Tasks()[0]; tk.State != domain.StateFailed { + t.Fatalf("expected failed, got %s", tk.State) + } + + // Referencing an unknown event is rejected. + badCorrection, _ := json.Marshal(map[string]any{"corrects": "does-not-exist", "state": "queued"}) + if err := s.Append(domain.Event{Type: "TaskCorrected", TaskID: "task-1", Version: 3, Payload: badCorrection, Surface: string(authz.System)}); !errors.Is(err, domain.ErrInvalid) { + t.Fatalf("expected ErrInvalid for unknown corrects target, got %v", err) + } + + correction, _ := json.Marshal(map[string]any{"corrects": "e2", "state": "queued"}) + if err := s.Append(domain.Event{ID: "e3", Type: "TaskCorrected", TaskID: "task-1", Version: 3, Payload: correction, Surface: string(authz.System)}); err != nil { + t.Fatal(err) + } + tk := s.Tasks()[0] + if tk.State != domain.StateQueued { + t.Fatalf("expected correction to restore queued state, got %s", tk.State) + } + + // Replay from disk still applies both the wrong event and its correction + // (the snapshot only elides already-applied events from Events(), never + // from the durable log itself). + s2, err := Open(dir) + if err != nil { + t.Fatal(err) + } + if tk2 := s2.Tasks()[0]; tk2.State != domain.StateQueued { + t.Fatalf("expected replayed state queued, got %s", tk2.State) + } +} + // TestLeaseAndExpireEventIDsAreUnique guards S5: Event.ID was set to the // task id in both Lease and ExpireLeases, so every lease of the same task // produced a TaskLeased/TaskReleased event with a colliding ID — unsound diff --git a/progress.md b/progress.md index c96584a..1d6771f 100644 --- a/progress.md +++ b/progress.md @@ -295,7 +295,19 @@ Fixed so far: would refuse or continue — asserting rotation happens anyway once a `reason=manual` handoff exists). -Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11's +- **S8** — no compensation-event mechanism existed for §3.1's own invariant + ("a wrong event is never edited; a compensating event is appended and + replay sees both"). Added `TaskCorrected`: payload requires `corrects` + (the id of the event it repairs) plus at least one change (`state`, or the + existing amend-style metadata fields). `Store.Append` rejects a `corrects` + that doesn't name a real prior event on the same task; `store.apply` + applies the state/field changes and clears `Lease` like every other + terminal-state branch. Covered by `TestTaskCorrected` + (`internal/store/store_test.go`): a mistaken `TaskFailed` reverted to + `queued`, an unknown-`corrects` rejection, and both events surviving a + snapshot+replay reopen. + +Not yet started: Codex/opencode Stop-hook-equivalent scripts, S11's milestone/thrash pieces. See `AUDIT.md` for the full plan. **Phase 0 done (2026-07-27):** this box has live TCP reachability to the real