From 77a2b323fabcf080d7542061ae2d7b3c34eef5b7 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 26 Aug 2026 23:48:04 +0400 Subject: [PATCH] Stop lease validation from depending on the current clock 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 --- internal/domain/decision_test.go | 35 ++++++++++++++++++++++++++++++++ internal/domain/domain.go | 12 +++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/domain/decision_test.go b/internal/domain/decision_test.go index d121c4a..6113aa8 100644 --- a/internal/domain/decision_test.go +++ b/internal/domain/decision_test.go @@ -289,3 +289,38 @@ func TestDecisionEventValidation(t *testing.T) { 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 +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 5bdedbb..fb1acf1 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -299,12 +299,19 @@ func ValidatePayload(typ string, p map[string]any) error { if err := requiredString("harness_id"); err != nil { return err } + // Validation of a durable event must not depend on the current clock. + // Comparing until_ns against time.Now() here made every lease event + // fail validation once it expired, so replaying the log after a + // restart refused the store's own history and the coordinator could + // not start at all. Freshness is a lease question, answered by + // Store.Lease and Store.ExpireLeases; well-formedness is this + // function's question. until, untilOK := p["until_ns"].(float64) if ttl, ok := p["ttl"].(float64); ok { if ttl <= 0 { return fmt.Errorf("%w: ttl invalid", ErrInvalid) } - } else if !untilOK || until <= float64(time.Now().UnixNano()) { + } else if !untilOK || until <= 0 { return fmt.Errorf("%w: ttl required", ErrInvalid) } if v, ok := p["expected_version"].(float64); !ok || v < 0 || v != float64(int(v)) { @@ -314,8 +321,9 @@ func ValidatePayload(typ string, p map[string]any) error { if err := requiredString("harness_id"); err != nil { return err } + // Time-independent for the same reason as TaskLeased above. until, ok := p["until_ns"].(float64) - if !ok || until <= float64(time.Now().UnixNano()) { + if !ok || until <= 0 { return fmt.Errorf("%w: until_ns required", ErrInvalid) } if v, ok := p["expected_version"].(float64); !ok || v < 0 || v != float64(int(v)) {