Shared Enrichment Layer — Product-User Context
Shared Enrichment Layer — Product-User Context
Section titled “Shared Enrichment Layer — Product-User Context”Problem
Section titled “Problem”Three systems independently enrich product data for overlapping purposes:
- Consumer graph worker (this repo) — builds a Neo4j purchase graph, computes repurchase metrics, schedules notifications
- Rover-agent (
feat/PLT-360-product-card-enrichment) — enriches AI product mentions into mobile-renderable product cards - Rover-mcp (
feat/PLT-356-fido-offer-lookup) — wraps Offer Guardian with a FIDO reverse index, exposes offer lookups to the agent
Each calls a different subset of the same upstream services, applies its own enrichment logic, and produces its own output format. The result is duplicated client code, inconsistent data, and no shared path for a product’s full context.
A shared enrichment layer would provide a single, complete product-user context that all three consumers draw from.
Union capability set
Section titled “Union capability set”The table below is the union of all data points consumed across the three systems. Every row must be supported by the enrichment layer regardless of which consumer currently uses it.
| Data Point | Upstream Source | Graph Worker | Rover-Agent | Rover-MCP | Enrichment Layer |
|---|---|---|---|---|---|
| Product name | FPS + FIDORA | FPS name | FIDORA name | — | Both (FPS for generic, FIDORA for retailer-specific) |
| Product brand | FPS | FPS brand | — | — | FPS |
| Product category | FPS | FPS category | — | — | FPS |
| Product image URL | FIDORA | — | FIDORA normalizedImageUrl | — | FIDORA |
| Product price (cents) | FIDORA | — | FIDORA priceCents | — | FIDORA |
| Product URL | FIDORA | — | FIDORA url | — | FIDORA |
| Retailer ID (from product) | FIDORA | — | FIDORA retailerId | — | FIDORA |
| Product availability | FIDORA | — | FIDORA isAvailable | — | FIDORA |
| Button commissioned | FIDORA | — | FIDORA isButtonCommissioned | — | FIDORA |
| Retailer name | Retailer Service / Receipt | Receipt storeName | Button merchant name | — | Retailer Service + FIDORA→Button chain |
| Retailer venue type | Retailer Service | normalizeVenueType() | — | — | Retailer Service |
| Merchant name + logo | Button Merchant Service | — | Button name, iconUrl | — | Button (via FIDORA retailerID) |
| Merchant PPD | Button Merchant Service | — | Button awardDetails | — | Button |
| Merchant stickers | Button Merchant Service | — | Button stickers[] | — | Button |
| All offers for FIDO | Offer Guardian (via rover-mcp) | OG direct (mocked) | rover-mcp | OG → FidoIndex | rover-mcp FidoIndex |
| Offer points | Offer Guardian | PointsEarned only | via rover-mcp | PointsEarned | rover-mcp |
| Offer stackability | Offer Guardian | Not modeled | via rover-mcp | IsStackable | rover-mcp |
| Offer expiration | Offer Guardian | Not modeled | via rover-mcp | EndTime | rover-mcp |
| Offer description | Offer Guardian | Not used | via rover-mcp | OfferDescription | rover-mcp |
| User eligible for offer | Neli | Separate Neli call | Not yet (user_id param unused) | Not yet | Neli (or integrated into rover-mcp) |
| eReceipt support | eReceipt Provider + Handbook | — | IsSupported() | — | eReceipt services |
| Purchase count | Neo4j PURCHASED.times | Candidate query | — | — | Neo4j read |
| Avg interval days | Neo4j PURCHASED.avg_interval_days | Candidate query | — | — | Neo4j read |
| Last purchase date | Neo4j PURCHASED.last | Candidate query | — | — | Neo4j read |
| Repurchase likelihood | Neo4j PURCHASED.repurchase_likelihood | Candidate query | — | — | Neo4j read |
| Purchase timestamps | Neo4j PURCHASED.timestamps | Candidate query | — | — | Neo4j read |
| User timezone | Neo4j User.timezone | Candidate query | — | — | Neo4j read |
| Expected value | Computed: likelihood × (points - 25) | calculator.go | — | — | Calculated enrichment |
| Days since last purchase | Computed: now - last | calculator.go | — | — | Calculated enrichment |
| Predicted next purchase | Computed: last + avg_interval | calculator.go | — | — | Calculated enrichment |
| Total achievable points | Computed: stacking rules + PPD | — | CalculateProductPoints() | — | Calculated enrichment |
| Stickers | Computed: offer state + merchant | — | generateStickers() | — | Calculated enrichment |
| CTA deep link | Computed: merchant + offer + URL | — | buildProductCTA() | — | Calculated enrichment |
Proposed API surface
Section titled “Proposed API surface”Single product context
Section titled “Single product context”GetProductContext(userID, productID) -> ProductUserContextReturns the full enriched view of one product for one user:
ProductUserContext { // Product identity (FPS + FIDORA merged) Product: { ProductID string Name string // FPS name, FIDORA override if available Brand string // FPS Category string // FPS }
// Retailer-specific product data (FIDORA) RetailerProduct: { RetailerID string ImageURL *string PriceCents int ProductURL string IsAvailable bool IsButtonCommissioned *bool }
// Offers (rover-mcp FidoIndex + Neli eligibility) Offers: []OfferContext { OfferID string Points int Description string EndDate *time.Time IsStackable bool UserEligible bool // Neli check }
// Merchant / retailer (Button + Retailer Service) Merchant: { Name string Logo *string VenueType string // Retailer Service normalized PPD float64 // Points per dollar PPDType string // "PPD", "COMMISSIONLESS_PPD", "FLAT" Stickers []string // BMS stickers EReceipt bool // eReceipt Provider + Handbook }
// Purchase history (Neo4j read) History: { PurchaseCount int AvgIntervalDays float64 LastPurchaseDate time.Time RepurchaseLikelihood float64 Timestamps []time.Time Timezone string }
// Calculated scoring (combines graph + offers) Scoring: { ExpectedValue float64 // likelihood × (points - 25) DaysSinceLastPurchase float64 PredictedNextPurchase time.Time TotalAchievablePoints int // Stacking rules applied }
// Presentation (computed from above) Display: { Stickers []string // Max 2, priority ordered CTA *CTA // Deep link (shop/offer/browser) }}Batch / candidate queries
Section titled “Batch / candidate queries”GetCandidatesForUser(userID) -> []ProductUserContextGetTopCandidates(filters) -> []ProductUserContextGetTopCandidates wraps the existing Neo4j repurchase candidate query plus full enrichment. The scheduler, agent, and product card renderer all consume the same function — only what they do with the results differs.
Source clients (union)
Section titled “Source clients (union)”All upstream service integrations that the enrichment layer must support:
| Client | Service | What It Provides | Current Location(s) |
|---|---|---|---|
| FIDO Product Service | FPS | Generic product metadata: name, brand, category, barcode | graph-worker internal/api/fido/ |
| FIDORA | FIDO Assortment Service | Retailer-specific product data: name, price, image, URL, retailerID, availability, Button-commissioned flag | rover-agent internal/enrichment/product/fidora_client.go |
| Offer Guardian (via rover-mcp FidoIndex) | Offer Guardian → rover-mcp | All offers for a FIDO: points, stackability, expiration, description | rover-mcp pkg/api/service/offer-guardian/, graph-worker internal/api/offerguardian/ (mocked) |
| Neli | Neli eligibility service | Per-user offer eligibility: (userID, offerID) → bool | graph-worker internal/api/neli/ |
| Button Merchant Service | BMS | Merchant data: name, logo, PPD, stickers, merchant URL. Cached retailerID→merchant mapping. | rover-agent internal/enrichment/product/button_client.go |
| Retailer Service | Fetch Retailer Service | Venue type classification per store name | graph-worker internal/api/retailer/ |
| eReceipt Provider + Handbook | eReceipt services | Whether a retailer supports eReceipt scanning (direct connection or email parsing) | rover-agent internal/enrichment/product/ereceipt_client.go |
| Neo4j (read) | Consumer graph | Purchase history, repurchase likelihood, timestamps, timezone | graph-worker internal/notification/repurchase/neo4j.go |
Calculated enrichment (union)
Section titled “Calculated enrichment (union)”All computed fields that the enrichment layer must derive:
| Calculation | Formula | Current Location | Consumers |
|---|---|---|---|
| ExpectedValue | repurchase_likelihood × (offer_points - 25) | graph-worker calculator.go | Scheduler (ranking, notification score), Agent (prioritization) |
| DaysSinceLastPurchase | now - last_purchase | graph-worker calculator.go | Scheduler, Agent |
| PredictedNextPurchase | last_purchase + avg_interval_days | graph-worker calculator.go | Scheduler, Agent |
| TotalAchievablePoints | PPD×price + stackable offers (stacking rules) | rover-agent points.go | Agent (display), Product cards |
| OfferPoints | sum(stackable) or max(non-stackable) | rover-agent offer_client.go | Points calc input |
| Stickers | Priority: expiring → stackable → multiplier → eReceipt (max 2) | rover-agent stickers.go | Product cards, Agent |
| CTA deep link | Shop > Offer > Browser priority with merchant/offer context | rover-agent cta.go | Product cards, Agent |
What stays in each consumer
Section titled “What stays in each consumer”Scheduler only
Section titled “Scheduler only”| Component | Why |
|---|---|
| SendTime / DeliveryWindow | Push notification timing mechanics |
| Time-of-day minute-level analysis | Scheduler-specific optimization |
| ShouldSendToday() | Scheduling decision |
| State tracking (snooze, opt-out, quota) | Notification-specific Valkey state |
| Candidate selection (top N per user per run) | Notification throughput management |
| Notification template building | Copy formatting for push notifications |
Graph writer only
Section titled “Graph writer only”| Component | Why |
|---|---|
| Cypher MERGE / upsert logic | Write path |
| Community assignment | Graph topology maintenance |
| ID generation (CAT_, RET_, COMM_) | Internal identity scheme |
| Idempotency cache (Valkey) | Write-path dedup |
Rover-agent only
Section titled “Rover-agent only”| Component | Why |
|---|---|
| ProductCard struct formatting | iOS BFF contract serialization |
| Component parsing / streaming | LLM output processing |
| Product card YAML config | Component registry |
Architecture
Section titled “Architecture” ┌─────────────────────────────────────────────────────┐ │ Enrichment Layer │ │ │ ┌───────────┐ │ ┌──────────────────────────────────────────────┐ │ │ FPS │◄──┼──│ Product Identity │ │ ├───────────┤ │ │ FPS (brand, category) + FIDORA (price, img) │ │ │ FIDORA │◄──┼──│ │ │ └───────────┘ │ └──────────────────┬───────────────────────────┘ │ │ │ │ ┌───────────┐ │ ┌──────────────────▼───────────────────────────┐ │ │ rover-mcp │◄──┼──│ Offer Resolution │ │ │ FidoIndex │ │ │ rover-mcp (offers) + Neli (eligibility) │ │ ┌─────────────────┐ ├───────────┤ │ │ │ │ │ │ │ Neli │◄──┼──│ │ │──►│ Scheduler │ └───────────┘ │ └──────────────────┬───────────────────────────┘ │ │ (timing, │ │ │ │ │ delivery, │ ┌───────────┐ │ ┌──────────────────▼───────────────────────────┐ │ │ state mgmt) │ │ Button │◄──┼──│ Merchant / Retailer │ │ │ │ │ (BMS) │ │ │ Button (PPD, logo, stickers) │ │ └─────────────────┘ ├───────────┤ │ │ Retailer Service (venue type) │ │ │ Retailer │◄──┼──│ eReceipt (scanning support) │ │ ┌─────────────────┐ │ Service │ │ │ │ │ │ │ ├───────────┤ │ └──────────────────┬───────────────────────────┘ │──►│ Agent │ │ eReceipt │◄──┼── │ │ │ (reasoning, │ └───────────┘ │ ┌──────────────────▼───────────────────────────┐ │ │ conversation, │ │ │ Graph Reads │ │ │ recs + cards) │ ┌───────────┐ │ │ (purchase history, likelihood, patterns) │ │ │ │ │ Neo4j │◄──┼──│ │ │ └─────────────────┘ │ (read) │ │ └──────────────────┬───────────────────────────┘ │ └───────────┘ │ │ │ ┌─────────────────┐ │ ┌──────────────────▼───────────────────────────┐ │ │ │ │ │ Calculated Enrichment │ │──►│ Product Cards │ │ │ EV, days since, predicted next, │ │ │ (rover-agent │ │ │ total points, stickers, CTA │ │ │ formatting) │ │ └──────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ └─────────────────┘
┌─────────────────────────────────────────────────────┐ │ Graph Writer (unchanged) │ │ Kafka → Transform → Neo4j MERGE │ └─────────────────────────────────────────────────────┘Per-product enrichment flow (unified)
Section titled “Per-product enrichment flow (unified)”Input: (userID, fidoID)
Step 1: Product identity ├── FPS.GetProductInfo(fidoID) → name, brand, category └── FIDORA.GetFidorsByFido(fidoID) → best fidor: image, price, URL, retailerID, availability
Step 2: Offer resolution ├── rover-mcp.GetOffersByFido(fidoID) → all offers: points, stackability, expiry └── Neli.IsUserEligibleForOffer(userID, offerID) → eligible flag per offer
Step 3: Merchant / retailer ├── Button.GetMerchantByFetchRetailerID(fidor.retailerID) → name, logo, PPD, stickers ├── RetailerService.GetRetailerByStoreName(name) → venue type └── eReceipt.IsSupported(merchantName) → eReceipt flag
Step 4: Graph reads (if userID has history) └── Neo4j: MATCH (u:User)-[r:PURCHASED]->(p:Product) → times, avg_interval, last, likelihood, timestamps
Step 5: Calculated enrichment ├── ExpectedValue = likelihood × (best_offer_points - 25) ├── DaysSinceLastPurchase = now - last ├── PredictedNextPurchase = last + avg_interval ├── TotalAchievablePoints = CalculateProductPoints(price, PPD, offerPoints, stackable) ├── Stickers = generateStickers(offers, hasShopPartner, merchantStickers) └── CTA = buildProductCTA(merchantID, merchantName, productURL, offerID, hasOffer)
Output: ProductUserContext (full union)Benefits
Section titled “Benefits”-
Single wiring point — Eight upstream clients initialized once, shared by all consumers. Offer Guardian is currently mocked in the graph worker; FIDORA doesn’t exist here at all. Both get wired once.
-
Consistent data — Agent, scheduler, and product cards all see the same product, the same offers, the same points. No drift between what the agent says and what the notification sends.
-
Full context everywhere — The scheduler gains price, image, and merchant data it never had. The agent gains purchase history and repurchase scoring it never had. Product cards gain history context for smarter display.
-
Testability — One enrichment interface to mock instead of eight separate clients scattered across three repos.
-
Caching — Product metadata (FPS, FIDORA) and merchant data (Button) are stable on minute-to-hour timescales. The enrichment layer can cache internally without each consumer managing its own cache. Rover-mcp’s FidoIndex already demonstrates this for offers.
Existing implementations reference
Section titled “Existing implementations reference”Rover-Agent (PLT-360)
Section titled “Rover-Agent (PLT-360)”Interface: enrichment.Enricher
type Enricher interface { EnrichProducts(ctx context.Context, products []ProductInfo, userID string) ([]ProductCard, error)}Input: ProductInfo — minimal product data from AI output (FidoID, Title, Price, ImageURL, ProductURL).
Output: ProductCard — fully enriched card for iOS rendering (ID, Title, ImageURL, Merchant, Price, TotalPoints, Stickers, CTA, RetailerEreceipt).
Per-product flow: FIDORA (gatekeeper) → offers + merchant in parallel → eReceipt → assemble card (points, stickers, CTA). Each product enriched concurrently via goroutines. 1-second timeout for all enrichment.
Points calculation (points.go):
shopPoints = price × PPD (or flat rate)offerPoints = sum(stackable) or max(non-stackable)TotalPoints = shopPoints + offerPoints (if stackable)TotalPoints = max(shopPoints, offerPoints) (if not stackable)Sticker priority (stickers.go): expiring offer (< 7d) → stackable → BMS multiplier → eReceipt. Max 2.
CTA priority (cta.go): Shop deep link (Button partner) → Offer deep link → Fallback browser.
Rover-MCP (PLT-356)
Section titled “Rover-MCP (PLT-356)”Tool: get_offers_by_fido (MCP JSON-RPC)
Architecture: Wraps Offer Guardian SDK with a FIDO reverse index. At startup, warms cache by fetching all active offers (up to 20,000), extracting FIDOs from ActionRequirements, and building fido_id → []*Offer map. Lookups are O(1) in-memory.
Internal model:
type Offer struct { ID, Points, OfferDescription, Description, Details, SubHeader // identity + display Category, Categories, ImageURL, DetailImageURL // categorization + images FidoIds []string // reverse-indexed EndTime *int64 // ms epoch IsStackable bool // stacking rules}Gaps for shared use:
- User eligibility filtering (
user_idaccepted but unused) - Index staleness (built once at startup, no refresh)
- Single-FIDO lookups only (no batch endpoint)
Graph Worker (this repo)
Section titled “Graph Worker (this repo)”Offer client: internal/api/offerguardian/client.go — calls Offer Guardian SDK directly to get active offers and scan ActionRequirements for a FIDO match. Extracts PointsEarned → PointsPerDollar → PointsPerItem. Currently mocked in production (dependencies.go).
FIDO Product Service: internal/api/fido/client.go — batch API for generic product metadata (name/description, brand, category, barcode). Used by all three Kafka/API transformers. 30s timeout, 3 retries, up to 1000 FIDOs per batch.
Graph reads: internal/notification/repurchase/neo4j.go — Cypher query returns top 10,000 candidates ordered by repurchase_likelihood DESC, filtered by times >= 2, not Alcohol, within lookback window.
Calculated enrichment: internal/scheduler/calculator.go — ExpectedValue = likelihood × (points - 25), plus DaysSinceLastPurchase and PredictedNextPurchase.
Open questions
Section titled “Open questions”- Service vs library? If consumers run in the same process, this is an internal Go package. If the agent is a separate service, this needs an HTTP API. Rover-mcp is already an HTTP/JSON-RPC server — could the enrichment layer be an extension of rover-mcp, or does it warrant its own service given the Neo4j dependency?
- Staleness tolerance — Rover-mcp’s FidoIndex is stale after startup. The scheduler runs periodically (minutes). The agent needs near-real-time. Different caching strategies may be needed per data tier: offers (minutes), product metadata (hours), graph data (seconds).
- Graph read load — The scheduler’s candidate query returns up to 10,000 rows in batch. The agent needs single-user lookups in real-time. The enrichment layer needs both access patterns, likely with different Neo4j indexes.
- Batch efficiency — Rover-mcp’s
get_offers_by_fidois single-FIDO. The scheduler processes thousands of candidates. Options: batch MCP endpoint, client-side parallelism, or embed the FidoIndex directly in the enrichment library. - FIDORA as gatekeeper — In rover-agent, FIDORA is the gatekeeper: if it returns nothing, the product gets only AI-provided data. Should the enrichment layer follow the same pattern, or should it degrade gracefully per-tier (product identity works even if FIDORA is down, offers work even if FIDORA is down, etc.)?
- Neli integration point — Either add Neli filtering into rover-mcp’s offer lookup (single call returns pre-filtered offers) or keep it as a separate enrichment step. The former is cleaner; the latter is more flexible if eligibility checking needs to happen at different points in different consumers.