From 637b522486026ee35db0a19c83de80cbb11f9172 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:34:50 +0100 Subject: [PATCH] test: harden result annotation reconciliation --- multiplayer-todo.md | 2 +- server/domain/result.go | 18 ++++++++++++++++++ server/domain/result_test.go | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 3a66ddc2..adbab35e 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]` | **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 | +| 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, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds | `server/domain/result.go` and adversarial fixtures cover binding, duplicate/conflict, annotation forgery, commit and delivery-health invariants; production credential verification, Agones annotation persistence/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 index 6604682e..b08970d8 100644 --- a/server/domain/result.go +++ b/server/domain/result.go @@ -94,6 +94,24 @@ func (s *ResultStore) Submit(resultID string, result MatchResult, binding Worklo return receipt, true, nil } +// ResultAnnotation is the non-secret Agones spool representation. Its +// signature is checked by the workload-credential adapter before Reconcile; +// the digest check here prevents annotation/payload drift even after trust +// has been established. +type ResultAnnotation struct { + ResultID string + Result MatchResult + PayloadDigest [32]byte + Signature []byte +} + +func (s *ResultStore) Reconcile(annotation ResultAnnotation, verify func(ResultAnnotation) bool, binding WorkloadBinding, now time.Time) (ResultReceipt, bool, error) { + if len(annotation.Signature) == 0 || verify == nil || !verify(annotation) || annotation.PayloadDigest != resultDigest(annotation.Result) { + return ResultReceipt{}, false, ErrResultBinding + } + return s.Submit(annotation.ResultID, annotation.Result, binding, now) +} + func (s *ResultStore) Commit(resultID, matchID string, now time.Time) (ResultReceipt, error) { receipt, ok := s.receipts[matchID] if !ok || receipt.ResultID != resultID { diff --git a/server/domain/result_test.go b/server/domain/result_test.go index 9ce8cc7f..d7be2f0c 100644 --- a/server/domain/result_test.go +++ b/server/domain/result_test.go @@ -61,6 +61,23 @@ func TestConflictingResultIsInertAndIntegritySuppressesRating(t *testing.T) { } } +func TestAnnotationReconcileChecksSignatureAndDigest(t *testing.T) { + now := time.Unix(1000, 0) + binding := testBinding() + store, _ := NewResultStore(binding) + result := testResult() + annotation := ResultAnnotation{ResultID: "result-1", Result: result, PayloadDigest: resultDigest(result), Signature: []byte("sig")} + verify := func(candidate ResultAnnotation) bool { return string(candidate.Signature) == "sig" } + if _, created, err := store.Reconcile(annotation, verify, binding, now); err != nil || !created { + t.Fatalf("valid annotation = created=%v err=%v", created, err) + } + forged := annotation + forged.Result.Team0Score = 99 + if _, _, err := store.Reconcile(forged, verify, binding, now); !errors.Is(err, ErrResultBinding) { + t.Fatalf("forged annotation accepted: %v", err) + } +} + func TestResultDeliveryHealthSeparatesOutageFromIntegrity(t *testing.T) { now := time.Unix(1000, 0) binding := testBinding()