From 2ac35c05d5ca4b2063eeee02c912bce5a0ee3cae Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 6 Aug 2026 11:23:22 -0700 Subject: [PATCH 1/4] waved: Answer inbound RPCs to the envelope sender handleInboundRPC addressed its response to the request's Rpc.ReplyTo verbatim. A response belongs to whoever sent the request, so the sender is the authoritative destination and is what we address now. The two agree for every producer in this repo: serverconn's unary_facade, actor and heartbeat paths all set Sender and Rpc.ReplyTo to the same LocalMailboxID, as do the operator's clientconn equivalents. So this changes nothing for a well-formed peer. What it removes is a request's ability to name a reply destination that disagrees with where the request came from. It also fixes an absent ReplyTo, which produced an empty Recipient. The mailbox store rejects that outright, so a caller that left the field unset lost its answer rather than receiving it. TestHandleInboundRPCAnswersTheSender covers all three cases through the handler's error path, which still sends a response and so still has to address one. --- waved/server.go | 19 +++++- waved/server_reply_to_test.go | 119 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 waved/server_reply_to_test.go diff --git a/waved/server.go b/waved/server.go index bec65f659..ea4bbeb18 100644 --- a/waved/server.go +++ b/waved/server.go @@ -3605,8 +3605,23 @@ func (s *Server) handleInboundRPC(ctx context.Context, } responseEnv := &mailboxpb.Envelope{ - Sender: s.localMailboxID, - Recipient: env.Rpc.ReplyTo, + Sender: s.localMailboxID, + + // A response belongs to whoever sent the request, so address + // it to the sender rather than to the request's ReplyTo. + // + // The two agree for every producer in this repo: each sets + // Sender and Rpc.ReplyTo to the same LocalMailboxID (see + // serverconn/unary_facade.go, serverconn/actor.go, + // serverconn/heartbeat.go and the operator's clientconn + // equivalents). So this changes nothing for a well-formed + // peer, and it stops a request choosing a destination that + // disagrees with where it came from. + // + // It also fixes an absent ReplyTo. That used to produce an + // empty recipient, which the mailbox store rejects outright, + // so the caller lost its answer entirely. + Recipient: env.Sender, Headers: headers, Body: body, Rpc: &mailboxpb.RpcMeta{ diff --git a/waved/server_reply_to_test.go b/waved/server_reply_to_test.go new file mode 100644 index 000000000..3dbbd99cc --- /dev/null +++ b/waved/server_reply_to_test.go @@ -0,0 +1,119 @@ +package waved + +import ( + "context" + "testing" + + mailboxpb "github.com/lightninglabs/wavelength/mailbox/pb" + mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/anypb" +) + +// recordingReplyEdge captures the envelopes handleInboundRPC sends so a test +// can assert where a response was addressed. +type recordingReplyEdge struct { + sent []*mailboxpb.Envelope +} + +// Send records the outbound envelope and reports success. +func (r *recordingReplyEdge) Send(_ context.Context, in *mailboxpb.SendRequest, + _ ...grpc.CallOption) (*mailboxpb.SendResponse, error) { + + r.sent = append(r.sent, in.Envelope) + + return &mailboxpb.SendResponse{ + Status: &mailboxpb.Status{ + Ok: true, + }, + }, nil +} + +// Pull is unused by these tests. +func (r *recordingReplyEdge) Pull(_ context.Context, _ *mailboxpb.PullRequest, + _ ...grpc.CallOption) (*mailboxpb.PullResponse, error) { + + return &mailboxpb.PullResponse{ + Status: &mailboxpb.Status{ + Ok: true, + }, + }, nil +} + +// AckUpTo is unused by these tests. +func (r *recordingReplyEdge) AckUpTo(_ context.Context, + _ *mailboxpb.AckUpToRequest, _ ...grpc.CallOption) ( + *mailboxpb.AckUpToResponse, error) { + + return &mailboxpb.AckUpToResponse{ + Status: &mailboxpb.Status{ + Ok: true, + }, + }, nil +} + +// TestHandleInboundRPCAnswersTheSender asserts an inbound request is answered +// to the mailbox it came from, whatever its Rpc.ReplyTo says. +// +// The method dispatched here is deliberately unregistered, so ServeRPC fails +// and the handler takes its error path. That is the interesting path to pin: +// it still sends a response envelope, so it still has to address one. +func TestHandleInboundRPCAnswersTheSender(t *testing.T) { + t.Parallel() + + const ( + operatorMailboxID = "operator-1" + otherMailboxID = "somebody-else" + ) + + tests := []struct { + name string + replyTo string + }{{ + name: "matching reply-to", + replyTo: operatorMailboxID, + }, { + // Previously produced an empty Recipient, which the mailbox + // store rejects, so the caller never got its answer. + name: "absent reply-to", + replyTo: "", + }, { + name: "reply-to naming another mailbox", + replyTo: otherMailboxID, + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + edge := &recordingReplyEdge{} + s := newCompatTestServer(t, edge) + s.mailboxMux = mailboxrpc.NewServeMux() + + env := &mailboxpb.Envelope{ + Sender: operatorMailboxID, + Body: &anypb.Any{}, + Rpc: &mailboxpb.RpcMeta{ + Kind: mailboxpb. + RpcMeta_KIND_REQUEST, + Service: "svc.Unregistered", + Method: "Method", + CorrelationId: "corr-1", + ReplyTo: tc.replyTo, + }, + } + + err := s.handleInboundRPC(t.Context(), edge, env) + require.NoError(t, err) + + require.Len(t, edge.sent, 1) + require.Equal( + t, operatorMailboxID, edge.sent[0].Recipient, + ) + require.NotEqual( + t, otherMailboxID, edge.sent[0].Recipient, + ) + }) + } +} From fa52ed88d69efc3eb0cd651edde50b545bb81de9 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 6 Aug 2026 12:44:59 -0700 Subject: [PATCH 2/4] waved: Address review notes on the response-sender change Three follow-ups from review, none behavioural. Say in the comment that Rpc.ReplyTo is advisory from the responder's point of view. Producers still set it and nothing on this path reads it, which is worth stating so a future reader does not assume it is still honoured. Mirror the change in serverconn's testServer double, which still modelled the old behaviour. Sender and ReplyTo are equal there, so it kept working either way; matching the production handler keeps the double honest if they ever diverge. Assert the response Sender as well. newCompatTestServer left localMailboxID unset, so the test only ever pinned one half of the envelope. --- serverconn/e2e_test.go | 11 ++++++++--- waved/server.go | 4 ++++ waved/server_reply_to_test.go | 8 ++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/serverconn/e2e_test.go b/serverconn/e2e_test.go index 5a1a411e5..897a6589e 100644 --- a/serverconn/e2e_test.go +++ b/serverconn/e2e_test.go @@ -205,9 +205,14 @@ func (s *testServer) handleRequest(ctx context.Context, ProtocolVersion: 1, ArkProtocolVersion: 1, Sender: s.serverMailboxID, - Recipient: env.Rpc.ReplyTo, - Headers: headers, - Body: body, + + // Mirror the production responder (waved's handleInboundRPC), + // which answers the envelope sender rather than the request's + // ReplyTo. The two are equal here, so this changes nothing + // today -- it keeps the double honest if they ever diverge. + Recipient: env.Sender, + Headers: headers, + Body: body, Rpc: &mailboxpb.RpcMeta{ Kind: mailboxpb.RpcMeta_KIND_RESPONSE, CorrelationId: env.Rpc.CorrelationId, diff --git a/waved/server.go b/waved/server.go index ea4bbeb18..ce6f02d7e 100644 --- a/waved/server.go +++ b/waved/server.go @@ -3621,6 +3621,10 @@ func (s *Server) handleInboundRPC(ctx context.Context, // It also fixes an absent ReplyTo. That used to produce an // empty recipient, which the mailbox store rejects outright, // so the caller lost its answer entirely. + // + // Rpc.ReplyTo is therefore advisory from the responder's + // point of view: producers still set it, and nothing here + // reads it. Recipient: env.Sender, Headers: headers, Body: body, diff --git a/waved/server_reply_to_test.go b/waved/server_reply_to_test.go index 3dbbd99cc..1a10879f1 100644 --- a/waved/server_reply_to_test.go +++ b/waved/server_reply_to_test.go @@ -65,6 +65,7 @@ func TestHandleInboundRPCAnswersTheSender(t *testing.T) { const ( operatorMailboxID = "operator-1" otherMailboxID = "somebody-else" + daemonMailboxID = "this-daemon" ) tests := []struct { @@ -90,6 +91,7 @@ func TestHandleInboundRPCAnswersTheSender(t *testing.T) { edge := &recordingReplyEdge{} s := newCompatTestServer(t, edge) s.mailboxMux = mailboxrpc.NewServeMux() + s.localMailboxID = daemonMailboxID env := &mailboxpb.Envelope{ Sender: operatorMailboxID, @@ -114,6 +116,12 @@ func TestHandleInboundRPCAnswersTheSender(t *testing.T) { require.NotEqual( t, otherMailboxID, edge.sent[0].Recipient, ) + + // The other half of the envelope: the response is from + // us, whoever it is addressed to. + require.Equal( + t, daemonMailboxID, edge.sent[0].Sender, + ) }) } } From a1e504f6b80d621661f31db172837dca1ca6f1f4 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 11 Aug 2026 13:33:32 -0700 Subject: [PATCH 3/4] multi: Surface mailbox send rejections when answering RPCs The daemon's inbound-RPC responder was the one Edge.Send call site in the repo that discarded SendResponse.Status. That status is the mailbox's application-level failure channel: a rejected recipient or a version mismatch arrives as Status.Ok=false, never as a transport error. Discarding it meant handleInboundRPC returned nil for a response that was never delivered, the ingress loop acked the request as dispatched, and the caller blocked on its correlation ID until it timed out, with nothing logged on either side. Fold the status through the same check its three siblings already use. edgeResponseError is unexported, so add a small exported wrapper for the send case rather than re-implementing the check in waved. Also guard an empty envelope Sender. Sender is now the sole input deciding where an answer goes, and nothing upstream asserts it: the ingress validation only compares the two version fields. An empty one therefore produces the same empty recipient the absent-ReplyTo case used to, so refuse it alongside the existing nil-Rpc and nil-Body checks and let the ingress loop log the dispatch error. --- serverconn/compatibility.go | 11 ++++ waved/server.go | 23 +++++++-- waved/server_reply_to_test.go | 94 ++++++++++++++++++++++++++++++++--- 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/serverconn/compatibility.go b/serverconn/compatibility.go index 1e96a2c0a..230976ccd 100644 --- a/serverconn/compatibility.go +++ b/serverconn/compatibility.go @@ -51,6 +51,17 @@ func edgeResponseError[T edgeStatusCarrier](op string, resp T, return nil } +// SendResponseError exposes edgeResponseError for the one Edge.Send call site +// that lives outside this package (waved answers inbound mailbox RPCs on its +// own edge client). Without it that path would have to re-implement the +// status check, and a send whose rejection arrives as Status.Ok=false rather +// than as a transport error would read as a success. +func SendResponseError(op string, resp *mailboxpb.SendResponse, + err error) error { + + return edgeResponseError(op, resp, err) +} + // compatibilityError returns the cached permanent version error if the // connector has transitioned to the terminal INCOMPATIBLE state, or nil while // it is still COMPATIBLE. Send paths consult this before contacting the edge. diff --git a/waved/server.go b/waved/server.go index ce6f02d7e..ee13bc0f8 100644 --- a/waved/server.go +++ b/waved/server.go @@ -3567,6 +3567,16 @@ func (s *Server) handleInboundRPC(ctx context.Context, return fmt.Errorf("missing envelope body") } + // The sender is where the answer goes, so an absent one is the same + // lost-response failure an absent ReplyTo used to cause: the recipient + // would be empty and the mailbox store rejects that outright. Nothing + // upstream asserts it — the ingress version check only compares the + // two version fields — so refuse here, where the value is used, and + // let the ingress loop log the dispatch error. + if env.Sender == "" { + return fmt.Errorf("missing envelope sender") + } + // Dispatch through the mux to the registered handler. respMsg, err := s.mailboxMux.ServeRPC( ctx, env.Rpc.Service, env.Rpc.Method, env.Body.Value, @@ -3641,11 +3651,18 @@ func (s *Server) handleInboundRPC(ctx context.Context, // client's negotiated mailbox transport and Ark protocol versions. s.runtime.StampEnvelope(responseEnv) - _, err = edge.Send(ctx, &mailboxpb.SendRequest{ + // Fold the response status in alongside the transport error: a mailbox + // rejection (an unknown recipient, a version mismatch) travels as + // Status.Ok=false, not as an error, so discarding it would report a + // dropped answer as a successful dispatch and leave the caller waiting + // on its correlation ID until it times out. + resp, err := edge.Send(ctx, &mailboxpb.SendRequest{ Envelope: responseEnv, }) - if err != nil { - return fmt.Errorf("send RPC response: %w", err) + if sErr := serverconn.SendResponseError( + "send rpc response", resp, err, + ); sErr != nil { + return sErr } return nil diff --git a/waved/server_reply_to_test.go b/waved/server_reply_to_test.go index 1a10879f1..835614fc6 100644 --- a/waved/server_reply_to_test.go +++ b/waved/server_reply_to_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + mailboxconn "github.com/lightninglabs/wavelength/mailbox/conn" mailboxpb "github.com/lightninglabs/wavelength/mailbox/pb" mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc" "github.com/stretchr/testify/require" @@ -12,21 +13,31 @@ import ( ) // recordingReplyEdge captures the envelopes handleInboundRPC sends so a test -// can assert where a response was addressed. +// can assert where a response was addressed. When sendStatus is set it is +// returned verbatim, which lets a test drive the application-level rejection +// path that arrives as a non-OK status rather than as a transport error. type recordingReplyEdge struct { sent []*mailboxpb.Envelope + + sendStatus *mailboxpb.Status } -// Send records the outbound envelope and reports success. +// Send records the outbound envelope and reports the configured status, +// defaulting to success. func (r *recordingReplyEdge) Send(_ context.Context, in *mailboxpb.SendRequest, _ ...grpc.CallOption) (*mailboxpb.SendResponse, error) { r.sent = append(r.sent, in.Envelope) - return &mailboxpb.SendResponse{ - Status: &mailboxpb.Status{ + status := r.sendStatus + if status == nil { + status = &mailboxpb.Status{ Ok: true, - }, + } + } + + return &mailboxpb.SendResponse{ + Status: status, }, nil } @@ -70,18 +81,31 @@ func TestHandleInboundRPCAnswersTheSender(t *testing.T) { tests := []struct { name string + sender string replyTo string + wantErr string }{{ name: "matching reply-to", + sender: operatorMailboxID, replyTo: operatorMailboxID, }, { // Previously produced an empty Recipient, which the mailbox // store rejects, so the caller never got its answer. name: "absent reply-to", + sender: operatorMailboxID, replyTo: "", }, { name: "reply-to naming another mailbox", + sender: operatorMailboxID, replyTo: otherMailboxID, + }, { + // Sender is now the sole input deciding where the answer goes, + // so an empty one reproduces the very failure the ReplyTo fix + // removed. It must be refused before anything is sent. + name: "absent sender", + sender: "", + replyTo: operatorMailboxID, + wantErr: "missing envelope sender", }} for _, tc := range tests { @@ -94,7 +118,7 @@ func TestHandleInboundRPCAnswersTheSender(t *testing.T) { s.localMailboxID = daemonMailboxID env := &mailboxpb.Envelope{ - Sender: operatorMailboxID, + Sender: tc.sender, Body: &anypb.Any{}, Rpc: &mailboxpb.RpcMeta{ Kind: mailboxpb. @@ -107,6 +131,17 @@ func TestHandleInboundRPCAnswersTheSender(t *testing.T) { } err := s.handleInboundRPC(t.Context(), edge, env) + + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + + // A refused envelope must not put an + // unaddressable response on the wire. + require.Empty(t, edge.sent) + + return + } + require.NoError(t, err) require.Len(t, edge.sent, 1) @@ -125,3 +160,50 @@ func TestHandleInboundRPCAnswersTheSender(t *testing.T) { }) } } + +// TestHandleInboundRPCReportsSendRejection asserts a mailbox rejection that +// arrives as a non-OK SendResponse.Status is reported as an error rather than +// swallowed. That status is the canonical application-level failure channel +// for the mailbox edge, so discarding it would report a lost answer as a +// successful dispatch and leave the caller blocked until its own deadline. +func TestHandleInboundRPCReportsSendRejection(t *testing.T) { + t.Parallel() + + const rejectCode = "UNKNOWN_RECIPIENT" + + edge := &recordingReplyEdge{ + sendStatus: &mailboxpb.Status{ + Ok: false, + Code: rejectCode, + Message: "no such mailbox", + }, + } + + s := newCompatTestServer(t, edge) + s.mailboxMux = mailboxrpc.NewServeMux() + s.localMailboxID = "this-daemon" + + env := &mailboxpb.Envelope{ + Sender: "operator-1", + Body: &anypb.Any{}, + Rpc: &mailboxpb.RpcMeta{ + Kind: mailboxpb.RpcMeta_KIND_REQUEST, + Service: "svc.Unregistered", + Method: "Method", + CorrelationId: "corr-1", + ReplyTo: "operator-1", + }, + } + + err := s.handleInboundRPC(t.Context(), edge, env) + require.Error(t, err) + + // The structured status must survive, not be flattened into a string, + // so callers can classify a permanent version failure. + var statusErr *mailboxconn.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, rejectCode, statusErr.Code()) + + // The send was attempted; only its outcome was misreported before. + require.Len(t, edge.sent, 1) +} From 6abaafe95a12c730436e4e894dca9f77c43a57a7 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 11 Aug 2026 15:39:51 -0700 Subject: [PATCH 4/4] multi: Document the send-rejection and sender guards The doc-gardening advisory on this PR is right: the Go change left both packages' docs stale. serverconn gains SendResponseError, the exported seam that lets a caller outside the package fold an application-level rejection through the same check the package uses internally -- a Status.Ok of false is this codebase's failure channel and never arrives as a transport error, so a caller that only checks err reports success on a rejected send. waved records that an inbound RPC is answered to env.Sender and that the field is now guarded, since it is the sole input deciding where the answer goes and nothing on the ingress path asserts it is set. --- serverconn/AGENTS.md | 14 ++++++++++++++ serverconn/CLAUDE.md | 14 ++++++++++++++ waved/AGENTS.md | 18 ++++++++++++++++++ waved/CLAUDE.md | 18 ++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/serverconn/AGENTS.md b/serverconn/AGENTS.md index a88da6d0f..433d8be7e 100644 --- a/serverconn/AGENTS.md +++ b/serverconn/AGENTS.md @@ -31,6 +31,14 @@ background ingress polling with event routing. - `NewAuthenticatedMailboxClient` — `mailboxpb.MailboxServiceClient` decorator that signs and attaches the `x-mailbox-auth-sig` header to every `Send` before forwarding to the wrapped edge transport. +- `SendResponseError` — Exported wrapper over the package-internal + `edgeResponseError` (`compatibility.go`) for the one `Edge.Send` call site + outside this package: `waved` answering an inbound mailbox RPC on its own + edge client. Maps a `(*mailboxpb.SendResponse, error)` pair to the single + error the caller should return — transport error wrapped with `op`, nil + response reported as a transport error, non-OK `Status` converted to a + `*mailboxconn.StatusError` — or nil when the send was accepted. It does not + drive the incompatibility transition; callers still pass the error on. - `AckState` — Four-cursor watermark state machine (PullCursor, DispatchCommittedTo, AckTarget, AckCommittedTo). - `SendUnaryRequest` — Durable typed unary request that becomes a real unary RPC after commit. The response arrives via KIND_RESPONSE and, if no in-memory waiter exists, falls back to durable route dispatch via the EventRouter. - `DurableUnaryRequestBuilder` — Interface for proof-gated request-body construction. Implementations build the actual proto request (e.g., with signed proofs) at send time, not at persist time. The interface is provided via `ConnectorConfig.DurableUnaryBuilder`. @@ -84,6 +92,12 @@ background ingress polling with event routing. the logical request (see `mailboxrpc.Retry`). `SendRPC` mints a fresh key only when the caller leaves `RPCOptions.IdempotencyKey` empty, which is correct for a single-shot call and defeats deduplication for a retry. +- A mailbox rejection (unknown recipient, empty recipient, version mismatch) + travels back as `SendResponse.Status.Ok = false`, **not** as a gRPC error, + so no `Edge.Send` caller may discard the response and check only `err` — + that reports a dropped envelope as a successful dispatch. Inside the + package every path routes through `edgeResponseError`; outside it, use the + exported `SendResponseError`. - Ingress loop checkpoints pull cursor and ack state; on restart, resumes from checkpoint. - `DurableUnaryQuery` values are handled generically in `ServerConnectionActor.Receive` via `buildDurableUnary`: the query is converted to a `SendUnaryRequest` using the configured `DurableUnaryRequestBuilder`. Adding a new durable indexer query type requires only implementing `DurableUnaryQuery` — no new `Receive` case is needed. - `DurableUnaryQuery` implementations must produce stable identity bytes in `BuildBody` so that `MsgID` and `IdempotencyKey` are deterministic across restarts (auto-derived via `mailboxconn.StableEventMsgID` / `StableEventIdempotencyKey` when the caller leaves them empty). diff --git a/serverconn/CLAUDE.md b/serverconn/CLAUDE.md index a88da6d0f..433d8be7e 100644 --- a/serverconn/CLAUDE.md +++ b/serverconn/CLAUDE.md @@ -31,6 +31,14 @@ background ingress polling with event routing. - `NewAuthenticatedMailboxClient` — `mailboxpb.MailboxServiceClient` decorator that signs and attaches the `x-mailbox-auth-sig` header to every `Send` before forwarding to the wrapped edge transport. +- `SendResponseError` — Exported wrapper over the package-internal + `edgeResponseError` (`compatibility.go`) for the one `Edge.Send` call site + outside this package: `waved` answering an inbound mailbox RPC on its own + edge client. Maps a `(*mailboxpb.SendResponse, error)` pair to the single + error the caller should return — transport error wrapped with `op`, nil + response reported as a transport error, non-OK `Status` converted to a + `*mailboxconn.StatusError` — or nil when the send was accepted. It does not + drive the incompatibility transition; callers still pass the error on. - `AckState` — Four-cursor watermark state machine (PullCursor, DispatchCommittedTo, AckTarget, AckCommittedTo). - `SendUnaryRequest` — Durable typed unary request that becomes a real unary RPC after commit. The response arrives via KIND_RESPONSE and, if no in-memory waiter exists, falls back to durable route dispatch via the EventRouter. - `DurableUnaryRequestBuilder` — Interface for proof-gated request-body construction. Implementations build the actual proto request (e.g., with signed proofs) at send time, not at persist time. The interface is provided via `ConnectorConfig.DurableUnaryBuilder`. @@ -84,6 +92,12 @@ background ingress polling with event routing. the logical request (see `mailboxrpc.Retry`). `SendRPC` mints a fresh key only when the caller leaves `RPCOptions.IdempotencyKey` empty, which is correct for a single-shot call and defeats deduplication for a retry. +- A mailbox rejection (unknown recipient, empty recipient, version mismatch) + travels back as `SendResponse.Status.Ok = false`, **not** as a gRPC error, + so no `Edge.Send` caller may discard the response and check only `err` — + that reports a dropped envelope as a successful dispatch. Inside the + package every path routes through `edgeResponseError`; outside it, use the + exported `SendResponseError`. - Ingress loop checkpoints pull cursor and ack state; on restart, resumes from checkpoint. - `DurableUnaryQuery` values are handled generically in `ServerConnectionActor.Receive` via `buildDurableUnary`: the query is converted to a `SendUnaryRequest` using the configured `DurableUnaryRequestBuilder`. Adding a new durable indexer query type requires only implementing `DurableUnaryQuery` — no new `Receive` case is needed. - `DurableUnaryQuery` implementations must produce stable identity bytes in `BuildBody` so that `MsgID` and `IdempotencyKey` are deterministic across restarts (auto-derived via `mailboxconn.StableEventMsgID` / `StableEventIdempotencyKey` when the caller leaves them empty). diff --git a/waved/AGENTS.md b/waved/AGENTS.md index b778f21cb..f5aa65306 100644 --- a/waved/AGENTS.md +++ b/waved/AGENTS.md @@ -136,6 +136,24 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/waved.