Skip to content

MVP Specification — Version 1

This document defines the Minimum Viable Product (MVP) for the platform. The goal of this version is to validate the core data model, access control system, and collaborative match structure.

The system revolves around a few core domain objects: User, CanonicalPlayer, GhostPlayer, Match, and Community.

The MVP focuses on: identity → match recording → controlled access sharing

Statistics (derived, computed insight from match history) are explicitly deferred to Phase 2, alongside Data Portability — see Out of Scope.


  • Users authenticate exclusively via Keycloak. Stratastax never stores or verifies a password itself — Keycloak owns authentication entirely, and may itself broker other providers (Google, Meta, …) behind a single realm.
  • Upon first authentication with a given identity:
  • UserProvisioningService provisions a User, together with exactly one CanonicalPlayer
  • CanonicalPlayer becomes the permanent identity anchor for all future gaming history; User remains responsible for authentication only
  • UserIdentity
  • provider (keycloak, or a provider Keycloak itself brokers)
  • providerUserId (the JWT sub claim)
  • email, emailVerified (from the JWT, cached for display/lookup — not authoritative; Keycloak remains the source of truth)
  • userId (belongs to User — never to CanonicalPlayer directly)
  • User
  • immutable ID
  • exactly one linked canonicalPlayerId, created at the same time
  • lastLoginAt
  • CanonicalPlayer
  • immutable ID
  • profile metadata (minimal MVP fields)

A user can create a Match with:

  • Selected Game
  • Participants (one or more)
  • Optional Playset configuration (expansions, variants, scenario — shared across all participants)

Each participant is represented as a MatchParticipant, referencing a GhostPlayer and, where the Game defines it, a Loadout (faction, character, starting cards — specific to that one participant).

A MatchParticipant never stores a direct link to a CanonicalPlayer. Whether a participant’s GhostPlayer is claimed is resolved by looking up an active ClaimEdge, not by a field on MatchParticipant itself.

  • gameId
  • playsetId (optional)
  • communityId (optional)
  • recorderUserId — the Match’s owner; fixed, never transferred
  • timestamp
  • editRequestsEnabled (boolean, default true — see §3)
  • lockedAt (null until the edit window elapses)
  • ghostPlayerId
  • result fields (score, rank, outcome — per the Game’s ScoreType/ WinCondition)
  • disputeFlag (optional, settable only by the User owning the CanonicalPlayer that claims this participant’s GhostPlayer — see §3)
  • belongs to exactly one MatchParticipant
  • faction / character / starting cards, where the Game defines them
  • gameId (fixed at creation)
  • expansionIds (0..N, must belong to the same Game)
  • variants / custom rules (free-form labels)
  • scenario (optional)
  • ownerUserId
  • reusable across any number of Matches; content immutable once created, only its Name can be changed
  • name, description, minPlayers, maxPlayers, imageUrl, tags
  • scoreType (Score | Time | Turns | WinLoss)
  • winCondition (HigherWins | LowerWins; required unless WinLoss)
  • Expansion belongs to exactly one Game

  • Matches can be corrected after creation via a MatchEdit: score updates, participant corrections, or a change to which Playset a Match references.
  • The Match’s owner is the sole approver of any MatchEdit.
  • A participant may propose a MatchEdit only if the Match owner has enabled edit requests for that Match (editRequestsEnabled).
  • Edits are auto-approved if the proposer is a TrustedEditor for the Match’s owner.
  • A Match becomes Locked once its configured edit window elapses; no further MatchEdits are possible after that, regardless of approval or trust.
  • A participant may mark their own result as Disputed, with a reason — a visible annotation only, with no further workflow.
  • MatchEdit
  • matchId
  • changeSet (field-based: targeted MatchParticipant/Loadout fields, or a new playsetId)
  • proposedByUserId
  • approvedByUserId (the Match’s owner)
  • status: pending | approved | rejected
  • TrustedEditor
  • grantorUserId (a Match owner)
  • trusteeUserId
  • directional; grantor can revoke at any time
  • MatchParticipant.disputeFlag
  • reason (free text)
  • settable/clearable only by the User owning the CanonicalPlayer that claims the participant’s GhostPlayer

  • Users can claim a GhostPlayer if they are the real participant it refers to.
  • A claim must be approved by the GhostPlayer’s owner (the User who created it) before it takes effect — unless pre-approved via a trusted invitation path.
  • ClaimRequest
  • canonicalPlayerId (the claimant)
  • ghostPlayerId (one or more targets)
  • validatedByUserId (the GhostPlayer’s owner)
  • status: pending | approved | rejected
  • ClaimEdge
  • canonicalPlayerId
  • ghostPlayerId
  • revocable, auditable

