Skip to content

Play Context Domain for Consumer Context Service

Play Context Domain for Consumer Context Service

Section titled “Play Context Domain for Consumer Context Service”

Rewards Assistant and other consumer-facing AI agents have no visibility into a user’s Fetch Play activity. Play is a major engagement surface where users earn points by installing/playing external games (Adjoe milestones) and a daily word-guessing mini-game (Daily Guess). Users cannot currently ask Rewards Assistant about their Play progress, available opportunities, streak status, or active promotions.

Without Play context, Rewards Assistant provides an incomplete picture of the user’s Fetch experience. Users miss actionable opportunities — expiring rewards, streak risks, available milestones worth hundreds of points, and active 2.5x promotions — because the AI agents simply don’t know about them.

CCS already aggregates 12+ upstream services across Products, Offers, and Users domains. Play is the most significant missing domain.

CCS has three established domain patterns:

  • Products (internal/products/) — enriches product data from FPS, FIDORA, Button, Retailer services
  • Offers (internal/offers/) — offer lookup from Offer Guardian, Neli, LIDAR, Offer Search
  • Users (internal/users/) — purchase history and repurchase candidates from Neo4j, Purchase History Service

Each domain follows the same pattern: types in types.go, service orchestration in service.go, client adapters in internal/clients/, thin REST handlers in internal/api/handler.go, and thin MCP tools in internal/mcp/tools.go.

Play data exists across three RAPPS-collective services but has never been integrated into CCS.

  1. Direct service calls, not BFF — The rewarded-apps-bff aggregates Play data for mobile clients using proto/Connect RPC. CCS will call downstream services directly because the BFF adds unnecessary overhead and we need raw data for AI context, not pre-rendered UI sections. See RFD.

  2. play-support-service in scope — Despite data overlap with gameplay-service, play-support-service provides pre-aggregated app summaries, app names (not available from gameplay-service), and support award history. See RFD section “play-support-service: Why It’s In Scope”.

  3. REST polling for initial implementation — Event-driven integration (SNS/Kafka) is deferred. REST polling matches the current CCS pattern and is sufficient for request-time freshness. Event-driven approach may be explored in a future spec.

