fix(server): repair the initial-connect outbox envelope and unblock dispatch

ApplyInitialConnectPlan wrote a payload of {match_id,state,action},
omitting event, revision, resource_id, occurred_at and player_ids --
every field deliverStateOutboxEvent requires. Delivery rejected the row,
dispatch returned on the first error so it was never acknowledged, and
because reads are ordered oldest-first it was retried ahead of every
later state_changed event on every 100ms poll. One initial-connect
transition therefore blocked lifecycle delivery for all matches, not
just its own.

Two independent fixes, since either alone leaves the system fragile:

Build envelopes through one validating helper (MarshalOutboxEnvelope)
and convert all five writers to it. A writer that omits a required
field now fails its own transaction instead of committing a row that
can only ever poison the queue. The helper takes revision as int64 so
the -1 "nothing matched" sentinel some CTEs return surfaces as an error
rather than wrapping to a huge uint64.

Make dispatch resilient regardless: a delivery failure is now counted
against that row and the batch continues, with the row dead-lettered
after MaxOutboxDeliveryAttempts so a poison event degrades to one lost
notification instead of a stalled queue. Ordering within an aggregate
is still honoured -- later events of a failed match are deferred, so no
client observes that match's newer state before its older state. An ack
failure still stops the batch, being a database rather than a payload
problem.

