# Online Services & Persistence

V4 is one universe wearing six bodies — a tactical FPS cell, a stealth/Hitman
cell, an asymmetric RTS cell, a Wukong-style ARPG cell, a top-down tactics cell,
and the shared Battle Hub that stitches them together. The promise that makes
that a _universe_ rather than six unrelated games is identity-shaped: **one
account, one operator roster, one currency ledger, one progression record, every
cell, every platform.** A confirmed kill in the Hitman cell, a ranked win in the
tactical cell, and a unique boss clear in the ARPG cell all have to land in the
same profile, mint the same kind of server-validated ledger entry, and survive a
platform switch from PS5 to PC without a player losing a single cosmetic. That
is what this page is about: the **online backbone** — the client-side
`V4OnlineServices` subsystems and the Rust service tier under `apps/v4` that
back them — and **persistence** — the `V4Persistence` module that owns
save-game, profile, the currency ledger, contract progress, and the Hitman
persistent world. The deterministic match runtime these services wrap lives one
layer down in [Networking & Determinism](./networking-determinism.md); the
seasonal ranked operations and DLC cadence layered on top are in
[Game Modes & Live Service](./game-modes-live-service.md). The section hub is
[../V4_ARCHITECTURE.md](../V4_ARCHITECTURE.md).

## What ships, honestly

The split is clean and worth stating before the detail. **Two tiers are real
code**, and a small set of seams are honestly labelled as deployment substrate
rather than wired integrations.

The **client tier is real Unreal C++**. `V4/ue/Source/V4OnlineServices` ships
fifteen `UGameInstanceSubsystem`s — login, matchmaking, session, presence,
friends, party, leaderboard, replay-upload, crossplay, live-service, moderation,
compliance, online-contract, and contract-schedule — over a typed surface
(`V4OnlineTypes.h`), and its `V4OnlineServices.Build.cs` really declares the
`OnlineSubsystem`, `OnlineServicesInterface`, `OnlineServicesCommon`, and
`EOSShared` dependencies that make the EOS abstraction load-bearing rather than
decorative. `V4/ue/Source/V4Persistence` ships nine more subsystems over a 20 KB
typed model (`V4PersistenceTypes.h`): save-game, profile, currency ledger,
save-sync, save-migration, replay-store, contract-progress, and the Hitman
persistent-world subsystem.

The **service tier is real Rust + Axum**. `apps/v4/online-services` is a
`v4-online-services` crate whose `service-manifest.json` enumerates **25
services** and whose `service_router()` (in `src/lib.rs`) nests every one of
them over genuine, tested service structs — Glicko-2 matchmaking, OAuth2 + PKCE
login, replay retention, leaderboards, moderation, compliance, store, esports,
and the rest. The domain logic is not CRUD: it is a backtracking exact-fill
matchmaker, a dependency-free SHA-256/PKCE implementation, a replay-vault
retention calculus, and a right-to-be-forgotten scrubber, each carrying inline
`#[cfg(test)]` cases that assert computed answers (a Glicko-2 fairness window, a
region that minimises worst-case QoS, a 14-day expiry boundary).

Three honest labels travel with the rest of the page:

- **The shipped router is wider than the architecture note.**
  `V4_ARCHITECTURE.md` carries a 2026-06-12 note that "only the login router
  mounts real routes today." That note is now stale: `service_router()` in
  `lib.rs:24-47` nests **all 20** service routers, and `src/http.rs` backs each
  with real `Arc<Mutex<Service>>` state and real handlers. The login router is
  no longer the exception.
- **The services hold in-memory state.** Each Rust service keeps a `BTreeMap`
  behind an `Arc<Mutex<…>>`; the **PostgreSQL / Redis / ClickHouse / S3** stores
  the architecture names are the _specified deployment substrate_, not yet wired
  into these crates. The reference implementations are real and tested; the
  database backends are the next integration step, labelled as such.
