feat(store): add TaskCorrected compensating-event type (S8)

Implements §3.1's invariant that a wrong event is never edited, only
compensated for by a new appended event. TaskCorrected references the
event it repairs and can change state and/or amend-style fields;
Store.Append verifies the referenced event actually exists on the task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
kami
2026-07-27 23:48:19 +04:00
parent 86cc0b9276
commit e363a77ae9
5 changed files with 151 additions and 2 deletions
+28
View File
@@ -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.**
+24 -1
View File
@@ -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 {
+36
View File
@@ -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
+50
View File
@@ -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
+13 -1
View File
@@ -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