# ADR 0033. Partner KPI Platform: Visibility Tiers, Anonymous Comparison, Cube Behind Nest

## Status
Accepted.

Extends [ADR 0026](0026-analytics-subject-and-provider-cohort.md) (distinct
Account counts and the Account floor) from studio analytics to every
partner-facing KPI. Adds HMAC request signing alongside the machine OAuth in
[ADR 0016](0016-bff-cookies-and-machine-oauth.md), for KPI reads only.
Authorization stays application-native ([ADR 0029](0029-application-native-authorization.md)).
The event log stays Phase 1 under [ADR 0005](0005-phased-event-log-infrastructure.md).

## Context
Operators, providers and affiliates each want KPIs about the players linked
to them. They also want to know how those players compare with the wider
PlayerPal network and with their competitors. Every portal dashboard number
is mock data today, and no table records funnels, logins or data access.

Four constraints shape the design:

1. **Scope.** A party may only see players linked to it. The existing studio
   endpoints trust a caller-asserted `x-party-id` behind one shared machine
   key, so any key holder can claim any party.
2. **Anonymity.** Comparison figures must never let a party identify a
   competitor. This applies directly (names, ranks) and by inference: if a
   market has two operators, "network minus you" reveals the other one, and
   if one operator holds most of a market, a "market average" is in effect
   that operator's figure.
3. **Cost.** No paid BI and no heavy new stateful platform. Metabase was
   rejected because it needs a JVM pod, a metadata database and, ideally, a
   read replica.
4. **Market dimension.** Country is needed for comparisons, but PlayerPal
   never stores player PII ([ADR 0012](0012-never-receive-player-pii.md)).

## Decision
1. **Four visibility tiers.** Every KPI value belongs to exactly one tier:
   - **Own:** the party's own players (operator: active links; provider:
     Accounts reached through its integrations).
   - **Network comparison:** your players (Y) against the rest of the
     PlayerPal network (R = all active Accounts minus Y), expressed as
     per-player rates, an index and a percentile band.
   - **Benchmark:** anonymous peers of the same party type.
   - **Network:** staff only.

   Definitions are in [docs/kpis.md](../kpis.md).
2. **Scope comes from the authenticated principal, never from the request.**
   Partner routes ignore `party_id` in query strings and in `x-party-id`
   headers.
3. **Anonymity rules**, enforced in Nest guards at write time (suppressed
   segments store no values) and again at read time:
   - **Account floor:** at least 50 distinct Accounts per cell (ADR 0026).
   - **Peer floor:** at least 5 other parties of the requester's type in the
     comparison population.
   - **Dominance rule:** suppress the segment if any single other party
     contributes more than 50% of the measure, or the top two more than 75%.
   - **Statistics only:** p25, p50, p75, mean, and the requester's
     percentile band. Never min/max, exact rank, per-peer values or names.
   - **No absolute network totals for partners.** Network size is shown only
     as a band.
   - **Fixed segments only:** closed weeks and months, and markets from the
     catalogue. No arbitrary ranges or ad-hoc filters, so a party cannot
     difference two segments.
   - A segment that fails a rule falls back from market to global, or is
     suppressed with a reason.
   - The thresholds are configuration (`BENCHMARK_MIN_PEERS`,
     `BENCHMARK_MIN_ACCOUNTS`, `BENCHMARK_DOMINANCE_P`, `BENCHMARK_DOMINANCE_P2`).
     Production must not set them below the values above.
4. **Market comes from activity, not the player.** Providers and operators
   report an ISO 3166-1 alpha-2 `market` (the licensed jurisdiction) per
   activity event or batch, and it is checked against the integration's
   declared markets. A player's market is derived per day from their
   activity and is never stored on the identity.
5. **Domain event log.** Funnels, logins, linking, credential changes and
   KPI data access are recorded in `domain_events`, an append-only table
   written in the same transaction as the state change. This is Phase 1 of
   ADR 0005. It records no PII, and Delete Account nulls `player_id` and
   keeps the event.
6. **Cube Core as an internal semantic layer behind Nest.**
   - Cube is an app service (`services/cube`): self-hosted, Apache 2.0, a
     single container, with pre-aggregations stored in Postgres.
   - It holds the metric definitions and does the caching.
   - Cube is never reachable by partners. Nest maps each public catalogue
     request to a fixed Cube query and signs a short-lived internal JWT that
     carries the principal's scope.
   - As defence in depth, Cube's `queryRewrite` also enforces the party
     filter.
   - Staff ad-hoc work uses Cube's SQL API over a port-forward.
7. **Partner KPI authentication.**
   - **OAuth 2.0 client credentials** (ADR 0016) is the default.
   - **HMAC-SHA256 request signing** is an additional scheme for partners
     who cannot run OAuth.
     - Per-party keys, with two active at once for rotation.
     - Secrets are encrypted at rest.
     - Each request carries a timestamp and a nonce, and is rejected if the
       timestamp is more than 5 minutes off or the nonce was seen before.
   - **Portal sessions** cover the browser.
   - All three resolve to one principal (party, party type, client, scopes)
     checked by the same guard.

## Consequences
- The studio analytics endpoints take the provider from the partner
  principal. The legacy shared key with `x-party-id` stays behind
  `STUDIO_LEGACY_PARTY_HEADER` (default on, so local demos keep working) and
  must be set to `false` in production. The response shape is unchanged.
- Comparisons can be suppressed in thin markets. Early on, with only a
  handful of operators per market, partners will mostly see global
  figures. This is intended.
- KPI definitions live in `services/cube/model` and are tested against
  hand-written SQL. `docs/kpis.md` must be kept in sync with them.
- Network comparisons and peer benchmarks are computed by a job in the API
  process, directly over the same `analytics` views the Cube model uses,
  because per-player distributions and per-requester populations are not a
  good fit for Cube. The anonymity rules live in one pure module
  (`analytics/compare/comparison.math.ts`) and are applied before anything
  is stored.
- One more service to run (Cube). If volume grows, Cube Store or ClickHouse
  can sit behind Cube without changing the public API.
- HMAC adds a secret type that must be decrypted to verify, so a master-key
  rotation procedure is needed before production.
- Glossary additions: Visibility tier, Network comparison, Market.
  Day-to-day reference: [docs/kpis.md](../kpis.md).