- **V4 does not ride the `@oshun` plane.** Unlike V2's event-bus-native
  backbone, V4's services are a self-contained Rust workspace
  (`v4_shared::ServiceName`). There is no `@oshun/event-bus` or
  `@oshun/identity` composition in `apps/v4`, `V4/store`, `V4/community`, or
  `V4/ue` — the only `@oshun` string anywhere in V4 is inside a vendored Next.js
  build artifact. Where V2 publishes cross-domain events, V4 keeps its own
  service graph.

## The online backbone

### Client subsystems and the EOS abstraction

Gameplay code never branches on platform. Every menu, lobby, and matchmaking
screen calls a `V4OnlineServices` subsystem whose types speak one enum —
`EV4OnlinePlatform { EOS, PSN, XBL, NN, Steam, Apple, Google }` — and the
platform-specific online stacks sit behind it. `UV4LoginSubsystem` is a clean
client-side state machine: `BeginLogin` moves an account to
`EV4LoginState::Pending`, `CompleteLogin` resolves it to `SignedIn` or `Failed`,
and `IsPlatformSupported` gates the seven launch providers
(`V4LoginSubsystem.cpp`). The heavy cryptographic exchange deliberately does not
live in the client — it lives in the Rust login-service, where it can be audited
and rotated server-side.

The crossplay subsystem is the clearest example of V4's honesty discipline.
`UV4CrossplaySubsystem::BuildDefaultPlatformIntegrations()` returns the launch
**contract** — the capabilities V4 commits to per platform — and every row is
stamped `bMeasured = false` with a comment that reads, verbatim, "a target
statement, not a probe … it must never be read as 'the SDK is wired'." The
_live_ truth comes from a separate path: `ProbePlatformIntegration` calls
`IOnlineSubsystem::Get(...)` and reads `GetIdentityInterface().IsValid()`,
`GetPurchaseInterface().IsValid()`, `GetFriendsInterface()`,
`GetPartyInterface()`, and `GetSessionInterface()` on the real subsystem,
returning a struct with `bMeasured = true`; when no SDK is registered on the
host, every capability honestly reads `false`. `BuildIntegrationGapReport` diffs
contract against probe and emits a line for each "contracted capability not
wired on this host." That is exactly the fail-loud seam the codebase asks for:
the contract never masquerades as the measurement.

### Matchmaking and the ranked ladder

Matchmaking is the backbone's most fairness-sensitive job, and V4 implements it
on both sides of the wire. On the client, `UV4MatchmakingSubsystem` carries
`FV4MatchmakingTicket` rows (mode, region, ladder, integer `SkillRating`) and a
`BuildMatchmakingDiagnostic` call that powers the
`WBP_RankedMatchmakingDiagnostic` explanation panel — it computes `PlayerMMR`,
`OpponentAverageMMR`, `MMRDelta`, `bWithinTolerance`, and a human-readable
explanation so a player can see _why_ a match formed.

The authoritative skill model is server-side and genuine. `MatchmakingService`
(`src/matchmaking.rs`) runs a **Glicko-2** expected-score fairness window:
`glicko2_expected_score` applies the `q = ln(10)/400` scale factor — with a
checked-in comment recording the 2026-06-12 fix for a missing `q` that had
flattened every pairing toward even — and a candidate joins the match only when
its expected score against the seed stays within
`FAIR_MATCH_EXPECTED_SCORE_WINDOW = 0.15` of 0.5. `find_match` is a real
depth-first **backtracking exact-fill**: it seeds on the longest-waiting solo
(`prefer_solo_queue`, FIFO within party size), recurses through `fill_exact` to
assemble exactly `team_size` players from mixed party sizes, and requires one
region every member can play in — then picks the region with the best worst-case
latency. The tests prove the behaviour rather than the shape: a 2400-rated smurf
is _excluded_ from a 1500-lobby and stays queued; a 3+2 backtrack fills a
5-stack while a lone solo waits; and `SA` wins over `EU` because its shared
worst-case QoS is 45 ms versus 80 ms.

