Skip to content

Multi-Account Detection

Status: ๐Ÿšง Partial โ€” MultiAccountCluster / MultiAccountFlag models are committed (models/multi_account.py, 183 lines), and message_beacon_service.py already consumes MultiAccountFlag for a HARD-severity gate. The detection sweep, signal-scoring, and admin review UI are โœ… Shipped (multi_account_detection_service.py's upsert_cluster/run_detection_sweep, HARD-paypal/SOFT-IP/SOFT-fingerprint scoring, wired hourly into the scheduler's core loop; full admin CRUD at /admin/multi-account/clusters; MultiAccountReview.tsx admin-ui page). Soft-tier 0.5ร— discount + all_paid_subscribers exemption in participation_weight are also โœ… Shipped (LEG-256 / PR #668 โ†’ tip 9dc7ed12). Remaining Partial: no surface argument on participation_weight, and planet-scoped soft refinements for eligible_for_contest (see Service contract). (Re-verified 2026-08-21 vs origin/feat 46bce720 โ€” tip delta Admin Soft-ORDER #789/#788 honesty only; Soft-HOLD residuals unchanged.)

๐Ÿšง Partial. Per ADR-0056. Shared infrastructure that surfaces clusters of accounts likely operated by the same human, then downgrades or blocks their participation in surfaces where alt-rings cause measurable harm: governance voting, station-takeover volume metrics, message-beacon visibility, and faction-rep farming.

Principle: payment is the legitimacy gate

The multi-account exploit gets its leverage from being cheap. One human running 10 free-tier alts is a meaningfully different actor from one human paying 10ร— $5/mo for 10 Galactic Citizen subscriptions. The first is gaming the system; the second is a paying customer with multiple personas. The detection layer is subscription-tier aware:

  • All clustered accounts have active paid subscriptions (Galactic Citizen or Region Owner): no block, no discount. Soft signals still surface for monitoring but do not penalize.
  • At least one free-tier account in the cluster: hard signals block, soft signals discount the free account's participation weight.

This means a household of two paying players sharing a payment method gets full participation; a single human running five free alts on the same fingerprint does not.

Cluster signals

Signal Class Meaning
Same payment method on file hard Same credit card, PayPal billing agreement, or other PSP-side identifier across multiple accounts. PSP returns a stable hash; we never store raw card numbers.
Same active session token across different player IDs hard A single browser session authenticated as two players within the same hour. Should never happen legitimately โ€” session-share or account-sale signal.
Same IP address within 24h soft Common for shared households, coffee shops, dorm rooms. Cluster signal but not a confidence signal alone.
Same device fingerprint hash soft Canvas + WebGL + timezone + audio-ctx + UA-CH composite hash. Stable across sessions on the same device.
Perfectly-correlated trade timings soft Two accounts trading reciprocally within a tight time window, repeated. Behavioural โ€” runs as a periodic batch.
Perfectly-correlated voting patterns soft Two accounts voting identically across multiple unrelated proposals. Behavioural โ€” runs at election close.

Hard signals trigger immediately on the gated action (team formation, payment update). Soft signals run as a periodic batch every hour; the runtime worker is a Batch-6 service with no need for real-time evaluation โ€” alt-rings don't form in seconds.

Discount math

For every gated participation surface, the per-account multiplier is:

def participation_weight(player_id, surface):
    flag = MultiAccountFlag.most_severe_for(player_id)
    if flag is None:
        return 1.0  # no cluster, full weight
    if all_paid_in_cluster(flag.cluster_id):
        return 1.0  # paid-tier exemption
    if flag.severity == "hard":
        return 0.0
    if flag.severity == "soft":
        return 0.5

Surfaces that consume the weight:

  • Regional governance vote: RegionalVote.weight = membership.voting_power ร— participation_weight(player_id, 'governance'). Per ../FEATURES/gameplay/regional-governance.md.
  • Station-takeover volume metric: free-tier accounts in a flagged cluster contribute to volume at the discounted weight. Hard-flagged accounts contribute 0 โ€” the syndicate-volume exploit collapses.
  • Message-beacon visibility: free-tier accounts in a flagged cluster have beacon weight 0ร— (their beacons don't count toward the per-sector cap and aren't surfaced). Per ../FEATURES/gameplay/message-beacons.md.
  • Faction-rep gain: free-tier accounts in a flagged cluster have faction-rep deltas multiplied by the participation weight. Hard flag โ†’ no rep gain at all on cluster-coordinated actions.

Service contract

Shipped today โ€” services/gameserver/src/services/multi_account_service.py:

def participation_weight(db: Session, player_id: uuid.UUID) -> float
def blocks_vote(db: Session, player_id: uuid.UUID) -> bool
def eligible_for_contest(db: Session, player_id: uuid.UUID, planet_id: uuid.UUID) -> bool

participation_weight implements the live discount ladder (ADR-0056 E-V5 / Discount math above): no flag โ†’ 1.0; cluster all_paid_subscribers โ†’ 1.0; most-severe HARD โ†’ 0.0; most-severe SOFT โ†’ 0.5. No surface parameter yet โ€” callers share one weight. blocks_vote wraps it for the ADR-0056 N-V3 franchise gate (weight == 0.0).

eligible_for_contest is the ADR-0091 ยง8 Amendment A settle_contest surface โ€” a gated participation-weight query for planet-settlement contests, additive to ADR-0056's detection model. v1: ineligible when participation_weight == 0.0 (HARD, non-exempt); SOFT (0.5) and clear (1.0) remain eligible. The planet_id parameter exists for API-shape parity with the ADR's per-planet-scoped surface (soft-flag tie-loss / no-relief-between-linked-accounts refinement is still unbuilt); the underlying detection remains account-scoped, not planet-scoped, so it's unused today.

โœ… Shipped โ€” MultiAccountDetectionService (services/gameserver/src/services/multi_account_detection_service.py):

def upsert_cluster(...) -> MultiAccountCluster        # signal-scored, HARD-paypal / SOFT-IP / SOFT-fingerprint
def run_detection_sweep(db, *, now=None) -> dict       # wired hourly via scheduler/core_loop.py (state key multi_account_detection_last_run_at)
class MultiAccountDetectionService: ...

Admin review + decision routes: /admin/multi-account/clusters (list/get/decide with confirm/override/escalate semantics) in api/routes/admin_multi_account.py, surfaced in admin-ui's MultiAccountReview.tsx.

Still unbuilt (Partial residue): surface-scoped weights on participation_weight, and planet-scoped soft refinements on eligible_for_contest (planet_id accepted but unused). Soft-tier 0.5ร— + all_paid_subscribers exemption are โœ… shipped.

Schema

MultiAccountCluster and MultiAccountFlag live in ../DATA_MODELS/gameplay.md.

Admin review surface

The admin UI (per ./admin-ui.md) gets a Multi-Account Review page that lists every active cluster with:

  • Cluster signals (which heuristics fired) and their severity classes.
  • Each account's subscription tier, age, recent activity summary.
  • A decision panel: Confirm (cluster is real, apply discounts), Override (legitimate household / shared connection โ€” clear flags), Escalate (deeper review).
  • Audit log of admin decisions for compliance.

Decisions update MultiAccountCluster.admin_decision and propagate to all member MultiAccountFlag rows. An admin override clears flags for that cluster permanently โ€” re-detection requires a new signal.

Pre-existing alt rings

The detection job runs against historical session data on first deploy, generating an initial backlog of clusters. Admin reviews and confirms / overrides each one before any discounts apply. After the backlog is cleared, new clusters surface continuously.

Source map

Concern File
MultiAccountDetectionService services/gameserver/src/services/multi_account_detection_service.py โœ…
Cluster + flag models services/gameserver/src/models/multi_account.py โœ…
Periodic detection sweep run_detection_sweep, wired hourly in services/gameserver/src/services/scheduler/core_loop.py โœ… (not a standalone job file โ€” runs inline in the core scheduler loop)
Admin review routes services/gameserver/src/api/routes/admin_multi_account.py โœ…
Admin review UI services/admin-ui/src/components/pages/MultiAccountReview.tsx โœ…
Soft-discount math participation_weight, services/gameserver/src/services/multi_account_service.py โœ… (soft 0.5ร— + all_paid_subscribers; no surface arg yet)