TermDefinition
AdjoeThird-party gaming rewards platform powering Fetch Play milestone games
MilestoneA progress marker in an external game (e.g. “Reach Level 50”) that awards points when completed
Daily GuessFetch’s daily word-guessing mini-game with streak tracking and leaderboards
Timed CoinsBonus coins awarded for milestone completion that expire after a set duration
Sequential MilestoneMilestones that unlock in order (index 1, 2, 3…)
Bonus MilestoneOne-time milestones (e.g. “Make Your First Purchase!”)
OpportunityA derived, actionable item surfaced to the user (e.g. “your streak is at risk”)
RAPPSRewarded Apps — the Fetch collective that owns Play services
  1. The system MUST retrieve a user’s Play lifetime summary (total points, games installed, last transaction date, last install date, new user flag) from gameplay-service.
  2. The system MUST retrieve per-game milestone progress including status (COMPLETED/AVAILABLE/UNAVAILABLE/EXPIRED), point amounts, descriptions, expiration dates, and milestone types.
  3. The system MUST retrieve available milestones across all installed games via the nextUp endpoint.
  4. The system MUST retrieve completed and expired app lists with per-app point totals.
  5. The system MUST retrieve the current active promotion (multiplier, date range) or indicate no promotion is active.
  6. The system MUST retrieve Daily Guess mini-game stats including current streak, max streak, best score, attempts histogram, and whether the user has played today.
  7. The system MUST retrieve recently completed mini-games with reward amounts.
  8. The system MUST retrieve the user’s Play overview from play-support-service including total points earned, app list with names and status, and support award counts.
  9. The system MUST retrieve per-app history from play-support-service including app names, total points, milestone counts, install dates, and activity timestamps.
  10. The system MUST derive and rank actionable opportunities from the raw Play data (see Section 4.4).
  11. The system SHOULD expose Play context via both REST API and MCP tool.
  12. The system MUST degrade gracefully when any upstream service is unavailable — return partial context with nil fields rather than failing the entire request.
  13. The system MUST call upstream services in parallel where possible to minimize latency.
  14. The system MAY cache promotion data with a longer TTL than user-specific data.
  1. Play context retrieval MUST complete in < 2000ms at p95 (aggregating 3 upstream services).
  2. Individual upstream client calls MUST timeout at 1500ms.
  3. The system MUST handle users with 0 Play activity (new/non-Play users) without errors.
  4. Cached promotion data SHOULD have a TTL of 15 minutes.
  5. Cached user/milestone data SHOULD have a TTL of 5 minutes.
  6. Mini-game stats SHOULD NOT be cached (streak accuracy matters for opportunity detection).
  7. The system MUST NOT increase CCS memory usage by more than 50MB under steady-state load.
  • AC-1: Given a user with Play activity, when GetPlayContext(userId) is called, then the response includes lifetime points, at least one game with milestone data, and mini-game stats.
  • AC-2: Given a user with no Play activity, when GetPlayContext(userId) is called, then the response returns a valid PlayContext with zero points, empty games list, nil MiniGame, and no opportunities.
  • AC-3: Given a user with an active 31-day Daily Guess streak who has not played today, when opportunities are derived, then a “Streak at Risk” opportunity is included with the current streak count.
  • AC-4: Given a user with available milestones worth 240 points and an active 2.5x promotion, when opportunities are derived, then an “Available Milestone” opportunity is included with effective value of 600 points.
  • AC-5: Given a user with timed coins expiring within 24 hours, when opportunities are derived, then an “Expiring Timed Coins” opportunity is included.
  • AC-6: Given a user with a game where 90%+ milestones are completed, when opportunities are derived, then an “Almost-Complete Game” opportunity is included.
  • AC-7: Given gameplay-service is unavailable but mini-game-service and play-support-service respond, when GetPlayContext(userId) is called, then the response includes MiniGame and Overview data with nil User and empty Games list.
  • AC-8: Given a REST request to GET /play/{userId}, then the response is valid JSON matching the PlayContext schema.
  • AC-9: Given an MCP tool call for get_play_context with a userId, then the response contains the same data as the REST endpoint.
  • AC-10: Given a user with games that have expiration dates approaching within 7 days and unclaimed milestones, when opportunities are derived, then an “Expiring Game Rewards” opportunity is included with the total unclaimed points.
  • AC-11: Given a user with milestones containing cashbackConfig, when opportunities are derived, then a “Cashback Offer” opportunity is included with the exchange rate and max limit.
  • AC-12: Given a user with repetitiveRemainingCount > 0 on a milestone, when opportunities are derived, then a “Repeatable Milestone” opportunity is included with the remaining count.