The ranked ladder on top is seasonal and data-driven. `launch_season_cadence()`
encodes a **90-day season**, a **10-day pre-season reset**, **5 placement
matches**, and a **day-45 mid-season balance patch**;
`DA_RankedSeasonArchitecture` defines the five year-one ladders
(`Ranked.Tactical.R6Modern`, `Ranked.Stealth.SpiesVsMercs`,
`Ranked.RTS.Asymmetric`, `Ranked.RTS.Historical`, `Ranked.ARPG.BossRush`) with
Diamond+ 14-day decay and public `/ranked` MMR histograms. The launch SLO the
architecture commits to is concrete enough to test: **matchmaking p99 ≤ 35 s
under 5× expected launch concurrency.**

### Identity, login, and cross-progression

`LoginService` (`src/lib.rs`) is the account root. Its OAuth surface
(`login_router`) exposes `/oauth/providers`, `/oauth/authorize`, `/oauth/token`,
and `/oauth/refresh`, and the flow underneath is real RFC 6749 + RFC 7636 PKCE:
`src/oauth.rs` carries a dependency-free, FIPS 180-4 SHA-256 used for the S256
code-challenge binding, so the authorization-code exchange is genuinely
cryptographic and deterministically testable rather than a synthesised token
string. Refresh is single-use with replay defense — `redeem_refresh_token`
validates the presented token against its expected derivation, inserts it into a
`consumed_refresh_tokens` set, and rejects a replay with
`RefreshGrantError::ReplayedRefreshToken` before rotating to the next
generation.

Cross-progression roots every player in one first-party account with link/unlink
semantics on both sides of the wire. `LoginService::link_platform_account`
refuses to bind a provider identity already attached to a different account and
keeps at least one primary link alive on unlink; the in-engine mirror,
`UV4CrossplaySubsystem::LinkPlatformAccount` / `UnlinkPlatformAccount`, enforces
the same invariants on `FV4FirstPartyAccount` and promotes a new primary if the
old one is removed. Currency portability is encoded as policy, not prose:
`BuildDefaultCurrencyRules()` makes `OperatorTokens` and `EliteTokens`
cross-platform soft currencies while `CombatPoints` is per-platform hard
currency, and `CanCurrencyMoveCrossPlatform` returns true only for a soft,
cross-platform, non-per-platform currency. Per-surface session policy is real
too — `cross_surface_session_policies()` gives the companion app, wiki, roadmap,
and spectator portal each its own idle and absolute timeout with rotation on.

### Replay vault, right-to-be-forgotten, and speedrun ghosts

`ReplayService` is one of the most domain-specific structs in the tree. Uploads
default to a **14-day retention** (`DEFAULT_REPLAY_RETENTION_DAYS`); starring
moves a replay into a **lifetime vault** capped at **500 per account**
(`MAX_STARRED_REPLAYS_PER_ACCOUNT`), with `star_archive_for_account_at` refusing
a wrong-owner star and `expired_replays` computing expiry as
`created + retention_days × 86 400 ≤ now` while skipping anything starred. The
**right-to-be-forgotten** path, `scrub_deleted_account_from_replay`, walks a
replay's participant pawns, clears the deleted account's identifiers, swaps in
the `Skin.Anonymous.DeletedAccount` skin, and flips `privacy_scrubbed` — a
deleted player still appears in preserved replays, but only as an anonymous
silhouette. The shared speedrun ghost archive accepts only `verified_by_server`
submissions carrying replay, input-stream, and checkpoint hashes; a faster ghost
replaces the active route ghost while `SpeedrunGhostStatus::Superseded` and
`superseded_by_ghost_id` preserve the full lineage for audits. On the client,
`UV4ReplayStoreSubsystem` and `UV4ReplayUploadSubsystem` hold the local
`FV4ReplayRecord` model and the upload `Progress` field that the UI watches.

### Moderation, anti-cheat, and compliance

