From 864e4e8aaf9848566d2833dc5d21396a0d6e30e4 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:33:16 +0100 Subject: [PATCH] feat: add durable result policy core --- multiplayer-todo.md | 2 +- server/domain/result.go | 159 +++++++++++++++++++++++++++++++++++ server/domain/result_test.go | 79 +++++++++++++++++ 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 server/domain/result.go create mode 100644 server/domain/result_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 5bc6fc98..3a66ddc2 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1202,7 +1202,7 @@ the local/CI/community transport, not a silent production fallback. | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season rollover compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history and is idempotent by season ID | `ApplySeasonRollover` covers compression, floor/cap and duplicate replay; PostgreSQL transaction locking and 12-week scheduler remain | | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain | -| 8.25 `[D:8.10,8.24]` | Separate result delivery delay from match-integrity failure; signed Agones-annotation spool, retries, 5 m alert/30 m review, suppression only for lost/corrupt authority or measured unfair regional incident | API outage preserves rating/result; clients cannot request exemption; node/pod/integrity faults take the documented suppression/refund path | +| 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, and exposes 5 m alert/30 m review delivery thresholds | `server/domain/result.go` and adversarial fixtures cover binding, duplicate/conflict, commit and delivery-health invariants; signed credential verification, Agones annotation spool/reconciliation, PostgreSQL atomic rating/outbox transaction and integrity-classification adapters remain | #### 8D — Agones, allocation and regional scaling diff --git a/server/domain/result.go b/server/domain/result.go new file mode 100644 index 00000000..6604682e --- /dev/null +++ b/server/domain/result.go @@ -0,0 +1,159 @@ +package domain + +import ( + "crypto/sha256" + "fmt" + "strconv" + "time" +) + +const ( + ResultDeliveryAlertAfter = 5 * time.Minute + ResultDeliveryReviewAfter = 30 * time.Minute +) + +type IntegrityState string + +const ( + IntegrityCertified IntegrityState = "CERTIFIED" + IntegritySuppressed IntegrityState = "SUPPRESSED" + IntegrityReview IntegrityState = "REVIEW" +) + +var ( + ErrResultBinding = fmt.Errorf("result workload binding rejected") + ErrResultConflict = fmt.Errorf("conflicting result") + ErrResultInvalid = fmt.Errorf("invalid match result") + ErrReceiptMissing = fmt.Errorf("result receipt not found") +) + +// WorkloadBinding is the identity extracted and validated by the secure +// credential adapter. The domain compares every binding dimension recorded by +// allocation; a shared service-account class is not sufficient on its own. +type WorkloadBinding struct { + Issuer string + Audience string + Namespace string + ServiceAcct string + PodUID string + GameServerUID string + MatchID string + ServerID string +} + +type MatchResult struct { + MatchID string + ServerID string + ResultNonce string + Team0Score int + Team1Score int + IntegrityState IntegrityState +} + +type ResultReceipt struct { + ResultID string + MatchID string + ResultNonce string + PayloadDigest [32]byte + IntegrityState IntegrityState + ReceivedAt time.Time + CommittedAt time.Time +} + +type ResultStore struct { + expected WorkloadBinding + receipts map[string]ResultReceipt +} + +func NewResultStore(expected WorkloadBinding) (*ResultStore, error) { + if err := validateBinding(expected); err != nil { + return nil, err + } + return &ResultStore{expected: expected, receipts: make(map[string]ResultReceipt)}, nil +} + +// Submit is the durable-transaction boundary in miniature. Production code +// must persist the receipt, match transition, participant penalties/ratings, +// and outbox event atomically around this same decision. +func (s *ResultStore) Submit(resultID string, result MatchResult, binding WorkloadBinding, now time.Time) (ResultReceipt, bool, error) { + if resultID == "" || !sameBinding(s.expected, binding) { + return ResultReceipt{}, false, ErrResultBinding + } + if err := validateResult(s.expected, result); err != nil { + return ResultReceipt{}, false, err + } + digest := resultDigest(result) + if prior, ok := s.receipts[result.MatchID]; ok { + if prior.ResultID == resultID && prior.PayloadDigest == digest { + return prior, false, nil + } + return prior, false, ErrResultConflict + } + receipt := ResultReceipt{ResultID: resultID, MatchID: result.MatchID, ResultNonce: result.ResultNonce, PayloadDigest: digest, IntegrityState: result.IntegrityState, ReceivedAt: now} + s.receipts[result.MatchID] = receipt + return receipt, true, nil +} + +func (s *ResultStore) Commit(resultID, matchID string, now time.Time) (ResultReceipt, error) { + receipt, ok := s.receipts[matchID] + if !ok || receipt.ResultID != resultID { + return ResultReceipt{}, ErrReceiptMissing + } + if receipt.CommittedAt.IsZero() { + receipt.CommittedAt = now + s.receipts[matchID] = receipt + } + return receipt, nil +} + +type DeliveryHealth string + +const ( + DeliveryHealthy DeliveryHealth = "HEALTHY" + DeliveryAlert DeliveryHealth = "ALERT" + DeliveryReview DeliveryHealth = "REVIEW" +) + +func DeliveryStatus(receipt ResultReceipt, now time.Time) DeliveryHealth { + if !receipt.CommittedAt.IsZero() { + return DeliveryHealthy + } + age := now.Sub(receipt.ReceivedAt) + if age >= ResultDeliveryReviewAfter { + return DeliveryReview + } + if age >= ResultDeliveryAlertAfter { + return DeliveryAlert + } + return DeliveryHealthy +} + +func RatingEligible(receipt ResultReceipt) bool { + return receipt.IntegrityState == IntegrityCertified +} + +func validateBinding(binding WorkloadBinding) error { + if binding.Issuer == "" || binding.Audience == "" || binding.Namespace == "" || binding.ServiceAcct == "" || binding.PodUID == "" || binding.GameServerUID == "" || binding.MatchID == "" || binding.ServerID == "" { + return ErrResultBinding + } + return nil +} + +func sameBinding(a, b WorkloadBinding) bool { return a == b } + +func validateResult(expected WorkloadBinding, result MatchResult) error { + if result.MatchID != expected.MatchID || result.ServerID != expected.ServerID || len(result.ResultNonce) < 16 || len(result.ResultNonce) > 128 || result.Team0Score < 0 || result.Team1Score < 0 { + return ErrResultInvalid + } + switch result.IntegrityState { + case IntegrityCertified, IntegritySuppressed, IntegrityReview: + return nil + default: + return ErrResultInvalid + } +} + +func resultDigest(result MatchResult) [32]byte { + canonical := result.MatchID + "\x00" + result.ServerID + "\x00" + result.ResultNonce + "\x00" + strconv.Itoa(result.Team0Score) + "\x00" + strconv.Itoa(result.Team1Score) + "\x00" + string(result.IntegrityState) + return sha256.Sum256([]byte(canonical)) +} diff --git a/server/domain/result_test.go b/server/domain/result_test.go new file mode 100644 index 00000000..9ce8cc7f --- /dev/null +++ b/server/domain/result_test.go @@ -0,0 +1,79 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func testBinding() WorkloadBinding { + return WorkloadBinding{Issuer: "https://issuer", Audience: "cosmic-result", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: "match-1", ServerID: "server-1"} +} + +func testResult() MatchResult { + return MatchResult{MatchID: "match-1", ServerID: "server-1", ResultNonce: "nonce-1234567890", Team0Score: 3, Team1Score: 2, IntegrityState: IntegrityCertified} +} + +func TestResultStoreBindsWorkloadAndMakesIdenticalDuplicateInert(t *testing.T) { + now := time.Unix(1000, 0) + binding := testBinding() + store, err := NewResultStore(binding) + if err != nil { + t.Fatal(err) + } + first, created, err := store.Submit("result-1", testResult(), binding, now) + if err != nil || !created || !RatingEligible(first) { + t.Fatalf("first result = %+v created=%v err=%v", first, created, err) + } + replay, created, err := store.Submit("result-1", testResult(), binding, now.Add(time.Minute)) + if err != nil || created || replay.ReceivedAt != now { + t.Fatalf("duplicate result = %+v created=%v err=%v", replay, created, err) + } + wrong := binding + wrong.PodUID = "pod-2" + if _, _, err := store.Submit("result-2", testResult(), wrong, now); !errors.Is(err, ErrResultBinding) { + t.Fatalf("wrong pod accepted: %v", err) + } +} + +func TestConflictingResultIsInertAndIntegritySuppressesRating(t *testing.T) { + now := time.Unix(1000, 0) + binding := testBinding() + store, _ := NewResultStore(binding) + if _, _, err := store.Submit("result-1", testResult(), binding, now); err != nil { + t.Fatal(err) + } + conflict := testResult() + conflict.Team0Score = 99 + prior, _, err := store.Submit("result-2", conflict, binding, now) + if !errors.Is(err, ErrResultConflict) || prior.ResultID != "result-1" || prior.CommittedAt != (time.Time{}) { + t.Fatalf("conflict mutated receipt: %+v err=%v", prior, err) + } + suppressed := testResult() + suppressed.MatchID = "match-2" + suppressed.IntegrityState = IntegritySuppressed + secondBinding := binding + secondBinding.MatchID = "match-2" + secondStore, _ := NewResultStore(secondBinding) + got, _, err := secondStore.Submit("result-2", suppressed, secondBinding, now) + if err != nil || RatingEligible(got) { + t.Fatalf("suppressed result eligibility = %+v err=%v", got, err) + } +} + +func TestResultDeliveryHealthSeparatesOutageFromIntegrity(t *testing.T) { + now := time.Unix(1000, 0) + binding := testBinding() + store, _ := NewResultStore(binding) + receipt, _, err := store.Submit("result-1", testResult(), binding, now) + if err != nil { + t.Fatal(err) + } + if DeliveryStatus(receipt, now.Add(5*time.Minute-time.Nanosecond)) != DeliveryHealthy || DeliveryStatus(receipt, now.Add(ResultDeliveryAlertAfter)) != DeliveryAlert || DeliveryStatus(receipt, now.Add(ResultDeliveryReviewAfter)) != DeliveryReview { + t.Fatal("pending delivery thresholds are wrong") + } + committed, err := store.Commit("result-1", "match-1", now.Add(31*time.Minute)) + if err != nil || DeliveryStatus(committed, now.Add(2*time.Hour)) != DeliveryHealthy { + t.Fatalf("committed delivery status = %+v err=%v", committed, err) + } +}