fix: bind queue candidates to their owner

This commit is contained in:
Josh Creek
2026-08-31 22:05:44 +01:00
parent b47c7dc3fa
commit 3b208ae860
3 changed files with 16 additions and 2 deletions
+1 -1
View File
@@ -1191,7 +1191,7 @@ the local/CI/community transport, not a silent production fallback.
| # | Task | Acceptance |
|---|---|---|
| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, expired recovery as a terminal error, server-owned candidate resolution, strict compatibility metadata and cache loss/atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain |
| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, candidate/player ownership binding, expired recovery as a terminal error, server-owned candidate resolution, strict compatibility metadata and cache loss/atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain |
| 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release; authenticated HTTP now accepts only opaque location/nonce input through a server-owned probe provider | `server/domain/probes.go`, adversarial fixtures and `server/api/service.go` cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine, five-clean release, authenticated provider arguments and rejection of client RTT fields; Steam coordinator and regional probe adapters remain |
| 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning; queue-backed formation now consumes the server-owned projection, fences duplicate player identities and rejects playlist/build/protocol mixing | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance, malformed candidates, duplicate identities, compatibility mismatches and queue-backed oldest-anchor formation; full population fixtures and durable matcher claim integration remain |
| 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation; authenticated API exposes revisioned accept/decline mutations; formed matches now pass through a playlist-aware proposal boundary | `server/domain/proposal.go`, `formation.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision, casual lineup preparation and ranked metadata validation; queue precedence and allocation integration remain |
+1 -1
View File
@@ -62,7 +62,7 @@ func (q *Queue) Create(playerID, ticketID, idempotencyKey string, candidate Cand
}
return prior.ticket, nil
}
if idempotencyKey == "" || playerID == "" || ticketID == "" || candidate.TicketID != ticketID {
if idempotencyKey == "" || playerID == "" || ticketID == "" || candidate.TicketID != ticketID || candidate.PlayerID != playerID {
return QueueTicket{}, fmt.Errorf("%w: invalid queue create", ErrConflict)
}
if _, ok := q.byPlayer[playerID]; ok {
+14
View File
@@ -79,6 +79,20 @@ func TestQueueCreateIdempotencyIncludesCandidatePayload(t *testing.T) {
}
}
func TestQueueCreateRejectsCandidateOwnedByAnotherPlayer(t *testing.T) {
q := NewQueue()
now := time.Unix(1000, 0)
_, err := q.Create("player-a", "ticket-a", "create-key-123456", Candidate{
TicketID: "ticket-a", PlayerID: "player-b", EnqueuedAt: now,
}, now)
if !errors.Is(err, ErrConflict) {
t.Fatalf("mismatched candidate owner error = %v", err)
}
if got := q.Candidates(now); len(got) != 0 {
t.Fatalf("mismatched candidate was stored: %+v", got)
}
}
func TestQueueConcurrentCreateKeepsOneActiveTicketPerPlayer(t *testing.T) {
q := NewQueue()
now := time.Unix(1000, 0)