# Persistence, Online Services & Cross-Play

V5 is an open-world narrative universe split into cells — an Urban heist city, a
three-era Period crime saga, a Frontier outlaw trail, a Hunter's
monster-contract world, a Sci-Fi galactic theatre, and the cross-cutting Mind
Palace deduction layer. What makes that a _universe_ rather than six unrelated
games is identity-shaped, and the shape is **the Bureau**: one canonical
account, one Bureau XP ledger that every cell feeds, one Mind Palace graph, one
cosmetic inventory — the same player record on PC, PlayStation, Xbox, Switch,
and iOS, surviving a platform switch without losing a clue or a cosmetic. This
page is about the machinery that keeps that promise: **persistence** (the
`V5Core` encrypted save substrate and the `V5Persistence` ledgers, replays, and
New Game+ migration that ride on it), the **online backbone** (the 16-service
`V5OnlineServices` client catalog, its real HTTP transport, and the
`@v5/service-shared` NestJS tier under `apps/v5` that backs it), and
**cross-play / cross-progression** (the policy that decides who can match whom
and what travels with an account). The deterministic combat the session services
wrap lives one layer down in
[Netcode, Authority & Determinism](./netcode-authority-and-determinism.md); the
seasonal cadence layered on top is in
[Live Service & DLC](./live-service-and-dlc.md). The section hub is
[../V5_ARCHITECTURE.md](../V5_ARCHITECTURE.md).

## What ships, honestly

The split is clean, and stating it up front keeps the rest of the page honest.

The **client tier is real Unreal C++**. `V5/ue/Source/V5OnlineServices` ships a
16-endpoint service catalog (`UV5_Online_ServiceCatalog::BuildEndpointCatalog`)
fronted by a family of `UBlueprintFunctionLibrary` request builders
(`UV5_Online_AuthService`, `…MatchmakingService`, `…AntiCheatClient`, and the
rest) over a 1,200-line typed surface (`V5OnlineServicesTypes.h`). Crucially,
the transport underneath is **genuine network I/O**: `V5OnlineServices.Build.cs`
declares the `HTTP` and `Json` modules, and `FV5OnlineHttp` turns a request
value into a real `IHttpRequest` from `FHttpModule` — the comment in
`V5OnlineHttpClient.cpp` records this as the module's "first real network I/O,
replacing the prior 'build a POD struct, never transmit' path that the
2026-05-31 audit flagged." Authentication is real cryptography, not a token
template: `FV5JwtCrypto` is a self-contained FIPS 180-4 SHA-256, RFC 2104
HMAC-SHA256, RFC 7519 HS256 JWT, and RFC 7636 PKCE S256 implementation, unit
-tested against published vectors.

The **service tier is real TypeScript + NestJS**. `apps/v5` holds 16 service
packages (each with a `contract.json`, `Dockerfile`, and `k8s/` manifests), and
every one delegates its `handle()` to `handleRealRequest` in
`@v5/service-shared`, which resolves **live `pg.Pool` + `ioredis` clients** and
dispatches to a domain that is not CRUD: a Glicko-2 rating pipeline, an OAuth
refresh-rotation flow with reuse detection, a hash-chained balance ledger, a
CRDT Mind Palace merge. Each domain carries `#[cfg(test)]`-equivalent Vitest
cases asserting computed answers (Glickman's worked Glicko-2 example, a revoked
token family, a verified ledger chain).

Three honest labels travel with the rest of the page:

- **The save cipher is real but not the prose's cipher.**
  `UV5_SaveGameSubsystem` genuinely encrypts every save with a per-account,
  per-platform-derived key and validates integrity on load — but it is a SHA-1
  keystream XOR, **not** the AES the design notes imply, and the "Oodle Kraken
  compression" the architecture names is **not** in the shipped code. Encryption
  and the integrity check are real; compression is spec.
- **There are two backend code paths, and the live one is real.**
  `evaluateServiceSpecificRule` still returns the old `jwt.v5.<account>.oauth`
  fixture string for contract tests — but the deployed `service.handle()` calls
  `handleRealRequest` → `executeRealServiceRequest`, the Postgres/Redis-backed
  path. The fixture path is for golden contracts; the real path is what runs.
- **Kernel anti-cheat is a planned client tier.** The client builds EAC signal
  requests and the server keeps a real strike journal, but the EAC kernel driver
  itself is named, not in-tree — exactly as V4 and V2 label it.

## The online backbone

### The service catalog and the HTTP seam