Flow:

  1. User submits a ClaimRequest for one or more GhostPlayers.
  2. The GhostPlayer’s owner approves or rejects it (or it is pre-approved via a trusted invitation path).
  3. On approval, a ClaimEdge is created. The GhostPlayer is unchanged; the CanonicalPlayer gains the relationship.

Each data type supports a Privacy Level:

  • Private
  • PrivilegedAccess (Group-based)
  • Public

Applies, for the MVP, to:

  • Profile
  • MatchHistory
  • MatchDetails

Statistics is deferred to Phase 2, alongside the rest of the derived statistics layer (see Out of Scope) — there is nothing to apply a Privacy Level to yet, since no statistics are computed in this version.

  • PrivacySetting
  • userId
  • dataType (Profile | MatchHistory | MatchDetails)
  • level (Private | PrivilegedAccess | Public)
  • groupIds (0..N, only meaningful when level is PrivilegedAccess; every referenced Group must be owned by the same User)
  • absence of a row for a given dataType defaults to Private
  • Access resolution logic:
  • resolve the data type being requested;
  • if Public, allow;
  • if Private, allow only the owning User;
  • if PrivilegedAccess, allow the owning User and any User who is a member of one of the referenced Groups.

  • Users can create Groups.
  • Groups define who can access privileged data via a PrivacySetting.
  • Group membership is managed unilaterally by the Group’s owner — adding someone to a Group never requires their approval, the same way keeping someone in a personal address book does not. There is no request/ approval flow for Group membership, in the MVP or beyond; that flow belongs to Community membership instead (see §8).
  • Group
  • name
  • ownerUserId — fixed, never transferred
  • memberUserIds

A Community is a higher-level shared space:

  • Has its own Name, Description, and membership — not built from a Group’s member list
  • May optionally delegate admin rights to a Group’s members (its AdminGroup), which must be owned by the Community’s current owner
  • Contains shared match history context
  • No tournament mechanics
  • time-based seasons
  • competitive structures
  • ranking systems
  • Community
  • name, description
  • ownerUserId — the only ownership in this model that can be transferred; transferring it clears adminGroupId
  • adminGroupId (optional; must reference a Group owned by the current ownerUserId)
  • joinPolicy (Open | RequiresApproval)
  • Rights model:
  • Owner: everything, including transferring ownership, changing adminGroupId, changing joinPolicy, deleting the Community
  • Admin (owner, or any member of the AdminGroup): approve/reject MembershipRequests, edit Name/Description
  • Member: no administrative rights

  • Users can join a Community.
  • Two modes, controlled by the Community’s joinPolicy:
  • Open — a Membership is created directly, no approval needed
  • RequiresApproval — a MembershipRequest must be approved by the Community’s owner or an admin first
  • Membership
  • userId
  • communityId
  • at most one per (userId, communityId) pair
  • MembershipRequest
  • proposedByUserId
  • communityId
  • decidedByUserId (owner or admin)
  • status: pending | approved | rejected
  • only created when the target Community’s joinPolicy is RequiresApproval

The following are intentionally excluded from MVP:

  • No time-based competitive structure
  • No seasons or ladders
  • No derived statistics computation of any kind, including the Statistics Privacy data type
  • No scoped, point-in-time snapshots, and no active sharing layer (links, notifications) — targeted for Phase 2
  • No canonical identity deduplication system
  • No import/export APIs
  • No external integrations
  • Targeted for Phase 2, alongside Statistics
  • Only Open and RequiresApproval are supported; invitation-based joining would need its own Invitation concept

After implementation, the system must enable:

  1. A user registers → gets a User and exactly one CanonicalPlayer
  2. User records Matches with structured MatchParticipants, Loadouts, and an optional Playset
  3. Matches can be corrected safely, with the owner as sole approver and TrustedEditor for auto-approval, until the Match locks
  4. GhostPlayers can be claimed via ClaimRequest, resolved into a ClaimEdge
  5. Data visibility (Profile, MatchHistory, MatchDetails) is controlled via PrivacySettings
  6. Groups define privileged access boundaries, managed unilaterally by their owner
  7. Communities hold their own membership and shared history, optionally delegating admin rights via a Group
  8. Membership controls participation in Communities, either open or approval-based

This MVP is intentionally focused:

Identity → Match data → Controlled access → Shared communities

Statistics and Data Portability are deferred to Phase 2. Everything else listed under Out of Scope is deferred further still.