host: root's publish receipt binds the action, kind and phase (Task 7b fix round 2, P3-1)
A landing's configured receipt answered its probing request as a replay within the same holder tuple. The receipt now carries Action, Kind and Phase, so only exactly the same request is a replay; the probe's runs the whole path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+33
−4
internal/host/movesheldrelease_root.go+10 −4internal/host/movesheldrelease_test.go+23 −0
diff --git a/internal/host/movesheldrelease_root.go b/internal/host/movesheldrelease_root.go| index 82932d408..6e59057a7 100644 |
| --- a/internal/host/movesheldrelease_root.go |
| +++ b/internal/host/movesheldrelease_root.go |
| @@ -449,9 +449,14 @@ func (h *movesHeldReleaseRoot) publishRelease(ctx context.Context, actor hostbox |
| // heldReleaseReceipt is root's record of a publication it made, one per |
| // host (the latest), keyed by the session and the render hash with the |
| -// identity the publication was for. |
| +// identity the publication was for, and the action, kind and phase it was |
| +// made at (fix round 2, P3-1), so a landing's configured receipt never |
| +// answers its probing request. |
| type heldReleaseReceipt struct { |
| Schema string `json:"schema"` |
| + Action string `json:"action"` |
| + Kind string `json:"kind"` |
| + Phase string `json:"phase"` |
| Host string `json:"host"` |
| Account string `json:"account"` |
| App string `json:"app"` |
| @@ -468,7 +473,7 @@ type heldReleaseReceipt struct { |
| } |
| func receiptFor(q heldReleaseRequest) heldReleaseReceipt { |
| - return heldReleaseReceipt{Schema: heldReleaseSchema, Host: q.Host, Account: q.Account, App: q.App, Version: q.Version, MoveID: q.MoveID, HoldOp: q.HoldOp, |
| + return heldReleaseReceipt{Schema: heldReleaseSchema, Action: q.Action, Kind: q.Kind, Phase: q.Phase, Host: q.Host, Account: q.Account, App: q.App, Version: q.Version, MoveID: q.MoveID, HoldOp: q.HoldOp, |
| Incarnation: q.Incarnation, Stamp: q.Stamp, Session: q.Session, Generation: q.Generation, RenderHash: q.RenderHash, Outcome: heldReleaseOutcomePublished} |
| } |
| @@ -477,8 +482,9 @@ func (h *movesHeldReleaseRoot) receiptPath(host string) string { |
| } |
| // replayed reports whether root's receipt for q's host is for exactly q: |
| -// the same session and render hash, and the same identity. Anything else, |
| -// an unreadable receipt included, is not a replay and runs the whole path. |
| +// the same session and render hash, the same identity, action, kind and |
| +// phase. Anything else, an unreadable receipt included, is not a replay and |
| +// runs the whole path. |
| func (h *movesHeldReleaseRoot) replayed(q heldReleaseRequest) bool { |
| f, err := safefile.OpenFile(h.receiptPath(q.Host), os.O_RDONLY, 0) |
| if err != nil { |
diff --git a/internal/host/movesheldrelease_test.go b/internal/host/movesheldrelease_test.go| index 49cad8df6..1975b4bd0 100644 |
| --- a/internal/host/movesheldrelease_test.go |
| +++ b/internal/host/movesheldrelease_test.go |
| @@ -528,6 +528,29 @@ func TestHeldReleaseRootReplayAndCrashRecovery(t *testing.T) { |
| } |
| }) |
| + t.Run("a configured receipt never answers the probe", func(t *testing.T) { |
| + // Fix round 2, P3-1: the receipt binds the action, the kind and the |
| + // phase, so a landing's configured publication does not answer its |
| + // probing request within the same holder tuple: the probe's request |
| + // runs the whole path. |
| + f := newHRFixture(t) |
| + if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.set(func(f *hrFixture) { |
| + f.claim.Op.Phase = provision.LandProbing |
| + f.rec.Move.LandingPhase = provision.LandProbing |
| + }) |
| + reads := f.claimReads |
| + resp, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)) |
| + if err != nil || resp.Replayed { |
| + t.Fatalf("the probe's request: %+v %v, want the whole path", resp, err) |
| + } |
| + if f.sel.count() != 2 || f.claimReads == reads { |
| + t.Fatalf("selections %d, claim reads +%d: the probe was answered from configured's receipt", f.sel.count(), f.claimReads-reads) |
| + } |
| + }) |
| + |
| t.Run("a stale request is not answered from an old receipt", func(t *testing.T) { |
| f := newHRFixture(t) |
| if _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)); err != nil { |