Gameplay code never hand-rolls a URL. `BuildEndpointCatalog` enumerates all 16
services — auth, friends, parties, matchmaking, leaderboards, replays,
telemetry, anti-cheat, crash-reporting, companion-app-bridge,
mindpalace-cloud-sync, workshop, compliance-dsar, live-service-calendar,
balance-ledger, faction-rep — each as an `FV5OnlineEndpointDefinition` carrying
its verb, path, `bRequiresJwt`, and `bSupportsOfflineQueue` flags. A
`UV5_Online_*` builder fills an `FV5OnlineServiceRequest` (account, JWT, region,
JSON body, per-cell path slug), and `FV5OnlineHttp::BuildHttpRequest` constructs
the real `IHttpRequest`: it sets the verb, an `Authorization: Bearer` header
when a JWT is present, `X-V5-Account` and `X-V5-Region` routing headers, and the
JSON body for non-GET methods. Base URL resolution is fail-soft and never
fabricated — `V5_ONLINE_BASE_URL` is treated as an API gateway, else
`V5_ONLINE_HOST` plus the service's catalog port (`4210 + service index`).

The latent node `UV5_Online_HttpRequest::Activate` is where the fail-loud
discipline shows: a request whose endpoint `bRequiresJwt` but whose token is
empty **short-circuits to a 401 before touching the network**
(`auth.jwt.required`), a transport failure yields `TransportFailureResponse`
(`service.unreachable`, status 0), and a 503 sets `bQueuedOffline` from the
endpoint's offline-queue flag. No path fabricates a success it did not receive.

### Identity, JWT, and PKCE

`UV5_Online_AuthService::IssueOAuthJwt` mints a genuine HS256 token. It
assembles RFC 7519 registered claims (`iss`, `sub`, `aud`, `provider`, `iat`,
`nbf`, `exp`, `jti`) with a 3,600-second TTL, derives the `jti` from
`SHA-256(account|provider| iat)` so each issuance is unique yet deterministic,
signs with `FV5JwtCrypto::IssueHs256`, and derives an **opaque refresh token**
as `HMAC(SHA-256(key.refresh), SHA-256(accessJwt))` — revocable server-side and
not user-forgeable. `VerifyAccessJwt` does the real inverse: a constant-time
signature comparison plus `exp`/`nbf` enforcement. The tests prove behaviour,
not shape: an access token issued at `t=1000` is a 3-part compact JWT, verifies
at `t=1000`, is **rejected** at `t=4600` (past its `exp`), and a tampered
signature fails. `VerifyPkceChallenge` enforces RFC 7636 §4.1 length bounds
(43–128) and checks `BASE64URL(SHA256(verifier)) == challenge`.

Server-side, `AuthDomain` (`domain/auth.ts`) is Postgres-backed and stricter
still. `resolveCanonicalAccount` enforces **oldest-verified-credential-wins**:
the first verified `(platform, externalSubject)` pair binds a canonical
`v5acct.…` id, and later exchanges for the same pair return that id — one human
cannot fork into multiple V5 accounts per platform. `refreshAccessToken`
implements RFC 6749 §6 rotation with **RFC 6819 reuse detection**: presenting an
already-rotated token sets every row in the family to `revoked = TRUE` and
returns `reuse_detected`. Refresh tokens are stored only as SHA-256 hashes,
never cleartext. The Vitest suite asserts a stable canonical id across
exchanges, a `pkce_failed` on a bad verifier, and a family-wide revocation on
replay.

### Matchmaking and the skill model

The client builders are deliberately thin — `BuildQueueRequest` and
`BuildCrossplayQueueRequest` carry mode, region, integer `SkillRating`, ping,
and (for cross-play) `Platform`, `ModeId`, and `bCrossplayOptOut` to a per-cell
`/v5/matchmaking/{cell}/queue` path. The authority is server-side and genuine.
`MatchmakingDomain` (`domain/matchmaking.ts`) runs a real **Glicko-2** update
(Glickman 2013): `glicko2Update` computes the `g(φ)` and expected-score terms,
then solves the volatility step with an **Illinois (regula falsi) root finder**
— and the test pins it to the canonical worked example,
`toBeCloseTo(1464.06, 1)` / `toBeCloseTo(151.52, 1)` / `toBeCloseTo(0.05999, 4)`
for a 1500/200/0.06 player against three opponents. The queue is a **Redis
ZSET** banded by `cell:region:mode:skillBand:pingBand` (200-wide skill bands,
three ping bands), ordered by enqueue time; `formMatch` uses an **atomic
`ZPOPMIN`** so two formation passes never claim the same player, and re-enqueues
what it took if a rare race leaves it short.

### Anti-cheat and integrity

