// Package domain contains database-independent matchmaking invariants. // Adapters may persist these records in PostgreSQL, but must not redefine // transition, revision, or idempotency behavior. package domain import ( "bytes" "crypto/sha256" "errors" "fmt" ) type ResourceKind string const ( ResourceQueueTicket ResourceKind = "queue_ticket" ResourceProposal ResourceKind = "proposal" ResourceMatch ResourceKind = "match" ) type State string const ( Queued State = "QUEUED" Proposed State = "PROPOSED" Accepted State = "ACCEPTED" Allocating State = "ALLOCATING" ProcessReady State = "PROCESS_READY" AssignmentReady State = "ASSIGNMENT_READY" Assigned State = "ASSIGNED" Connecting State = "CONNECTING" Live State = "LIVE" ResultPending State = "RESULT_PENDING" Completed State = "COMPLETED" Cancelled State = "CANCELLED" Expired State = "EXPIRED" Failed State = "FAILED" Open State = "OPEN" Declined State = "DECLINED" ) var ( ErrConflict = errors.New("mutation conflict") ErrStaleRevision = errors.New("stale revision") ErrIllegalTransition = errors.New("illegal state transition") ) type Record struct { Kind ResourceKind ID string State State Revision uint64 idempotent map[string]appliedMutation } type appliedMutation struct { payloadDigest [32]byte result Result } type Result struct { Kind ResourceKind ID string State State Revision uint64 } func NewRecord(kind ResourceKind, id string, state State) *Record { return &Record{Kind: kind, ID: id, State: state, idempotent: make(map[string]appliedMutation)} } // Apply performs all validation before changing the record. Replaying an // identical idempotency key returns the original result without advancing the // revision. Reusing a key with a different payload, or presenting a stale // revision, is inert and returns an error. func (r *Record) Apply(idempotencyKey string, payload []byte, expectedRevision uint64, target State) (Result, error) { if idempotencyKey == "" { return Result{}, fmt.Errorf("%w: empty idempotency key", ErrConflict) } digest := sha256.Sum256(payload) if prior, ok := r.idempotent[idempotencyKey]; ok { if !bytes.Equal(prior.payloadDigest[:], digest[:]) { return Result{}, fmt.Errorf("%w: idempotency key reused with different payload", ErrConflict) } return prior.result, nil } if expectedRevision != r.Revision { return Result{}, fmt.Errorf("%w: expected %d, current %d", ErrStaleRevision, expectedRevision, r.Revision) } if !legalTransition(r.Kind, r.State, target) { return Result{}, fmt.Errorf("%w: %s %s -> %s", ErrIllegalTransition, r.Kind, r.State, target) } r.State = target r.Revision++ result := Result{Kind: r.Kind, ID: r.ID, State: r.State, Revision: r.Revision} r.idempotent[idempotencyKey] = appliedMutation{payloadDigest: digest, result: result} return result, nil } func legalTransition(kind ResourceKind, from, to State) bool { var targets []State switch kind { case ResourceQueueTicket: targets = queueTransitions[from] case ResourceProposal: targets = proposalTransitions[from] case ResourceMatch: targets = matchTransitions[from] default: return false } for _, target := range targets { if target == to { return true } } return false } var queueTransitions = map[State][]State{ Queued: {Proposed, Cancelled, Expired}, Proposed: {Queued, Accepted, Cancelled, Expired}, Accepted: {Queued, Allocating, Cancelled, Failed}, Allocating: {ProcessReady, Failed, Cancelled}, ProcessReady: {AssignmentReady, Failed, Cancelled}, AssignmentReady: {Assigned, Failed, Cancelled}, Assigned: {Connecting, Failed, Cancelled}, Connecting: {Live, Failed, Expired}, Live: {ResultPending, Failed}, ResultPending: {Completed, Failed}, Completed: {}, Cancelled: {}, Expired: {}, Failed: {}, } var proposalTransitions = map[State][]State{ Open: {Accepted, Declined, Expired, Cancelled}, Accepted: {}, Declined: {}, Expired: {}, Cancelled: {}, } var matchTransitions = map[State][]State{ Allocating: {ProcessReady, Failed, Cancelled}, ProcessReady: {AssignmentReady, Failed, Cancelled}, AssignmentReady: {Assigned, Failed, Cancelled}, Assigned: {Connecting, Failed, Cancelled}, Connecting: {Live, Failed, Cancelled}, Live: {ResultPending, Failed}, ResultPending: {Completed, Failed}, Completed: {}, Cancelled: {}, Failed: {}, }