Anti-cheat and moderation deliberately share one tested `CaseService`: the
`anti_cheat_router` and `moderation_router` both delegate to `case_router()`.
Report intake requires real evidence — `submit_player_report` rejects any report
whose attached `recent_gameplay_clip` is missing a cloud URI or has zero
duration — and `scan_content` is a graded classifier, not a coin flip: nudity ≥
85 or violence ≥ 90 blocks distribution, lower scores route to human review, and
a Lilith rights match triggers a takedown cascade plus an appeal. Appeals
resolve at `https://support.v4.game/appeal?sanctionId=…`. The architecture is
explicit that ML behavioural detection _flags for review_ and that permanent
bans require replay evidence + telemetry evidence + manual review — model output
prioritises a queue, it never silently disciplines. Kernel-mode EAC/BattlEye is
named as the client anti-cheat tier; like V2, that driver is a planned surface,
while the **replay-validation** integrity check rides the determinism the engine
already guarantees. Compliance is its own tested service: `ComplianceService`
exposes DSAR export, right-to-delete, statement-of-reasons logging, an age-gate,
and per-region residency, and the in-engine `UV4ComplianceSubsystem` resolves
regional privacy defaults across EU/UK/US/BR/JP/KR/IN/CN (China voice-off;
CN/KR/IN cosmetic-only economy).

```mermaid
flowchart LR
  subgraph Client["V4 client · Unreal C++"]
    OS["V4OnlineServices<br/>Login · MM · Session · Crossplay · Replay"]
    PS["V4Persistence<br/>SaveGame · Profile · CurrencyLedger"]
  end
  OS -->|OnlineSubsystem / EOSShared| EOS[(EOS · PSN · XBL · NN<br/>Steam · Apple · Google)]
  OS -->|REST / gateway| GW[API gateway]
  PS -. cloud save .-> GW
  GW --> R["v4-online-services · Rust + Axum<br/>service_router()"]
  R --> MM[matchmaking · Glicko-2]
  R --> LOGIN[login · OAuth2 + PKCE]
  R --> REPLAY[replay · vault · RTBF scrub]
  R --> LB[leaderboard · per cell/region/season]
  R --> MOD[moderation / anti-cheat]
  MM & LOGIN & REPLAY & LB & MOD -.specified store.-> DS[(PostgreSQL · Redis<br/>ClickHouse · S3)]
```

## Persistence and save

### Save game and cloud sync

`UV4SaveGameBase` is the model behind every cell's save. A slot
(`FV4CellSaveSlot`) is keyed `CellId.SlotId`, versioned, and carries its payload
as `PayloadJson`, a `ChapterId`, and a set of honest booleans — `bAutoSave`,
`bQuickSave`, `bCloudSyncQueued`, `bCloudSynced`. The save policy is real:
`BuildDefaultAutoSavePolicy` is a 60-second interval, PvE-only,
save-on-chapter-transition; `ShouldAutoSave` enforces all three; and
`PutQuickSave` flatly **refuses** a non-PvE quick-save
(`if (!bPvE) return false`) so competitive modes cannot save-scum.
`MigrateSlotsToVersion` walks every slot and lifts it to a target version, and
`UV4SaveMigrationSubsystem` registers `FV4SaveMigrationStep` from/to pairs so a
schema bump is a registered migration, not an ad-hoc rewrite.

`UV4SaveSyncSubsystem` owns cross-platform sync _metadata_:
`QueueCloudSyncForSave` mints a `PlatformSlotId` of the form
`platform.cell.slot`, `MarkSynced` records a `LastSyncUtc` and a `ConflictToken`
for last-writer reconciliation, and `IsCloudSyncEnabled` gates per slot. Honest
scope note: this subsystem is the **sync ledger and conflict-token tracker** —
the actual durable transport is the platform-layer OnlineServices SaveGame
surface the architecture names, not bytes this class writes itself.

### Profile, operator XP, and the codex