┌─────────────────────┐
│ Rewards Assistant / AI Agent │
└──────────┬──────────┘
┌───────────────┼───────────────┐
│ │ │
REST :8080 MCP :8081 (future gRPC)
│ │
└───────┬───────┘
┌────────▼────────┐
│ play.Service │
│ GetPlayContext() │
└────────┬────────┘
│ parallel calls
┌─────────────┼─────────────┐
│ │ │
┌─────────▼───┐ ┌─────▼─────┐ ┌────▼──────────┐
│ Gameplay │ │ MiniGame │ │ PlaySupport │
│ Client │ │ Client │ │ Client │
│ │ │ │ │ │
│ GET /user │ │ GET /stats│ │ GET /overview │
│ GET /nextUp │ │ GET /games│ │ GET /history │
│ GET /apps │ │ │ │ │
│ GET /promo │ │ │ │ │
└─────────────┘ └───────────┘ └───────────────┘
internal/play/types.go
type PlayContext struct {
User *PlayUser `json:"user,omitempty"`
Overview *PlayOverview `json:"overview,omitempty"`
AppHistory []AppHistory `json:"appHistory,omitempty"`
Games []GameProgress `json:"games,omitempty"`
MiniGame *MiniGameStats `json:"miniGame,omitempty"`
Promotion *ActivePromotion `json:"promotion,omitempty"`
Opportunities []PlayOpportunity `json:"opportunities,omitempty"`
}
type PlayUser struct {
TotalPoints int `json:"totalPoints"`
NumberOfGamesInstalled int `json:"numberOfGamesInstalled"`
LastTransactionDate *time.Time `json:"lastTransactionDate,omitempty"`
LastInstallDate *time.Time `json:"lastInstallDate,omitempty"`
IsNewUser bool `json:"isNewUser"`
}
type PlayOverview struct {
TotalPointsEarned int `json:"totalPointsEarned"`
Platform string `json:"platform,omitempty"`
TotalMissingMilestonePointsEarned int `json:"totalMissingMilestonePointsEarned"`
MissingMilestoneSupportAwardedCount int `json:"missingMilestoneSupportAwardedCount"`
TotalManuallyCompletedPoints int `json:"totalManuallyCompletedPoints"`
ManuallyCompletedCount int `json:"manuallyCompletedCount"`
Apps []PlayApp `json:"apps,omitempty"`
}
type PlayApp struct {
AppID string `json:"appId"`
AppName string `json:"appName"`
Platform string `json:"platform"`
UserInstalled bool `json:"userInstalled"`
UserAppStatus string `json:"userAppStatus"`
}
type AppHistory struct {
AppID string `json:"appId"`
AppName string `json:"appName"`
Platform string `json:"platform"`
UserAppStatus string `json:"userAppStatus"`
TotalPointsEarned int `json:"totalPointsEarned"`
InAppMilestonesCompletedCount int `json:"inAppMilestonesCompletedCount"`
LastActivityTime int64 `json:"lastActivityTimeInSeconds"`
InstallDate *int64 `json:"installDateTimeInSeconds,omitempty"`
}
type GameProgress struct {
AppID string `json:"appId"`
AppName string `json:"appName,omitempty"`
Platform string `json:"platform"`
Status string `json:"status"`
TotalPoints int `json:"totalPoints"`
InstallDate int64 `json:"installDate"`
CompletedDate *int64 `json:"completedDate,omitempty"`
ExpirationDate *int64 `json:"expirationDate,omitempty"`
AllMilestonesCompleted bool `json:"allMilestonesCompleted"`
Milestones []Milestone `json:"milestones,omitempty"`
}
type Milestone struct {
MilestoneID string `json:"milestoneId"`
Description map[string]string `json:"description"`
PointAmount int `json:"pointAmount"`
Status string `json:"status"`
MilestoneType string `json:"milestoneType"`
Index int `json:"index"`
CompletedDate *int64 `json:"completedDate,omitempty"`
ExpirationDate *int64 `json:"expirationDate,omitempty"`
TimedCoins int `json:"timedCoins,omitempty"`
TimedCoinsExp *int64 `json:"timedCoinsExpiration,omitempty"`
CashbackConfig *CashbackConfig `json:"cashbackConfig,omitempty"`
RepetitiveRemaining *int `json:"repetitiveRemainingCount,omitempty"`
}
type CashbackConfig struct {
ExchangeRate float64 `json:"exchangeRate"`
MaxLimitPerCampaignUSD float64 `json:"maxLimitPerCampaignUSD"`
}
type MiniGameStats struct {
CurrentStreakDays int `json:"currentStreakDays"`
MaxStreakDays int `json:"maxStreakDays"`
BestScore int `json:"bestScore,omitempty"`
AttemptsHistogram map[string]int `json:"attemptsHistogram,omitempty"`
LastPlayedAt *time.Time `json:"lastPlayedAt,omitempty"`
StartedToday bool `json:"startedToday"`
SuggestedEntry string `json:"suggestedEntryPoint"`
CompletedGames []CompletedGame `json:"completedGames,omitempty"`
}
type CompletedGame struct {
GameID string `json:"gameId"`
ReceivedAt time.Time `json:"receivedAt"`
RewardAmount int `json:"rewardAmount"`
}
type ActivePromotion struct {
StartDate int64 `json:"startDate"`
EndDate int64 `json:"endDate"`
Multiplier float64 `json:"multiplier"`
PromotionType string `json:"promotionType"`
}
type PlayOpportunity struct {
Type string `json:"type"`
Description string `json:"description"`
PointsValue int `json:"pointsValue"`
EffectiveValue int `json:"effectiveValue,omitempty"`
Urgency string `json:"urgency,omitempty"`
AppID string `json:"appId,omitempty"`
AppName string `json:"appName,omitempty"`
ExpiresAt *int64 `json:"expiresAt,omitempty"`
Score float64 `json:"score"`
}