Anti-cheat is defended in depth and labelled by tier. The client
`UV5_Online_AntiCheatClient` builds eight distinct, dedicated-endpoint requests
— EAC session signal (`requiresEAC: true`), aim plausibility, sub-tick aimbot
snap, line-of-sight wall-hack history, position-delta speed-hack, auto-fire
interval pattern, a weighted classifier carrying `modelId: V5_AntiCheat_ML`, and
an appeal routed to `/companion/support/anti-cheat-appeal`. Server-side,
`AntiCheatStore` (`domain/anti-cheat.ts`) makes this stateful: `recordSignal`
journals each flagged detector to Postgres and recomputes the account's running
strike count over verdicts `≥ strike`, driving the three-strike ladder
(`warning` → `ranked-suspension` → `ban-review`); `fileAppeal` queues a
`queued-human-review` row on the companion-app path. The governing rule matches
the platform posture: model output **prioritises a review queue**, it never
silently disciplines.

```mermaid
flowchart LR
  subgraph Client["V5 client · Unreal C++"]
    CAT["V5OnlineServices<br/>16-endpoint catalog · UV5_Online_*"]
    JWT["FV5JwtCrypto<br/>SHA-256 · HMAC · HS256 · PKCE S256"]
    HTTP["FV5OnlineHttp / UV5_Online_HttpRequest<br/>real FHttpModule transport"]
    SAVE["V5Core save<br/>UV5_SaveGame · encrypted envelope · 3 generations"]
    PERS["V5Persistence<br/>CurrencyLedger · MindPalace · Replay · NG+"]
  end
  JWT --> CAT
  CAT --> HTTP
  HTTP -->|Bearer JWT · X-V5-Account · region| GW[API gateway · V5_ONLINE_BASE_URL]
  SAVE -. IOnlineUserCloud .-> CLOUD[(platform cloud save)]
  GW --> SVC["@v5/service-shared · NestJS<br/>handleRealRequest → executeRealServiceRequest"]
  SVC --> AUTH[auth · AuthDomain<br/>PKCE · JWT · refresh rotation]
  SVC --> MM[matchmaking · Glicko-2<br/>Redis ZSET queue]
  SVC --> AC[anti-cheat · strike journal]
  SVC --> MORE[friends · parties · leaderboards<br/>replays · workshop · …16]
  AUTH & MM & AC & MORE -->|configured?| STORE[(Postgres · Redis<br/>else 503 not_configured)]
```

The diagram's last edge is the most important honesty seam.
`resolveBackingClients` hands a service live clients only when its catalog-
declared `V5_POSTGRES_URL` / `V5_REDIS_URL` resolve to non-empty values;
otherwise it returns `configured: false`, and `executeRealServiceRequest` serves
the **503 outage** contract. `buildHealth` reports `not_configured` in that case
rather than the old hardcoded `serviceHealthy: true` that made the outage branch
dead code. Absence is reported loudly, never faked.

## Persistence and save

### The save model and the encrypted envelope

`UV5_SaveGame` (in `V5Core`) is the model behind every cell's save: a
`USaveGame` at `CurrentSaveVersion = 2` (down to
`MinimumSupportedSaveVersion = 1`) whose `FV5SaveDocument` namespaces an
`FV5CellSaveSlot` per cell — Urban, Period, Frontier, Hunter, Sci-Fi, Steampunk,
and Mind Palace — alongside the account id, active cell, completed-cells set,
and the shared Bureau XP ledger. `ImportFromJsonString` runs
`MigrateLoadedDocument`, so a schema bump is a versioned migration, not an
ad-hoc rewrite.

Durability is real. `UV5_SaveGameSubsystem::SaveProfile` serialises to JSON,
then `EncryptJsonPayload` builds an `FV5EncryptedSaveEnvelope`: a GUID nonce, a
plaintext SHA-1 for integrity, a `KeyId`, and a hex cipher produced by
`ApplyKeyStream` XOR-ing the bytes against a counter-mode SHA-1 keystream. The
key material is **per-account and per-platform** — `BuildPlatformKeyMaterial`
derives it from the `IOnlineSubsystem` identity (the unique net id, falling back
to a hashed auth token, falling back to a local device id) prefixed by the
account. On load, `DecryptEnvelopeBytes` rejects an envelope whose account/slot,
`KeyId`, or recomputed plaintext SHA-1 does not match — a save encrypted under a
different platform key fails loudly rather than loading garbage.
`RotateGenerations` keeps the **triple-redundant**
`Current → Previous → Fallback` chain the architecture specifies, and
`LoadProfile` walks the three in order, auto-promoting a recovered older
generation back to current. Cloud sync is a real
`IOnlineUserCloud::WriteUserFile`, and it fails loud — "platform cloud
unavailable: no online subsystem" — when no platform cloud is registered.

### The currency ledger and Bureau XP