`UV4ProfileSubsystem` implements the cross-cell progression math. The model is a
linear **1000 XP per level** (`DA_ProgressionModel`): operator levels run 1-100
and clamp at 99,000 XP, account levels run 1-1000 and clamp at 999,000 XP, and
the anti-grind rule is enforced in code — `ClampOperatorDailyXPGrant` returns 0
once a day's operator XP reaches the **5000 XP cap**, and otherwise clamps the
grant to the remaining headroom. `UpsertProfile` sanitises on the way in (drops
`None` keys, floors XP at zero). The codex is genuinely populated:
`BuildCodexCategories` returns the eight unlockable categories (Operator,
Weapon, Gadget, Map, Faction, Civilization, Ruleset, Vehicle) and
`BuildLaunchCodexEntrySamples` ships real, authored entries — Cobra's Shadow War
dossier, a modular service rifle's recoil identity, the MH-6 Little Bird's
insertion routes — each with lore text, gallery ids, design commentary, and
account/operator-level gates that `CanUnlockCodexEntry` checks. XP is
server-authoritative by design: the client reports a result, the server
validates against telemetry, and the grant lands as a ledger entry.

### The currency ledger

`UV4CurrencyLedger` is a three-tier, audit-trailed ledger.
`BuildDefaultCurrencyDefinitions` defines **Operator Tokens** (soft, earned,
cross-platform), **Combat Points** (hard, purchased, per-platform), and **Elite
Tokens** (premium, earned through ranked, cross-platform). `ApplyTransaction`
rejects an unsupported currency or a transaction that would drive the balance
negative, mints a GUID `TransactionId`, records `BalanceAfter`, and appends to
an `AuditTrail` — every movement is reconstructable.
`ApplyServerValidatedTransaction` adds the integrity gate the architecture
promises: it requires a non-empty `ServerValidationToken`, demands a
`PlatformId` for any per-platform currency, and sets `bServerValidated`. The
cross-platform flag is computed from the currency, not asserted —
`bCrossPlatform = CurrencyId != "CombatPoints"` — so the per-platform hard
currency can never silently leak across a store boundary.

### Cosmetic inventory and the persistent world

Cosmetics are owned, not just listed. `UnlockCosmeticItem` validates the
category against the nine launch categories (operator/weapon skins, charms,
banner and calling cards, emotes, stickers, badges, sprays), records a
`FV4CosmeticInventoryItem` with its `SourceId` and `GrantedAtUtc`, and
`HasCrossPlatformCosmetic` confirms an item travels with the account. The
`FV4ProfileState` that carries all of this — XP, per-operator XP, unlocked
origin comics and codex entries, the cosmetic inventory, and settings — is the
single object cross-progression syncs.

The most distinctive persistence surface is the Hitman cell's living world.
`UV4HitmanPersistentWorldSubsystem` records per-profile NPC state
(`RecordNpcObserved`, `RecordNpcKilled` with story-mode and primary-target
flags) and decides, via `ShouldSpawnNpcForMode`, whether a story-dead NPC stays
gone or a Quick Mission resets it (`ResetQuickMissionRuntimeState`). On top of
that per-player state it builds the **community** layer the wiki publishes:
`RecordSeasonalNpcKill` rolls up all-map kills, `BuildReadOnlyWorldStateFeed`
emits the read-only season snapshot (`bReadOnly = true`),
`BuildWorldStateRippleEvents` maps Hitman assassinations to campaign branch
flags, and `RecordCommunityRaidContribution` / `BuildSeasonalWorldBossRaids`
drive the async Eclipse Leviathan world-boss. Critically, ripple events carry
`bDeterministicBeforeLoad = true`: cross-cell hooks resolve _before_ a mission
loads, so live community telemetry can change the fiction around a match but
never the deterministic combat simulation inside it.

## Where this connects

- **Down to the match:** [Networking & Determinism](./networking-determinism.md)
  — the deterministic sim the server-side replay-validation integrity check
  depends on, and the dedicated-server allocation the session-service drives.
- **Sideways to live service:**
  [Game Modes & Live Service](./game-modes-live-service.md) — the seasonal
  ranked operations, the store and battle-pass entitlements, and the DLC
  GameFeature cadence that ride this backbone.
- **Platform foundations:**
  [Persistence & Data](../../platform/persistence-data.html) — the Postgres /
  Redis / ClickHouse / object-storage substrate these services are specified to
  deploy onto, and the account-root and data-residency posture they inherit.