Response: 200 OK with PlayContext JSON body (schema above).

Error codes:

  • 400 — missing or invalid userId
  • 500 — all upstream services failed

Returns 200 with partial data if some upstreams fail (nil fields for failed services).

Input:

{
"userId": "60a578602b9aa31fe73b7506"
}

Output: Same PlayContext schema as REST.

Opportunities are derived after all upstream data is collected. Each opportunity type has a detection function and a scoring function:

TypeDetectionScore Formula
Available Milestonestatus == AVAILABLEpointAmount * multiplier
Expiring Timed CoinstimedCoins > 0 && timedCoinsExpiration < now + 24htimedCoins * (1 / hoursRemaining)
Active Promotionmultiplier > 11000 * multiplier (high base — affects all activity)
Almost-Complete GamecompletedMilestones / totalMilestones > 0.8remainingPoints * (completionRatio ^ 2)
Expiring GameexpirationDate < now + 7d && hasUnclaimedMilestonesunclaimedPoints * (1 / daysRemaining)
Streak at Risk!startedToday && currentStreakDays > 0currentStreakDays * 100
Daily Guess AvailablesuggestedEntryPoint in [StartStreak, ContinueStreak]500 (fixed — daily engagement)
Cashback OffercashbackConfig != nilmaxLimitPerCampaignUSD * 100
Repeatable MilestonerepetitiveRemainingCount > 0pointAmount * remainingCount

Opportunities are sorted by score descending. Top 10 returned by default.

gameplay-service returns appId only. play-support-service /app-history returns appName. The service MUST cross-reference app names from play-support-service data when building GameProgress entries. If play-support-service is unavailable, appName is omitted.

GetPlayContext(userId):
parallel:
1. gameplay-service GET /user
2. gameplay-service GET /nextUp?pageSize=50&pageNumber=0
3. gameplay-service GET /completedApps?pageSize=50&pageNumber=0
4. gameplay-service GET /expiredApps?pageSize=50&pageNumber=0
5. gameplay-service GET /currentPromotion
6. mini-game-service GET /game-stats?gameId=daily-guess
7. mini-game-service GET /completed-games
8. play-support-service GET /overview/{userId}
9. play-support-service GET /app-history/{userId}
then:
- Cross-reference app names from play-support-service into gameplay data
- Derive opportunities from combined data
- Sort opportunities by score
- Return PlayContext

All timestamps in the PlayContext response use the format from the upstream source to avoid lossy conversions:

  • gameplay-service milestone timestamps: Unix seconds (int64)
  • gameplay-service /user dates: ISO 8601 strings → parsed to time.Time
  • mini-game-service timestamps: RFC3339 strings → parsed to time.Time
  • play-support-service timestamps: Unix seconds (int64)

gameplay-service totalPoints String Parsing

Section titled “gameplay-service totalPoints String Parsing”

gameplay-service returns totalPoints as a string (e.g. "5000"). The client adapter MUST parse this to int using strconv.Atoi and handle parse errors gracefully (default to 0).

