BlueShop Recommendation Domain — Implementation Plan (v4, semantic and measurement)

Status: PHASES 0-4 SHIPPED, DEPLOYED, AND LIVE-VALIDATED. Last updated: 2026-07-11. Research basis: 6 deep exploration reports (catalog, customer/order/cart behavior, stock/analytics/infra, storefront frontend, specs/BDD/e2e conventions, 2026 off-the-shelf recommender landscape). Phase 5 Gorse remains gated on sufficient real CTR evidence.


1. Executive summary

The recommendation bounded context is deployed as recommendation-service and serves product recommendations to the storefront at four placements: home page, cart page, order confirmation, and PDP similar products. It maintains event-fed projections from Kafka topics (order.orders, stock.stock-events, catalog.products) and computes recommendations with curated relations, count-based co-purchase, decayed popularity, client-provided seeds, and multilingual semantic similarity. One non-negotiable invariant remains: never recommend a product that is OUT_OF_STOCK or not PUBLISHED.

Build-vs-buy verdict (researched, §4): hybrid. The bounded context, domain rules, and serving API are ours regardless of engine. The scoring engine sits behind a domain port (RecommendationEngine):


2. Current-state findings (what exists — verified in code)

Finding Evidence
Curated cross-sell/upsell exists end-to-end catalog.product_cross_sell/product_upsell tables, `PUT /v1/products/{id}/cross-sells
A catalog-admin recommendation endpoint exists — different context RecommendationsController (GET /v1/recommendations, hasRole('catalog-admin')), strategies HIGH_MARGIN/SLOW_MOVER/BEST_SELLER/COMPLEMENTARY, ≤500 products in-memory. Campaign backoffice tool. Untouched by this plan (consolidation option recorded in ADR-024).
Storefront recommendation strips are deployed Dedicated RecommendationsStore + <app-recommended-products> render HOME, CART, ORDER_CONFIRMATION, and PDP_SIMILAR. Authenticated HOME passes RecentlyViewedStore product ids as seeds; anonymous HOME remains public and auth-independent. Recommendation set ids and click rank/strategy are measured without PII.
Recently-viewed lives in catalog-service, not customer catalog.customer_recently_viewed_products (max 30, prune-on-write, RLS self-only), GET/POST /v1/customers/{id}/recently-viewed, recorded on PDP view for authenticated customers only.
Stock-service is fully implemented (STRUCTURE.md is stale) Real topic stock.stock-events (not blueshop.stock.events), StockAvailabilityChanged {productId, variantId, availableToSell, status, occurredAt} event-carried state (ADR-020); GET /v1/availability?productIds= permitAll ≤100 ids. MockInventoryClient no longer exists — cart uses HttpInventoryClient (fail-open display / fail-closed checkout).
Proven availability-projection pattern to copy catalog StockAvailabilityEventConsumercatalog.product_stock_availability (idempotent on occurred_at), feeds ES inStock.
Order events carry full line items order.orders: OrderCreatedEvent + OrderChangedEvent (every lifecycle transition), CloudEvents envelope, keyed customerId, items[] {productId, variantId, quantity, unitPrice, unitCostPrice…}. Replayable from earliest.
ClickHouse already computes basket affinity analytics.basket_product_affinity (support/confidence/lift, ANL-016). Used as offline validation oracle only — ADR-023 keeps analytics a terminal read model, not a runtime dependency.
Bulk product hydration exists Recommendation hydration reuses the existing public GET /v1/products/bulk?productIds=... endpoint. The recommendation client uses comma-separated ids and 20-item refresh batches to stay under WebClient response-buffer limits.
Redis deployed but unused by any service Two small deployments (50m/64Mi req), zero consumers today. Available as serving cache / future Gorse cache store.
Elasticsearch has no spare headroom Single node, 1 replica, limits 1 CPU/2Gi, sized for catalog search only. Phase 3 must be measured + cached + feature-flagged.
Infra cost of one service is templated ApplicationSet entry + overlay + Vault role + Kafka OIDC client/ACLs + Harbor + CI job. Standard footprint 2×(100m/512Mi req → 500m/1Gi lim); analytics precedent allows replicas: 1. No GPU, arm64 node.

3. Data sources → how each is exploited

# Source Transport Exploitation
1 Order history (orders.order_items) Kafka order.orders (replay from earliest = instant backfill) Fact ingestion → co-purchase pair counts, popularity, per-customer purchase profile (§5.2–5.4). The backbone signal.
2 Stock availability Kafka stock.stock-events Eligibility projection; hard filter REC-001. OUT_OF_STOCK removes a product from all responses within the propagation window (§5.6).
3 Product catalog (status, category, brand, price) Kafka catalog.products + CatalogProductClient re-fetch on thin ProductUpdated Eligibility projection (PUBLISHED-only, REC-002) + category/brand/price features for personalization and diversity (§5.4, §5.5).
4 Curated cross-sell/upsell (catalog-manager input) Kafka CrossSellUpdated/UpsellUpdated (full replacement lists) curated_relations projection → CURATED_RELATION strategy: merchandiser intent ranks above statistics when present (§5.5). This finally makes the catalog-manager's upsell/cross-sell input systemically exploited beyond the PDP.
5 Recently viewed (catalog, RLS self-only) Client-side seeds — storefront already holds the list (RecentlyViewedStore), passes ids in the request Extra seeds for HOME personalization for logged-in customers. No cross-service RLS bypass, no new API.
6 Cart contents Client-side seeds (cart page knows its items) Seeds for CART placement + REC-003 exclusion set.
7 Category tree (3 levels) via #3 (categoryId + path from re-fetch) Category-proximity scoring in PERSONAL_HISTORY; diversity guard (§5.5).
8 Product text/attributes (name, description ×4 locales, category path, brand, attributes) via #3 re-fetch → embedded in-service (Phase 3) Semantic layer (§5.9): multilingual sentence embeddings → vector kNN → SIMILAR_CONTENT. Serves cold-start customers (seeds = views/cart, zero orders needed) and cold-start products. ES More-Like-This kept as a cheap term-based complement/fallback.
9 ClickHouse analytics.basket_product_affinity offline only Validation oracle: our pair counts must reconcile with analytics' independent computation from the same events (§9, data-quality gate).

Not used v1 (documented why): cart.events topic (funnel analytics grain, no removal events — cart context comes fresher from the client); customer loyalty/RFM (Phase 5 personalization features); review ratings (candidate quality boost — noted as backlog REC idea).


4. Build-vs-buy analysis (2026 state of the art, researched)

Option Verdict Key facts
Own count-based strategies (co-occurrence + popularity + rules) v1 The industry-standard baseline (original Amazon item-item CF). Zero new infra, deterministic ⇒ BDD-provable, explainable reason per item. Right-sized for our data volume.
Gorse (gorse.io, Go, Apache-2.0) Phase 5, gated Only mature, actively maintained (v0.5.10 2026-06, pushed 2026-07), CPU-only, self-hostable recommender service. REST feed/serve (users/items/feedback/recommend/{user}); data store = our Postgres, cache = our idle Redis; Helm chart; gorse-in-one ~1 CPU/1–2Gi; arm64 OK. Risks: single lead maintainer, config-breaking minors ⇒ pin version.
Own embeddings (in-JVM ONNX) + pgvector Phase 3, implemented Immutable-checksum multilingual 384-d model runs inside recommendation-service via ONNX Runtime/DJL on a dedicated bounded scheduler. PostgreSQL pgvector stores vectors and filters cosine similarity at >= 0.55. Details §5.9.
ES More-Like-This ✅ complement Free Basic tier, TF-IDF-style term similarity, zero new components — cheap first content signal and fallback if embeddings are delayed. ELSER excluded (Platinum + ≥4Gi ML node).
Recombee SaaS-only.
Apache PredictionIO / Mahout Attic 2021 / pivoted to quantum computing.
NVIDIA Merlin GPU-required, key components deprecated.
Metarank ⚠️ watch Good LTR-over-ES concept; maintenance stalled (last push 2025-09). Re-evaluate if it revives.
RecBole / LensKit / Surprise / implicit Research libraries, not services; heavy Python footprint; we'd still build the service.
pgvector ✅ deployed vector store (§5.9) PostgreSQL-license extension 0.8.2 is installed in CNPG; recommendation owns a 384-d vector table and HNSW index, keeping vector load off shared Elasticsearch.

Why not Gorse first? (a) Acceptance criteria are Gherkin — deterministic heuristics are provable, a trained CF model is not. (b) The hard rules (OOS, PUBLISHED, exclusions, PII) must live in our domain layer anyway. (c) Zero extra pods now; Gorse costs ~1 CPU. (d) CF needs interaction volume we don't have yet; at demo scale it would underperform the heuristics. The port makes the swap cheap and A/B-testable when Phase-4 CTR data justifies it.


5. Algorithm & data pipeline design — the "how"

Direct answers: No trained-by-us model, ever — training is Gorse's job in Phase 5 if the CTR gate opens. No GPU (none exists). v1 is memory-based (count-based) collaborative filtering + decayed popularity + rule-based personalization — pure deterministic SQL over event-fed projections. Embeddings ARE in the plan and committed: Phase 3 adds a semantic layer (pretrained multilingual sentence-embedding model, ONNX inference in-JVM on CPU — inference only, no training) that powers SIMILAR_CONTENT and is the designed answer for the customer who has never ordered (§5.7, §5.9).

5.0 Three-layer pipeline

 Kafka (order.orders, stock.stock-events, catalog.products)
   │  streaming, idempotent (ADR-021 inbox, message_id = envelope id)
   ▼
 L1 INGESTION → immutable-ish FACTS + live projections
    order_product_facts | product_eligibility | curated_relations
   │  scheduled projector (15 min, config) + admin/BDD-triggerable recompute
   ▼
 L2 DERIVATION → precomputed SCORES (deterministic, fully rebuildable from facts)
   product_pair_affinity | product_popularity | customer_product_profile
   │  request-time, indexed lookups only, <10 ms target
   ▼
 L3 SERVING → strategy chain → eligibility filter → exclusions → RecommendationSet

Design principle: facts are the source of truth inside the context; every derived table is recomputable with RLS-safe DELETE + rebuild from facts, and facts are rebuildable from Kafka (earliest). This makes decay/damping parameters tunable without migration pain and gives a trivial disaster-recovery story (same property analytics relies on).

5.1 L1 — Ingestion (streaming consumers)

Topic / event Action Idempotency
order.orders (Kafka type = OrderCreatedEvent or OrderChangedEvent; both carry the same OrderCreatedEvent payload — verified remi) On any non-cancelled status: insert one row per items[] into order_product_facts(order_id, product_id, customer_id, quantity, unit_price, ordered_at, status) PK(order_id, product_id); upsert customer_product_purchases. items[] has productId/variantId/quantity/unitPrice/margin snapshots/display names — no categoryId, no brand (enrich at L2 from catalog projection) Inbox (envelope id, raw fallback orderId:status) + PK ON CONFLICT DO NOTHING
order.orders payload status=CANCELLED Mark the order's facts status='CANCELLED' (excluded from all derivations at L2) Inbox + idempotent UPDATE
stock.stock-events · StockAvailabilityChanged (payload: productId, variantId?, availableToSell:int, status ∈ {IN_STOCK, LOW_STOCK, OUT_OF_STOCK}, occurredAt — there are no out_of_stock/back_in_stock event names) Upsert product_eligibility.availability + available_to_sell from the payload directly, guarded by occurred_at. LOW_STOCK is still eligible; only OUT_OF_STOCK is ineligible (REC-001). (Ignore InsufficientStockDetected on the same topic — not an eligibility change.) Time-guarded upsert
catalog.products · ProductPublished/Unpublished/Archived/Deleted/Restored/Discontinued Upsert/flip product_eligibility.published; Deleted ⇒ delete row Inbox
catalog.products · ProductCreated/Updated/PriceChanged (thinProductCreated carries only productId/sku/name(German-only)/categoryId/brandId; ProductUpdated carries changedFields + nullable German name, not the new values; no four-locale names, no category path, no brand name, no isFeatured) Upsert id + re-fetch GET /v1/products/{id} via CatalogProductClient for localized names, categoryId, brand, isFeatured, cross/upsell ids. Resilience4j; on fetch failure keep last-known state (REC-011 bounds staleness) Inbox
catalog.products · CrossSellUpdated(crossSellProductIds) / UpsellUpdated(upsellProductIds) — full replacement lists Full-replace curated_relations(source_product_id, target_product_id, relation_type, sort_order) Inbox (full replacement is naturally idempotent)

Deployment correction shipped after audit: recommendation-service initially parsed invented targetProductIds/targets fields and therefore projected zero curated relations. Backend MRs !844–!846 fixed event parsing, catalog bulk relation backfill, admin-triggered refresh, and 20-item refresh batches. Live proof: curated_relations=26,528, exactly matching catalog cross-sell + upsell counts, and seeded CART returns CURATED_RELATION first.

Order-status semantics (REC-006, corrected per remi): the storefront checkout actually creates orders at PAID (Order.createPaid), while backoffice/direct factories can create at CREATED — status enum is CREATED, PENDING_PAYMENT, PAID, PICKED, PROCESSING, SHIPPED, DELIVERED, CANCELLED. So the rule is count on first sight of any non-CANCELLED status (covers both real checkout PAID and backoffice CREATED), keyed by orderId so later status-change events for the same order don't double-count (PK ON CONFLICT DO NOTHING on order_product_facts), and remove from derivations when CANCELLED. PENDING_PAYMENT-stuck orders stay low-weight noise; acceptable v1.

5.2 L2 — CO_PURCHASE: item-item collaborative filtering (count-based)

Recomputed from non-cancelled facts:

Stored score (asymmetric cosine with bestseller damping):

score(a→b) = C(a,b) / ( C(a) · C(b)^α )        α = 0.5 (config)

Table: product_pair_affinity(product_a, product_b, co_order_count, score_a_to_b, score_b_to_a, computed_at) PK(product_a, product_b), index on each column for neighbor lookups.

5.3 L2 — POPULARITY: time-decayed best sellers

pop(p) = Σ over non-cancelled orders containing p:  exp( -λ · age_days(order) )
λ = ln 2 / 14      (14-day half-life, config)

Table: product_popularity(product_id PK, pop_score, category_id, orders_30d, units_30d, last_ordered_at, computed_at).

5.4 L2/L3 — PERSONAL_HISTORY: rule-based personalization (no model)

Server-side profile from customer_product_purchases + client seeds (recently-viewed ids):

  1. Seed set = last K=10 purchased products, weight w = exp(-λ_p · days_since_purchase) (λ_p = ln2/30), plus recently-viewed seeds at a lower fixed weight (0.5, config) — a view is a weaker signal than a purchase.
  2. Candidates = union of (a) affinity neighbors of every seed (§5.2), (b) top-popularity products in the customer's top-3 categories by purchase weight (§5.3 per-category).
  3. Score = Σ_seeds w_seed · score(seed→cand) for (a); category-popularity normalized ×0.6 for (b) so behavioral affinity outranks demographic popularity.
  4. Exclusions: REC-003 (seeds themselves), REC-004 (purchased within 30 days), then eligibility filter.

Deterministic, explainable (reason: "Because you bought <seed name>" / "Popular in <category>"), and — critically — every step is expressible as a Gherkin scenario.

5.5 L3 — Serving: strategy chains, blending, diversity

Per placement (REC-007):

Placement Chain (priority order) Exclusions
HOME (auth, has purchases) PERSONAL_HISTORY → CURATED_RELATION(cross-sells/upsells of profile seeds) → POPULARITY purchased ≤30d (REC-004)
HOME (auth, zero orders) CURATED_RELATION(recently-viewed seeds) → POPULARITY until Phase 3 adds SIMILAR_CONTENT viewed seeds (REC-003)
HOME (anon) POPULARITY (with featured boost, §5.5a)
CART CO_PURCHASE(cart seeds) → CURATED_RELATION(cross-sells of cart items) → SIMILAR_CONTENT(P3) → POPULARITY cart items (REC-003)
ORDER_CONFIRMATION CO_PURCHASE(ordered seeds) → CURATED_RELATION → POPULARITY just-ordered items (REC-003)

Seeding gotcha (mude): the confirmation page today has no purchased product idsPlaceOrderResponse only returns orderId/orderNumber/status, and the ReviewStep resets cart+checkout before navigating. So Phase 2 must add a durable seed source: capture the cart line-item ids before the reset and carry them into the confirmation route (router state / a short-lived signal), then pass them as CO_PURCHASE seeds. Fallback if unavailable: seed server-side from the customer's latest order_product_facts (already ingested), else PERSONAL_HISTORY → POPULARITY. Never block the confirmation page on it (REC-010). | PDP_SIMILAR (Phase 3) | SIMILAR_CONTENT → CO_PURCHASE → CURATED_RELATION → POPULARITY | current product |

5.5a Catalog-manager input is a first-class signal (explicit requirement): (1) CURATED_RELATION — the cross-sells/upsells that catalog managers author in backoffice-catalog are present in every seeded chain and outrank statistics when both propose the same slot (fixed score by sort_order, merchandiser intent wins). Today that data is heuristic seed — shipping this feature makes curating it actually pay off, which we surface to the catalog team. (2) Featured boost — products flagged is_featured by catalog managers get a configurable POPULARITY multiplier (×1.2 default) and are the preferred filler for anonymous HOME. product_eligibility snapshots is_featured from catalog re-fetches. Explainability preserved: reason: "Recommended by our team" for curated/featured picks (REC-012).

Blending = priority-fill (not weighted mixing): strategy #1's results (score-normalized 0..1) fill first; remaining slots filled by strategy #2 with scores compressed under the last #1 score; dedupe by productId keeping highest strategy. Simple, order-stable, explainable — every item's strategy field says where it came from. CURATED_RELATION items carry fixed score by sort_order — merchandiser intent beats statistics when both propose the same slot.

Diversity guard (REC-008 refinement): max 3 items per category in a strip of 8 (config) — prevents "8 pairs of socks" carts. Deterministic tiebreak: score desc, then productId asc.

Serving path: candidate lookups are indexed reads on precomputed tables + one eligibility join; target p95 < 30 ms server-side. Caffeine cache 60 s TTL keyed (placement, customerId|anon, hash(seeds), limit) — recommendations tolerate 60 s staleness except availability, which is re-filtered on every request against product_eligibility (so an OOS flip beats the cache, REC-011).

5.6 Consistency windows (explicit, testable)

Path Window Bound by
OOS → removed from responses ≤ ~2 s typical (Kafka consume + upsert; no cache on eligibility) REC-011 e2e asserts ≤ 10 s
Unpublished/archived → removed same as above REC-002
New order → affects affinity/popularity ≤ projector interval (15 min default; BDD triggers synchronously) REC-006
Catalog field drift (name/category) re-fetch on event; last-known on failure non-blocking

5.7 Cold start & empty states

The cold-start ladder, by how much we know about the visitor:

Visitor knowledge Answer
Nothing (anonymous, no session signals) POPULARITY + featured boost — the honest baseline; SSR-renderable.
Session signals only (cart items, current PDP — anon or auth) Seeded strategies work without any purchase history: CO_PURCHASE + CURATED_RELATION + SIMILAR_CONTENT(P3) over the seeds the client sends.
Auth, browsed but never ordered (the case that motivated §5.9) Recently-viewed ids (client-held, up to 30) become semantic seeds → SIMILAR_CONTENT kNN + curated relations of viewed items. Until Phase 3 ships: curated relations of viewed items → POPULARITY.
Auth with purchase history Full PERSONAL_HISTORY (§5.4).

5.8 Evaluation methodology

5.9 Semantic layer (Phase 3, committed) — embeddings design

Problem it solves: a customer who browsed but never ordered gets nothing personal from counting-CF; a brand-new product is invisible to statistics. Both need content understanding, not interaction counts.

Model (inference only, never trained by us): a pretrained multilingual sentence-embedding model — candidate: paraphrase-multilingual-MiniLM-L12-v2 (384-d, supports de/fr/it/en, permissive license). Runs inside recommendation-service via ONNX Runtime + DJL on CPU. VERIFIED (sage): DJL 0.36.0 + ONNX Runtime 1.21.1 are Java 25 compatible; the ORT jar and DJL tokenizers both ship linux-aarch64 natives. CRITICAL: those natives are glibc, but the BlueShop backend Dockerfile is Alpine/musl → recommendation-service must use a glibc Temurin Java 25 base image (deviation from the standard backend image — called out in Phase 3.1 / infra). Use the ~118 MB quantized arm64 ONNX model (not the 470 MB full one) to fit the 1Gi pod limit — steady RSS is feasible but tight, monitor. Bake the pinned model + tokenizer bundle into the service image for v1 (no runtime internet); Harbor OCI artifact is an optional later decoupling. Embedding the full catalog (thousands of items) is seconds; no GPU, no Python service, no external API.

What gets embedded (per product, on catalog events): one composed text per product — name(4 locales) + category name + brand + description(primary locale) + salient attributes → one 384-d vector, L2-normalized. (Note per remi: full category path names live only in the internal ES search document, not in ProductResponse; v1 embedding uses the single category name from the re-fetched detail, or projects categories separately if path context proves valuable.) Re-embedded on ProductUpdated/AttributesUpdated re-fetch (same L1 flow, §5.1); embedded at first ProductCreated — which is exactly why product cold-start closes: the vector exists before the first sale.

Storage & query — pgvector preferred, ES fallback (VERIFIED by sage research 2026-07-08, brief worktrees/reco-research/sage-semantic-feasibility.md):

SIMILAR_CONTENT scoring: for seed set S (viewed/cart/current product), candidate score = max_{s∈S} cos(v_s, v_c) (max, not mean — preserves niche interests instead of averaging them into mush), recency-weighted by seed age like §5.4. Threshold cos ≥ 0.55 (config) to avoid nonsense matches; deterministic given fixed vectors ⇒ still Gherkin-testable with a frozen test model + golden fixtures.

Why this is Phase 3 and not v1: v1 ships value immediately with zero new moving parts; the semantic layer adds a model artifact, an inference runtime, and a vector store — worth doing (committed, it completes cold-start), but not worth blocking first delivery. ES More-Like-This can be wired in days as an interim content signal if Phase 2 feedback demands it before the embedding work lands.


6. Bounded context design (DDD)

Ubiquitous language: Placement · RecommendationQuery (placement + seeds + customer + limit) · Recommendation (productId, score 0..1, reason, strategy) · RecommendationSet (ordered, deduped, filtered) · Strategy (CO_PURCHASE, POPULARITY, PERSONAL_HISTORY, CURATED_RELATION, SIMILAR_CONTENT) · Eligibility (PUBLISHED ∧ ¬OUT_OF_STOCK) · Seed · FallbackChain · Projector.

com.blueshop.recommendation/
├── api/            RecommendationController + request/response DTOs
├── application/
│   ├── service/    DefaultRecommendationApplicationService   # chain orchestration + filters + cache
│   ├── port/       RecommendationEngine                      # THE swap point (Heuristic v1 → Gorse P5)
│   └── client/     CatalogProductClient (interface)
├── domain/
│   ├── model/vo/   Placement, Recommendation, RecommendationSet, Strategy, Seed, Eligibility, AffinityScore
│   ├── service/    EligibilityPolicy (REC-001/002), ExclusionPolicy (REC-003/004),
│   │               FallbackChain (REC-007), DiversityPolicy (REC-008)
│   └── repository/ EligibilityRepository, AffinityRepository, PopularityRepository,
│                   CustomerPurchaseRepository, CuratedRelationRepository, OrderFactRepository
└── infrastructure/
    ├── persistence/  R2DBC entities + repos (schema `recommendation`, RLS service context)
    ├── event/        OrderEventConsumer, StockAvailabilityConsumer, CatalogProductConsumer,
    │                 inbox (common-lib IdempotentConsumer), [Phase 4: outbox relay]
    ├── projection/   AffinityProjector, PopularityProjector (scheduled + admin/BDD trigger endpoint)
    ├── engine/       HeuristicRecommendationEngine   [P3: +ElasticsearchMltClient] [P5: GorseRecommendationEngine]
    ├── client/       HttpCatalogProductClient (WebClient + Resilience4j)
    └── config/ security/

JMolecules annotations, @NullMarked, records for VOs/DTOs, Mono/Flux only, no .block(), ArchUnit enforcing layer boundaries (copy catalog's rules).

Flyway (schema recommendation): V1 baseline + RLS, V2 order_product_facts + customer_product_purchases, V3 product_eligibility + curated_relations, V4 product_pair_affinity + product_popularity, V5 inbox_events, (P4) V6 outbox_events. All tables service-only RLS (no customer-facing rows here; responses expose no PII — REC-009).

Serving API (gateway route /api/recommendation/**):

GET /v1/recommendations?placement=HOME|CART|ORDER_CONFIRMATION
                       &seedProductIds=<uuid,...>   (optional, ≤ 20)
                       &limit=8                     (server-capped at 12, REC-008)
Auth: permitAll; valid JWT ⇒ personalized (customerId = sub); anonymous ⇒ non-personalized (REC-005).
200: { "placement": "CART",
       "items": [ { "productId": "…", "score": 0.87,
                    "strategy": "CO_PURCHASE",
                    "reason": "Frequently bought together" } ],
       "generatedAt": "…" }
400: invalid placement / malformed seeds.  Never 5xx for empty data — empty list is a valid answer.
POST /internal/v1/recommendations/recompute   (service/admin only; catalog refresh + projector trigger for ops + BDD)

Response is ids + scores only; storefront hydration uses existing catalog bulk endpoint GET /v1/products/bulk?productIds=a,b,c (permitAll, staff-aware filtering).


7. Business rules (REC catalog — authored in docs at Phase 0)

ID Rule Category Severity
REC-001 A recommendation never contains a product whose availability is OUT_OF_STOCK Validation Blocking
REC-002 Only PUBLISHED products are recommendable Validation Blocking
REC-003 Seed/context products are never recommended back (cart items, just-ordered items, current product) Validation Blocking
REC-004 Personalized placements exclude products purchased by the customer within the last 30 days Workflow Warning
REC-005 Anonymous requests receive non-personalized results; no customer data is read without a valid JWT Security Blocking
REC-006 Order facts are ingested effectively-once: redelivered events change no count; cancelled orders are excluded from derivations Data Quality Blocking
REC-007 Each placement applies its defined strategy fallback chain; results deduped, priority-filled, score-desc ordered Calculation Blocking
REC-008 Limit capped at 12; ≤3 items per category per response; deterministic ordering and tiebreak Validation Warning
REC-009 Responses and events expose product ids/scores/reasons only — no PII, no cost/margin data Security Blocking
REC-010 Recommendation failure or thin results degrade gracefully: storefront hides the section; pages never block on recommendations Workflow Blocking
REC-011 An availability or publication change propagates to serving ≤ the documented window; OUT_OF_STOCK beats any cache Workflow Blocking
REC-012 Every recommendation carries a human-readable reason and its strategy Data Model Info
REC-013 Co-purchase scoring damps bestseller bias (asymmetric cosine, α documented) and enforces a minimum support Calculation Warning
REC-014 Derived scores are fully recomputable from facts; facts are fully replayable from Kafka Data Quality Blocking
REC-015 Catalog-manager input is honored: curated cross-sells/upsells outrank statistical strategies for the same slot; featured products receive the documented popularity boost Calculation Warning
(P3) REC-016 SIMILAR_CONTENT uses locally-inferred multilingual embeddings; candidates below the similarity threshold are never recommended Calculation Warning
(P4) REC-017+ Served/clicked measurement events on recommendation.events (CloudEvents, outbox, no PII) Analytics Info

8. Systemic impact / blast radius

New, additive:

Explicitly untouched: stock/order/cart services (pure topic consumption, zero producer changes); catalog campaign RecommendationsController; checkout stepper UX (recommendations at cart entry + confirmation only — no mid-funnel distraction against the guarded stepper).

Risk register:

Risk Mitigation
Thin catalog.products events force re-fetch; catalog down ⇒ stale eligibility Last-known-state + Resilience4j; REC-011 window documented; recommendation is never checkout-authoritative
Semantic layer footprint (Phase 3) pgvector-in-own-schema preferred (no shared-ES impact); ES dense_vector only as verified fallback, cached + feature-flagged + SigNoz before/after
Postgres connections (max 300 shared) Standard pool sizing, maxSurge: 0, replicas: 1 initially
Projector cost growth Full recompute is O(orders×lines²/order); trivial now; ClickHouse-assisted derivation is the documented escape hatch if catalog×orders explodes
Bestseller feedback loop (recs → sales → recs) α-damping (REC-013) + diversity guard (REC-008) + CTR monitoring (P4)
Kafka consumer error handling Copy analytics' DefaultErrorHandler pattern (MR !818): transient = infinite 5s retry (offset never skips), poison = bounded retry + ERROR log
Demo-scale data too sparse for affinity bootstrapOrderCount config lowers minSupport at low volume; POPULARITY/CURATED fallbacks always fill
Backend worktree currently on fix/cart-double-vat Branch from origin/main in worktrees/backend-recommendation

9. Testing strategy (TDD + BDD + quality gates)

Layer What Tools
Unit (TDD red-green first) Scoring math (asymmetric cosine, decay, blending, diversity, tiebreaks), policies (eligibility/exclusion/fallback), consumers' decode/mapping JUnit 5, StepVerifier, property-style cases for determinism
Architecture Layer rules, no blocking calls, naming ArchUnit (copy catalog rules)
Integration Flyway + RLS, R2DBC repos, projector recompute correctness on golden dataset, inbox idempotency under redelivery, catalog re-fetch fallback Testcontainers Postgres
BDD (the ACs) Every REC-### rule as Gherkin in src/bddTest/resources/features/recommendation/ (@domain:recommendation @rule:REC-###); golden-dataset scenarios: seed N orders → trigger projector → assert exact recommendation lists (possible because v1 is deterministic) Cucumber + Serenity, Testcontainers, runner filters not @wip and not @pending
Contract/reconciliation Pair counts vs ClickHouse ANL-016 on identical events (integration-env check, Phase-1 live proof) script/live probe
Mutation PIT functional-first (per !825 harness: explicit targetClasses, matrix job, allow_failure) PIT
Coverage JaCoCo 80/70 CI gate
Frontend Vitest colocated specs (store states: loading/error/empty/hide, SSR-disabled personalized resource, seed passing); zero ESLint warnings Vitest
E2E See §11 Playwright post-deploy
Live proof (per deploy) Consumer lag 0; projections row-counted; endpoint probes: anonymous/authenticated/seeded; OOS flip disappears live; SigNoz traces clean scripted probes

10. Delivery plan — phases, MRs, dependencies

Every MR follows the full loop: implement → local green → push → CI green → deploy → live verify. Conventional commits, branch feat/recommendation-*.

Phase 0 — Specification first (completed)

MR Repo Content Done when
0.1 blueshop-docs business-rules/recommendation.md (REC table + detail sections), events/recommendation-events.md + index entry (P4 topic documented as planned), 08-decisions/024-recommendation-bounded-context.md (context map, build-vs-buy record incl. Gorse gate, algorithm spec §5, OOS invariant) docs CI green, pages live on docs.blueshop.local
0.2 specifications specs/recommendation.spec.yaml via make generate D=recommendation S=recommendation-service (all ACs pending), Makefile generate-all line make check green
0.3 backend Gherkin feature skeletons (tagged, @pending) + empty module skeleton so spec-checker resolves paths (can fold into MR 1.1) spec-checker green

Phase 1 — recommendation-service core (completed)

MR Content
1.1 (backend) Shipped via backend MRs !831–!856: module, migrations, RLS, consumers, projectors, domain policies, HeuristicRecommendationEngine, controller, config parity, split schedulers, catalog backfill, curated relation and tombstone repair, and sparse purchase-history semantic fallback.
1.2 (infra) Shipped: overlay, ApplicationSet, Vault DB roles, Keycloak Kafka client/claim, Kafka ACLs, Harbor, gateway route.
Live proof dev-recommendation healthy; Kafka/R2DBC/Vault UP; recompute HTTP 200; anonymous HOME, seeded CART, curated relations, and tombstone invariants proven live.

Phase 2 — Storefront placements (completed)

MR Content
2.1 (backend) Existing GET /v1/products/bulk?productIds= reused; recommendation catalog client switched to comma-separated ids and bounded batches.
2.2 (frontend) Shipped RECOMMENDATION_API_URL, dedicated RecommendationsStore, <app-recommended-products>, HOME/CART/ORDER_CONFIRMATION placements, i18n, Vitest, REC-010 hide behavior, and recently-viewed HOME seeds.
2.3 (frontend) Recommendation hydration uses bulk endpoint; broader storefront lookup refactor remains a separate optimization, not required for recommendation delivery.
Live proof shop.blueshop.local: anonymous HOME visible; authenticated HOME emits recently-viewed seed ids after PDP views; seeded CART curated output; post-deploy storefront E2E pipelines 16011 and 16042 green.

Phase 3 — Semantic layer (completed — cold-start and PDP, §5.9)

MR Content
3.0 (infra, GitOps) Admin/GitOps CREATE EXTENSION vector on the recommendation DB (NOT service Flyway — untrusted ext, non-superuser role, sage brief); confirm CNPG postgresql:17 exposes it, else custom image path
3.1 (backend) glibc Temurin Java 25 base image for recommendation-service (ONNX natives are glibc; backend default is Alpine/musl); DJL 0.36 + ORT 1.21.1 + 118MB quantized model baked into the image (pinned); ProductEmbedder on catalog reconciliation, product_embeddings table, full-catalog backfill, SIMILAR_CONTENT strategy + PDP_SIMILAR + zero-order and sparse purchase-history HOME fallbacks; frozen-model fixtures (REC-016)
3.2 (frontend) PDP "Similar products" strip (same <app-recommended-products>); zero-order HOME already works via chain change
Live proof 3,860/3,860 eligible products embedded; PDP returns eight non-seed SIMILAR_CONTENT products above 0.55; fresh zero-order customer HOME and PDP browser scenarios pass; explicit 256 MiB G1 heap uses about 928 MiB after startup under a right-sized 1,280 MiB limit (512 MiB request unchanged)

The interim Elasticsearch More-Like-This adapter was not built; pgvector shipped directly.

Phase 4 — Measurement loop (completed)

RLS outbox + recommendation.events (RecommendationServed/Clicked, no PII) → six-partition Kafka topic with 90-day retention and least-privilege ACLs → analytics consumer + ClickHouse item facts/CTR views → Metabase dashboard 22 → storefront navigation-safe click beacons. The server owns the deployment environment; normal traffic defaults to LIVE; Playwright and demo contexts emit SYNTHETIC; retained unclassified events remain LEGACY. Dashboard baseline cards filter to LIVE, so automation cannot satisfy the Gorse evidence gate. Live proof joined served items and a click by the same anonymous recommendation-set id, and the protected dashboard renders that CTR at https://bi.blueshop.local/dashboards/recommendation-performance-ctr.

Phase 5 — Engine upgrade (gated on Phase-4 CTR evidence)

GorseRecommendationEngine behind the port; gorse-in-one Helm (pinned), our Postgres + Redis; feed = same facts; A/B per placement. ADR-024 records the gate criteria: heuristic CTR plateau at meaningful traffic + catalog/order volume where MF outperforms counting.

Current gate result: closed. The current ClickHouse evidence is development/E2E traffic, not sustained customer traffic or a heuristic CTR plateau. It proves instrumentation, not that matrix factorization will outperform the deterministic baseline.

Dependency graph: 0.1 → 0.2 → (0.3) → 1.1 → 1.2 → {2.1, 2.2} → 2.3; 3, 4 independent after 2; 5 after 4.


11. E2E & demo scenarios

Permanent feature specs now shipped (storefront-recommendations.spec.ts, @type:feature @surface:storefront):

Demo batch 9 — recommendation journey:

  1. REC-007: narrated real checkout → order-confirmation strip → measured recommendation click → personalized HOME, with the purchased product absent.
  2. REC-017: narrated protected BI route showing live overall CTR, CTR by placement/strategy, CTR by rank, and daily trend.
  3. Dynamic REC-001/REC-002 stock/archive mutation remains a permanent acceptance scenario rather than being repeated in the narrated demo.

12. Decisions — closed by the plan (veto window, not questions)

All previously-open points are now decided with rationale. They ship as stated unless vetoed; each lists what would flip it.

# Decision Rationale What would flip it
1 Placements v1 = HOME + CART + ORDER_CONFIRMATION; nothing mid-checkout-stepper The stepper is a guarded conversion funnel; distraction there is anti-conversion. Cart page IS the checkout-entry CTA; confirmation is the post-purchase CTA. PDP_SIMILAR joins in Phase 3. A/B evidence (Phase 4) that a review-step block converts.
2 UNTRACKED availability ⇒ recommendable (fail-open) Matches storefront search inStock semantics and cart's display path; strict mode would empty strips whenever stock rows lag catalog. REC-001 targets explicit OUT_OF_STOCK — the states where we know it's unavailable. Checkout remains fail-closed downstream (cart re-verifies). Live probes showing untracked products being ordered then rejected at checkout at meaningful rate.
3 Order facts counted on first non-CANCELLED status, removed on CANCELLED remi-verified: storefront checkout emits PAID (not CREATED); backoffice can emit CREATED. Counting the first non-cancelled sighting (PK-deduped by orderId) covers both without double-counting later state changes. Delivered-only would starve affinity at our volume. Cancellation rate high enough to distort pairs (watch in Phase 4 data).
4 REC-004 repurchase-exclusion window = 30 days (config recommendation.exclusion.repurchase-window) Sane default for a general catalog; consumables nuance is a per-category refinement not worth v1 complexity. Category-level CTR data showing suppressed legitimate repurchases.
5 Parameters: α=0.5, half-life 14 d, minSupport=2 (+bootstrap), ≤3/category, cache 60 s, cos≥0.55, featured boost ×1.2 — all externalized config with these defaults Standard literature values; determinism + config beats bikeshedding. BDD pins behavior at defaults. Phase-4 CTR tuning — that's what the config knobs are for.
6 Phase 3 semantic layer = committed (owner request: cold-start customer must work); Phase 5 Gorse = gated on Phase-4 CTR evidence Embeddings complete the cold-start ladder (§5.7/§5.9) with inference-only, self-hosted, CPU components. Gorse adds an operated service + training loops — only justified by evidence. Phase 3: nothing (committed). Phase 5: CTR plateau + volume ⇒ adopt; otherwise skip forever.
7 Reuse catalog bulk GET /v1/products/bulk?productIds=; no duplicate endpoint Recommendation uses the existing bounded public contract with comma-separated IDs and 20-item hydration batches.
8 Vector store = pgvector (ES dense_vector fallback) — RESOLVED sage verified: CNPG postgresql:17 bundles pgvector (ES 8.17.0 also supports kNN on free Basic). pgvector wins on isolation + eligibility joins. Caveats folded into §5.9: CREATE EXTENSION is admin/GitOps (untrusted ext, non-superuser role); service needs a glibc base image for ONNX natives; use 118MB quantized model. Only if enabling the extension is blocked in CNPG ⇒ ES fallback.
9 Catalog-manager curation = first-class (REC-015): curated relations outrank stats; is_featured boosts popularity Owner requirement; merchandiser intent is a deliberate business signal, not noise. Makes backoffice curation finally pay off beyond the PDP.

The only thing awaited from the owner/team: veto or go. No open questions remain.