`UV5_Persist_CurrencyLedger` is an audit-trailed, per-cell ledger.
`BuildStartingLedger` seeds each cell with its native currency — Urban/Period
Cash, Frontier Gold, Hunter Crowns, Sci-Fi Credits, Mind Palace Bureau Scrip —
and `ApplyTransaction` mints a deterministic `TransactionId`, **rejects any
transaction that would drive a balance negative**, updates lifetime
earned/spent, and appends every movement (accepted or not) to a `Journal` so the
balance is reconstructable. The connective progression record is
`FV5BureauXPLedger`: a `TotalXP`, a per-cell XP map, and a tier computed
monotonically as `CurrentTier = max(1, TotalXP / 2500 + 1)` — which is exactly
why New Game+ gates on tier 20 (47,500 XP). This is the single object
cross-progression syncs as the account's shared spine.

### Replays, New Game+, and save archaeology

`UV5_Persist_ReplaySerialization` builds a versioned deterministic replay packet
whose exact binary canonical form binds replay/account/key identifiers, issuance
and expiry, a base64url nonce, every frame field, IEEE float bits, event kind,
and ordered tags. The packet carries a SHA-256 payload digest and HMAC-SHA256
signature; verification uses the shared `V5IntegrityCore` and constant-time
digest comparison. `UV5ReplayIntegritySubsystem` fails closed unless an external
key-custody resolver and an atomic durable nonce consumer are configured, clears
transient key bytes after use, rejects future/expired packets and consumed
nonces, and retains bounded receipt-linked audit events. This makes the
in-memory nonce window defense-in-depth rather than the durable source of truth.

`UV5_Persist_NewGamePlus` encodes the Year-1 NG+ plan as data and validates it
in code: five launch cells, ≥ 2 story branches and ≥ 3 difficulty modifiers each
(≥ 10 and ≥ 15 totals), a strict carry-over set (Bureau XP, cosmetics, codex,
photo mode, Mind Palace archive) against a reset set (critical-path state,
economy, wanted heat, bounty debt), and `bNoPaidPowerCarryover` so purchased
power never crosses a reseed. `UV5_Persist_SaveSlotArchaeology` imports an old
v1/v2 save, reads its `ending.*` flags, and triggers the matching NG+ branch —
ten authored scenarios, each verified to read an ending flag and target an
`ngplus.*` branch — so a finished campaign measurably reshapes the replay.

## Cross-play & cross-progression

Cross-play decides _who matches whom_; cross-progression decides _what travels_.
Both are policy-rich, and the policy is a checked-in artifact:
`V5/crossplay/crossplay-progression-policy.json`. It names a **single canonical
account** (`idPrefix: v5acct`, `singleAccountIdRequired: true`,
`conflictPolicy: oldest_verified_credential_wins`) with nine platform
credentials — Steam on Windows/Linux, Apple on Mac/iOS, PSN on PS5/PS5 Pro, Xbox
Live on XSX/XSS, Nintendo on Switch 2 — each with its credential type and
required OAuth scopes. That policy is not prose:
`AuthDomain.resolveCanonicalAccount` enforces the oldest-credential rule
server-side, and the client mirror `BuildPlatformCredentialLinkRequest` binds an
external subject to the canonical `v5acct.…` id.

Four **progression domains** carry explicit, service-owned merge policies:
`profile` (auth, last-verified-write-wins), `cosmetics` (companion-app-bridge,
entitlement union), `bureauXp` (companion-app-bridge, monotonic ledger sum), and
`mindPalace` (mindpalace-cloud-sync, node last-writer-wins with **accusations
append-only**). The last is backed by real CRDT code: `mindpalace-cloud-sync.ts`
merges evidence nodes by version-vector causal dominance with a deterministic
`(updatedUnix, lastWriter)` tiebreak, and unions accusations — commutative and
idempotent, so re-syncing the same diff is a no-op. **Cross-play is on by
default** across all nine platforms; the single per-cell opt-out is Sci-Fi
spaceship PvP (`mode.scifi.spaceship-pvp`), and the runtime honours it precisely
— an opted-out ticket routes to a `platform-only` pool, every other mode stays
`crossplay`, gated for input parity rather than blanket console segregation.
Companion save transfer is a signed, encrypted-at-rest bundle
(`v5-save-transfer-bundle-v1`) with a
`newer_slot_version_wins_with_manual_review` import policy, exercised by five
named QA scenarios.

## Where this connects

- **Down to the match:**
  [Netcode, Authority & Determinism](./netcode-authority-and-determinism.md) —
  the deterministic sim the replay-determinism digest and server-validated score
  writes depend on, and the dedicated-server authority the cross-cell world runs
  under.
- **Sideways to live service:** [Live Service & DLC](./live-service-and-dlc.md)
  — the seasonal calendar, persistent world events, workshop marketplace, and
  faction-rep decay that ride this same backbone and ledgers.
- **Platform foundations:**
  [Persistence & Data](../../platform/persistence-data.html) — the Postgres /
  Redis / object-storage substrate these services are specified to deploy onto,
  and the account-root and data-residency posture they inherit.