Spec IDWhat We Need From ItWhy
(none)First spec in the system
DependencyOwnerStatusBlocker?
gameplay-service REST APIRAPPS collectiveLive (stage + prod)No
mini-game-service REST APIRAPPS collectiveLive (stage + prod)No
play-support-service REST APIRAPPS collectiveLive (stage + prod)No
CCS staging deploymentPilot teamLiveNo
Network access from CCS ECS to Play servicesPlatform/SRENeeds verificationPossible
#Risk / QuestionImpactMitigation / Answer
1Network connectivity — CCS may not have network access to Play service endpoints in stage/prodBlocks all Play contextVerify VPC/security group access before implementation begins
2play-support-service eventual consistency — data lags gameplay-service by SNS/SQS propagationStale app names or summariesAcceptable for AI context; document in response metadata
3Pagination limits — power users with 10+ games may exceed pageSize=50Missing games in contextStart with pageSize=50; monitor and increase if needed
4Platform parameter for nextUp — we may not know user’s platformMissing platform-specific milestonesUse play-support-service /overview platform field; fall back to omitting platform param
5quest-service integration deferredIncomplete Play picture for users with active questsAcceptable for v1; add in future spec if needed
6Opportunity ranking weights are heuristicSuboptimal orderingStart with proposed weights; tune based on Rewards Assistant feedback
7Event-driven integration deferredNo real-time Play awarenessREST polling is sufficient for v1; revisit based on Rewards Assistant needs
  • Unit tests: Test each client adapter with httptest mock servers returning realistic payloads from the RFD. Test opportunity detection logic for each of the 9 opportunity types. Test timestamp parsing and totalPoints string conversion. Test nil-safe degradation (each combination of failed upstream).

  • Integration tests: Call staging endpoints with known test users to verify response shapes match expected schemas. Verify cross-referencing of app names between gameplay-service and play-support-service data.

  • Contract tests: Validate client adapter response parsing against actual upstream OpenAPI specs (gameplay-service/api/openapi.yaml). Pin response schemas in test fixtures.

  • Manual validation: Call GET /play/{userId} on staging with known Play users. Verify opportunity detection produces sensible results. Test with non-Play user to confirm graceful empty response. Test MCP tool via Claude Desktop.

  1. Implement client adapters and Play domain service on feature branch
  2. Deploy to staging and verify with known Play test users
  3. Validate with Rewards Assistant team that context is useful and opportunity ranking is sensible
  4. Deploy to production behind feature flag if needed (or direct deploy given read-only nature)
  • Latency: p50/p95/p99 for GetPlayContext and each upstream client call
  • Error rates: Per-upstream-service failure rate (5xx, timeout, connection refused)
  • Degradation: Count of partial responses (at least one upstream failed but context returned)
  • Cache hit rates: Promotion cache hits vs misses
  • Opportunity counts: Average number of opportunities per user (track to ensure detection is working)

Play context is additive — it adds a new domain without modifying existing Products/Offers/Users domains. Rollback is simply removing the /play/{userId} route and MCP tool registration. No data migrations to reverse.

ServiceStageProd
gameplay-servicehttps://stage-gameplay-service.us-east-1.stage-services.fetchrewards.comhttps://prod-gameplay-service.us-east-1.prod-services.fetchrewards.com
mini-game-servicehttps://stage-mini-game-service.us-east-1.stage-services.fetchrewards.comhttps://prod-mini-game-service.us-east-1.prod-services.fetchrewards.com
play-support-servicehttps://stage-play-support-service.fetchrewards.comhttps://prod-play-support-service.fetchrewards.com

SNS topic {env}-gameplay-service-events produces POINTS_AWARDED and APP_INSTALL events. Current consumers include recommendations-orchestrator, Faber, battlepass-service, and Sequin. CDC topic gameplay-service-gameplay-db-cdc streams DynamoDB state via Debezium. These are documented in the Confluence page and RFD for future event-driven integration consideration.

  • Slack: #collective-rapps-dev, #fetch-play
  • Alarms: #rewarded-apps-alarms