Initial-connect events now address every participant, not just the
connected ones: a no-show needs to learn their ticket was failed and a
penalty applied.
This commit is contained in:
Josh Creek
2026-09-05 10:14:43 +01:00
parent 2c648514ba
commit 1dd05c75f1
12 changed files with 379 additions and 31 deletions
+33 -6
View File
@@ -35,7 +35,7 @@ func RunProposalOutboxDispatcher(ctx context.Context, db *sql.DB, service *Servi
if err != nil {
continue
}
_ = dispatchOutboxEvents(ctx, dispatcher, events)
_ = dispatchOutboxEvents(ctx, db, dispatcher, events)
}
}
}
@@ -61,7 +61,7 @@ func RunResultOutboxDispatcher(ctx context.Context, db *sql.DB, service *Service
if err != nil {
continue
}
_ = dispatchOutboxEvents(ctx, dispatcher, events)
_ = dispatchOutboxEvents(ctx, db, dispatcher, events)
}
}
}
@@ -87,29 +87,56 @@ func RunStateOutboxDispatcher(ctx context.Context, db *sql.DB, service *Service)
if err != nil {
continue
}
_ = dispatchOutboxEvents(ctx, dispatcher, events)
_ = dispatchOutboxEvents(ctx, db, dispatcher, events)
}
}
}
func dispatchOutboxEvents(ctx context.Context, dispatcher *store.OutboxDispatcher, events []store.OutboxEvent) error {
func dispatchOutboxEvents(ctx context.Context, db *sql.DB, dispatcher *store.OutboxDispatcher, events []store.OutboxEvent) error {
if len(events) == 0 {
return nil
}
// Use the same delivery-before-ack contract as the general dispatcher,
// while keeping the already-filtered batch from being read a second time.
//
// A delivery failure does not abort the batch. Returning here meant one
// undeliverable payload -- reads are oldest-first -- was retried ahead of
// every later event of its type on every poll, forever. Instead the failure
// is counted against that row (dead-lettering it once exhausted) and the
// batch continues.
//
// Ordering within one aggregate is still honoured: once an event for a
// match fails, its later events are left for a subsequent poll so a client
// can never observe that match's newer state before its older state. Other
// aggregates are independent and proceed.
blocked := make(map[string]struct{})
var firstErr error
for _, event := range events {
if event.EventID == "" {
return fmt.Errorf("outbox event has no ID")
}
if _, skip := blocked[event.AggregateID]; skip {
continue
}
if err := dispatcher.Deliver(ctx, event); err != nil {
return err
blocked[event.AggregateID] = struct{}{}
if firstErr == nil {
firstErr = err
}
if db != nil {
if _, failErr := store.RecordOutboxDeliveryFailure(ctx, db, event.EventID, err, time.Now().UTC()); failErr != nil {
return failErr
}
}
continue
}
if err := dispatcher.Ack(ctx, event.EventID, time.Now().UTC()); err != nil {
// An ack failure is a database problem, not a payload problem;
// stop rather than counting it against the event.
return err
}
}
return nil
return firstErr
}
func deliverProposalOutboxEvent(_ context.Context, event store.OutboxEvent, service *Service) error {
+52
View File
@@ -3,6 +3,8 @@ package api
import (
"context"
"encoding/json"
"errors"
"slices"
"testing"
"time"
@@ -103,3 +105,53 @@ func TestDeliverStateOutboxEventRoutesLiveAbandonmentLifecycle(t *testing.T) {
}
}
}
// One malformed row used to abort the whole batch. Because reads are
// oldest-first and the row was never acknowledged, it was re-read ahead of
// every later event of its type on every 100ms poll -- blocking lifecycle
// delivery for all matches indefinitely, not just its own.
func TestDispatchOutboxEventsIsNotBlockedByOnePoisonRow(t *testing.T) {
delivered := []string{}
acked := []string{}
dispatcher := &store.OutboxDispatcher{
Read: func(context.Context, int) ([]store.OutboxEvent, error) { return nil, nil },
Deliver: func(_ context.Context, event store.OutboxEvent) error {
delivered = append(delivered, event.EventID)
if event.AggregateID == "match-poison" {
return errors.New("invalid state outbox payload")
}
return nil
},
Ack: func(_ context.Context, eventID string, _ time.Time) error {
acked = append(acked, eventID)
return nil
},
}
events := []store.OutboxEvent{
{EventID: "poison-1", AggregateID: "match-poison"},
{EventID: "healthy-1", AggregateID: "match-healthy"},
{EventID: "poison-2", AggregateID: "match-poison"},
{EventID: "healthy-2", AggregateID: "match-other"},
}
// nil db: the failure counter is exercised against a real PostgreSQL in
// the store integration tests; here we assert only batch progress.
err := dispatchOutboxEvents(context.Background(), nil, dispatcher, events)
if err == nil {
t.Fatal("expected the delivery failure to be reported to the caller")
}
for _, eventID := range []string{"healthy-1", "healthy-2"} {
if !slices.Contains(acked, eventID) {
t.Fatalf("%s was not acknowledged; a poison row still blocks the batch (acked=%v)", eventID, acked)
}
}
if slices.Contains(acked, "poison-1") {
t.Fatal("a failed delivery must not be acknowledged")
}
// Ordering within the failing aggregate is preserved: poison-2 must wait
// so no client sees that match's newer state before its older state.
if slices.Contains(delivered, "poison-2") {
t.Fatalf("later event of a failed aggregate was delivered out of order: %v", delivered)
}
}
@@ -0,0 +1,15 @@
ALTER TABLE outbox
ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN last_delivery_error TEXT,
ADD COLUMN dead_lettered_at TIMESTAMPTZ;
-- The unpublished dispatchers read oldest-first and previously stopped on the
-- first delivery error, so one permanently malformed payload blocked every
-- later event of that type forever. Dead-lettered rows leave the working set
-- via this partial index so a poison row degrades to one lost event instead of
-- a stalled queue.
DROP INDEX IF EXISTS outbox_unpublished_order;
CREATE INDEX outbox_unpublished_order
ON outbox (created_at, event_id)
WHERE published_at IS NULL AND dead_lettered_at IS NULL;
@@ -0,0 +1,10 @@
DROP INDEX IF EXISTS outbox_unpublished_order;
CREATE INDEX outbox_unpublished_order
ON outbox (created_at, event_id)
WHERE published_at IS NULL;
ALTER TABLE outbox
DROP COLUMN IF EXISTS delivery_attempts,
DROP COLUMN IF EXISTS last_delivery_error,
DROP COLUMN IF EXISTS dead_lettered_at;
+2 -9
View File
@@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"time"
@@ -156,10 +155,7 @@ func AdvanceServerRegistration(ctx context.Context, db *sql.DB, binding domain.W
if err := rows.Close(); err != nil {
return err
}
payload, err := json.Marshal(map[string]any{
"event": "state_changed", "revision": revision, "resource_id": binding.MatchID,
"occurred_at": now, "state": string(to), "match_id": binding.MatchID, "player_ids": playerIDs,
})
payload, err := MarshalStateChangedEnvelope(binding.MatchID, revision, string(to), now, playerIDs)
if err != nil {
return err
}
@@ -279,10 +275,7 @@ func BindAllocatedMatch(ctx context.Context, db *sql.DB, allocation domain.Alloc
if err := rows.Close(); err != nil {
return err
}
payload, err := json.Marshal(map[string]any{
"event": "state_changed", "revision": revision, "resource_id": allocation.MatchID,
"occurred_at": allocation.AllocatedAt, "state": string(domain.Allocating), "match_id": allocation.MatchID, "player_ids": playerIDs,
})
payload, err := MarshalStateChangedEnvelope(allocation.MatchID, revision, string(domain.Allocating), allocation.AllocatedAt, playerIDs)
if err != nil {
return err
}
+15 -1
View File
@@ -156,7 +156,21 @@ func ApplyInitialConnectPlan(ctx context.Context, db *sql.DB, matchID, idempoten
if err := tx.QueryRowContext(ctx, initialConnectMatchUpdateSQL, matchID, string(plan.MatchState)).Scan(&finalRevision); err != nil {
return err
}
payload, _ := json.Marshal(map[string]any{"match_id": matchID, "state": plan.MatchState, "action": plan.Action})
// Every participant is told, not just the connected ones: a no-show
// needs to learn their ticket was marked NO_SHOW and a penalty applied.
// Publishing to a player with no live subscriber is a no-op.
recipients := make([]string, 0, len(participants))
for _, participant := range participants {
recipients = append(recipients, participant.PlayerID)
}
payload, err := MarshalOutboxEnvelope(OutboxEnvelope{
Event: "state_changed", ResourceID: matchID, Revision: finalRevision,
OccurredAt: now, State: string(plan.MatchState), MatchID: matchID,
PlayerIDs: recipients, Extra: map[string]any{"action": plan.Action},
})
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, initialConnectOutboxSQL, "initial-connect:"+matchID+fmt.Sprintf(":%d", finalRevision), matchID, finalRevision, payload); err != nil {
return err
}
+4 -5
View File
@@ -3,7 +3,6 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
@@ -160,10 +159,10 @@ func ApplyLiveAbandonments(ctx context.Context, db *sql.DB, matchID string, now
if len(targets) == 0 {
return fmt.Errorf("%w: live match has no active event targets", domain.ErrConflict)
}
payload, err := json.Marshal(map[string]any{
"event": "state_changed", "revision": revision, "resource_id": matchID,
"occurred_at": now, "state": domain.Live, "match_id": matchID,
"player_ids": targets, "abandoned_player_ids": abandonmentIDs(planned),
payload, err := MarshalOutboxEnvelope(OutboxEnvelope{
Event: "state_changed", ResourceID: matchID, Revision: int64(revision),
OccurredAt: now, State: string(domain.Live), MatchID: matchID, PlayerIDs: targets,
Extra: map[string]any{"abandoned_player_ids": abandonmentIDs(planned)},
})
if err != nil {
return err
+61 -4
View File
@@ -25,28 +25,28 @@ type OutboxEvent struct {
const OutboxUnpublishedSelectSQL = `SELECT event_id, aggregate_type, aggregate_id, revision,
event_type, payload, created_at, published_at
FROM outbox
WHERE published_at IS NULL
WHERE published_at IS NULL AND dead_lettered_at IS NULL
ORDER BY created_at, event_id
LIMIT $1`
const OutboxUnpublishedProposalSelectSQL = `SELECT event_id, aggregate_type, aggregate_id, revision,
event_type, payload, created_at, published_at
FROM outbox
WHERE published_at IS NULL AND event_type = 'proposal_changed'
WHERE published_at IS NULL AND dead_lettered_at IS NULL AND event_type = 'proposal_changed'
ORDER BY created_at, event_id
LIMIT $1`
const OutboxUnpublishedResultSelectSQL = `SELECT event_id, aggregate_type, aggregate_id, revision,
event_type, payload, created_at, published_at
FROM outbox
WHERE published_at IS NULL AND event_type = 'match_completed'
WHERE published_at IS NULL AND dead_lettered_at IS NULL AND event_type = 'match_completed'
ORDER BY created_at, event_id
LIMIT $1`
const OutboxUnpublishedStateSelectSQL = `SELECT event_id, aggregate_type, aggregate_id, revision,
event_type, payload, created_at, published_at
FROM outbox
WHERE published_at IS NULL AND event_type = 'state_changed'
WHERE published_at IS NULL AND dead_lettered_at IS NULL AND event_type = 'state_changed'
ORDER BY created_at, event_id
LIMIT $1`
@@ -59,8 +59,65 @@ const OutboxMarkPublishedSQL = `UPDATE outbox
SET published_at = $2
WHERE event_id = $1 AND published_at IS NULL`
// MaxOutboxDeliveryAttempts bounds how long one undeliverable row may hold up
// its event type. It must exceed any plausible transient outage of the local
// fan-out adapter, since a healthy event is retried through the same counter.
const MaxOutboxDeliveryAttempts = 20
// OutboxRecordFailureSQL increments the attempt counter and dead-letters the
// row once it is exhausted, in one statement so a crash between the two cannot
// leave a row that is retried forever.
const OutboxRecordFailureSQL = `UPDATE outbox
SET delivery_attempts = delivery_attempts + 1,
last_delivery_error = $2,
dead_lettered_at = CASE WHEN delivery_attempts + 1 >= $3 THEN $4 ELSE dead_lettered_at END
WHERE event_id = $1 AND published_at IS NULL AND dead_lettered_at IS NULL
RETURNING dead_lettered_at IS NOT NULL`
const OutboxDeadLetteredCountSQL = `SELECT count(*) FROM outbox WHERE dead_lettered_at IS NOT NULL`
var ErrOutboxEventNotFound = fmt.Errorf("outbox event not found or already published")
// RecordOutboxDeliveryFailure notes one failed delivery attempt and reports
// whether the row was dead-lettered as a result. Callers should keep going to
// the next event: the whole point is that one poison row must not stall the
// others.
func RecordOutboxDeliveryFailure(ctx context.Context, db *sql.DB, eventID string, cause error, now time.Time) (bool, error) {
if db == nil || eventID == "" || now.IsZero() {
return false, fmt.Errorf("invalid outbox failure arguments")
}
message := ""
if cause != nil {
message = cause.Error()
}
if len(message) > 500 {
message = message[:500]
}
var deadLettered bool
err := db.QueryRowContext(ctx, OutboxRecordFailureSQL, eventID, message, MaxOutboxDeliveryAttempts, now).Scan(&deadLettered)
if err == sql.ErrNoRows {
// Published or already dead-lettered by another replica; nothing owed.
return false, nil
}
if err != nil {
return false, err
}
return deadLettered, nil
}
// CountDeadLetteredOutboxEvents backs the deletion-lag/poison-row metric. A
// non-zero value means at least one lifecycle event was never delivered.
func CountDeadLetteredOutboxEvents(ctx context.Context, db *sql.DB) (int64, error) {
if db == nil {
return 0, fmt.Errorf("invalid outbox count arguments")
}
var count int64
if err := db.QueryRowContext(ctx, OutboxDeadLetteredCountSQL).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
type OutboxDelivery func(context.Context, OutboxEvent) error
// OutboxDispatcher is the durable-to-transient bridge. Read and Ack are
+91
View File
@@ -0,0 +1,91 @@
package store
import (
"encoding/json"
"fmt"
"time"
)
// OutboxEnvelope is the payload shape the api package's outbox dispatchers
// decode. Every lifecycle writer used to inline its own map literal, and one of
// them (ApplyInitialConnectPlan) omitted event/resource_id/occurred_at/
// player_ids entirely. Because delivery rejects a malformed row and the
// dispatcher reads oldest-first, that single row blocked every later
// state_changed event indefinitely. Constructing envelopes through one
// validating builder makes that failure impossible to reintroduce: a writer
// that forgets a required field now fails its own transaction instead of
// silently poisoning the queue.
type OutboxEnvelope struct {
Event string
ResourceID string
// Revision is int64 to match the BIGINT column and, more importantly, so
// the -1 "nothing matched" sentinel some CTEs return surfaces as an error
// here instead of wrapping to a huge uint64 in the payload.
Revision int64
OccurredAt time.Time
State string
// MatchID is omitted when empty, matching the proposal_changed shape which
// carries no match.
MatchID string
// PlayerIDs is the authoritative recipient list. Delivery rejects an empty
// one, so a writer must resolve participants before building the envelope.
PlayerIDs []string
// Extra carries event-specific keys (for example abandoned_player_ids). It
// may not overwrite a reserved key.
Extra map[string]any
}
var reservedEnvelopeKeys = map[string]struct{}{
"event": {}, "resource_id": {}, "revision": {}, "occurred_at": {},
"state": {}, "match_id": {}, "player_ids": {},
}
// MarshalOutboxEnvelope validates and encodes one envelope. The checks mirror
// exactly what api.deliverStateOutboxEvent and api.deliverProposalOutboxEvent
// require, so anything this accepts is deliverable.
func MarshalOutboxEnvelope(envelope OutboxEnvelope) ([]byte, error) {
if envelope.Event == "" || envelope.ResourceID == "" || envelope.State == "" || envelope.OccurredAt.IsZero() {
return nil, fmt.Errorf("invalid outbox envelope: missing event, resource, state or timestamp")
}
if envelope.Revision < 0 {
return nil, fmt.Errorf("invalid outbox envelope: negative revision for %s %s", envelope.Event, envelope.ResourceID)
}
if len(envelope.PlayerIDs) == 0 {
return nil, fmt.Errorf("invalid outbox envelope: no recipients for %s %s", envelope.Event, envelope.ResourceID)
}
seen := make(map[string]struct{}, len(envelope.PlayerIDs))
for _, playerID := range envelope.PlayerIDs {
if playerID == "" {
return nil, fmt.Errorf("invalid outbox envelope: empty participant")
}
if _, exists := seen[playerID]; exists {
return nil, fmt.Errorf("invalid outbox envelope: duplicate participant %s", playerID)
}
seen[playerID] = struct{}{}
}
payload := map[string]any{
"event": envelope.Event, "resource_id": envelope.ResourceID,
"revision": envelope.Revision, "occurred_at": envelope.OccurredAt,
"state": envelope.State, "player_ids": envelope.PlayerIDs,
}
if envelope.MatchID != "" {
payload["match_id"] = envelope.MatchID
}
for key, value := range envelope.Extra {
if _, reserved := reservedEnvelopeKeys[key]; reserved {
return nil, fmt.Errorf("invalid outbox envelope: %q is reserved", key)
}
payload[key] = value
}
return json.Marshal(payload)
}
// MarshalStateChangedEnvelope is the common case: a match lifecycle transition
// fanned out to that match's participants. The resource and match are the same
// aggregate, which is what deliverStateOutboxEvent asserts.
func MarshalStateChangedEnvelope(matchID string, revision int64, state string, occurredAt time.Time, playerIDs []string) ([]byte, error) {
return MarshalOutboxEnvelope(OutboxEnvelope{
Event: "state_changed", ResourceID: matchID, Revision: revision,
OccurredAt: occurredAt, State: state, MatchID: matchID, PlayerIDs: playerIDs,
})
}
+88
View File
@@ -0,0 +1,88 @@
package store
import (
"encoding/json"
"testing"
"time"
)
func TestMarshalStateChangedEnvelopeProducesDeliverableShape(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
payload, err := MarshalStateChangedEnvelope("match-1", 7, "LIVE", now, []string{"player-a", "player-b"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Decode with exactly the struct api.deliverStateOutboxEvent uses, then
// apply exactly its acceptance predicate. This is the regression guard for
// the initial-connect payload that omitted every one of these keys.
var envelope struct {
Event string `json:"event"`
Revision uint64 `json:"revision"`
ResourceID string `json:"resource_id"`
OccurredAt time.Time `json:"occurred_at"`
State string `json:"state"`
MatchID string `json:"match_id"`
PlayerIDs []string `json:"player_ids"`
}
if err := json.Unmarshal(payload, &envelope); err != nil {
t.Fatalf("decode: %v", err)
}
if envelope.Event != "state_changed" || envelope.ResourceID != "match-1" || envelope.Revision != 7 ||
envelope.State == "" || len(envelope.PlayerIDs) == 0 {
t.Fatalf("envelope would be rejected by the dispatcher: %+v", envelope)
}
if envelope.MatchID != "match-1" || !envelope.OccurredAt.Equal(now) {
t.Fatalf("unexpected envelope: %+v", envelope)
}
}
func TestMarshalOutboxEnvelopeRejectsUndeliverablePayloads(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
valid := OutboxEnvelope{
Event: "state_changed", ResourceID: "match-1", Revision: 1,
OccurredAt: now, State: "LIVE", MatchID: "match-1", PlayerIDs: []string{"player-a"},
}
if _, err := MarshalOutboxEnvelope(valid); err != nil {
t.Fatalf("baseline envelope must be valid: %v", err)
}
for name, mutate := range map[string]func(*OutboxEnvelope){
"no event": func(e *OutboxEnvelope) { e.Event = "" },
"no resource": func(e *OutboxEnvelope) { e.ResourceID = "" },
"no state": func(e *OutboxEnvelope) { e.State = "" },
"no timestamp": func(e *OutboxEnvelope) { e.OccurredAt = time.Time{} },
"no recipients": func(e *OutboxEnvelope) { e.PlayerIDs = nil },
"empty recipient": func(e *OutboxEnvelope) { e.PlayerIDs = []string{"player-a", ""} },
"duplicate recipient": func(e *OutboxEnvelope) { e.PlayerIDs = []string{"player-a", "player-a"} },
// -1 is the "nothing matched" sentinel several CTEs return. Untyped as
// uint64 it would become 18446744073709551615 in the payload.
"sentinel revision": func(e *OutboxEnvelope) { e.Revision = -1 },
"reserved extra": func(e *OutboxEnvelope) { e.Extra = map[string]any{"state": "CANCELLED"} },
} {
t.Run(name, func(t *testing.T) {
envelope := valid
mutate(&envelope)
if _, err := MarshalOutboxEnvelope(envelope); err == nil {
t.Fatalf("expected %s to be rejected at construction", name)
}
})
}
}
func TestMarshalOutboxEnvelopeOmitsMatchIDForProposals(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
payload, err := MarshalOutboxEnvelope(OutboxEnvelope{
Event: "proposal_changed", ResourceID: "proposal-1", Revision: 0,
OccurredAt: now, State: "OPEN", PlayerIDs: []string{"player-a"},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode: %v", err)
}
if _, present := decoded["match_id"]; present {
t.Fatalf("proposal envelope must not carry a match_id: %s", payload)
}
}
+5 -2
View File
@@ -1792,8 +1792,11 @@ func TestPostgreSQLMigrationsRollBackAndReapplyCleanly(t *testing.T) {
// Roll back every migration one at a time, in reverse, checking each
// down file actually undoes what its forward file created — not just
// that Rollback returns nil.
if err := migrations.Rollback(context.Background(), db, dir, 7); err != nil {
t.Fatalf("rollback 0013 through 0007: %v", err)
// This count is the number of migrations above 0006, so it must grow with
// every new migration; otherwise the later fixed-count rollbacks below
// silently target the wrong files.
if err := migrations.Rollback(context.Background(), db, dir, 8); err != nil {
t.Fatalf("rollback 0014 through 0007: %v", err)
}
var hasInitialConnectReadyColumn bool
if err := db.QueryRow(`SELECT count(*) > 0 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'initial_connect_ready_at'`).Scan(&hasInitialConnectReadyColumn); err != nil {
+3 -4
View File
@@ -3,7 +3,6 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
@@ -54,9 +53,9 @@ func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, t
}
players = append(players, participant.PlayerID)
}
payload, err := json.Marshal(map[string]any{
"event": "proposal_changed", "revision": uint64(0), "resource_id": proposal.ProposalID,
"occurred_at": now, "state": string(proposal.State), "player_ids": players,
payload, err := MarshalOutboxEnvelope(OutboxEnvelope{
Event: "proposal_changed", ResourceID: proposal.ProposalID, Revision: 0,
OccurredAt: now, State: string(proposal.State), PlayerIDs: players,
})
if err != nil {
return err