# 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.

> **AI-generated, human-directed.** This historical plan, its research synthesis and diagrams were produced and iteratively revised by AI agents under human direction. It records prior intent and must not override the current As-Built document or source code.

---

## 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`):
- **v1 (shipped):** deterministic SQL/count-based strategies — zero new infra, Gherkin-provable, explainable.
- **Phase 3 (deployed):** **semantic layer** — pinned multilingual product embeddings (in-JVM ONNX inference, CPU) + pgvector cosine kNN → SIMILAR_CONTENT. It serves the **cold-start customer** (browsed but never ordered) and PDP similar-product placement (§5.9); live coverage is 3,860/3,860 eligible products.
- **Phase 4 (deployed):** no-PII `RecommendationServed`/`RecommendationClicked` CloudEvents via an RLS-protected outbox to `recommendation.events`, then item-grain ClickHouse facts and CTR by environment, traffic class, placement, strategy, and rank. `LIVE`, `SYNTHETIC`, and retained `LEGACY` traffic remain separate; dashboard 22 defaults to the `LIVE` baseline at `/dashboards/recommendation-performance-ctr`.
- **Phase 5 (gated on CTR data):** **Gorse** (Apache-2.0, active 2026, CPU-only, runs on our existing Postgres + idle Redis) for learned matrix-factorization personalization.

---

## 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|upsells` (role `app-catalog-manager`), backoffice-catalog editor UI, storefront PDP widgets (`cross-sell.component.ts`, `product-family.component.ts`). Data is seeded heuristically (`R__seed_10_product_relations.sql`), limits inconsistent: domain 8/4 vs UI 10/5 — to fix in passing. |
| 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 `StockAvailabilityEventConsumer` → `catalog.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` (**thin** — `ProductCreated` 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:

- `C(a,b)` = number of distinct orders containing both a and b (pairs canonicalized `a<b`)
- `C(a)` = number of distinct orders containing a; `N` = total orders

Stored score (**asymmetric cosine with bestseller damping**):

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

- The `C(b)^α` denominator damps the "everything recommends the bestseller" failure mode of raw confidence `C(a,b)/C(a)`.
- Noise floor: pairs kept only if `C(a,b) ≥ minSupport` (=2, config; =1 tolerated below `bootstrapOrderCount` so the demo dataset produces results — explicit config, not a hack).
- We also store raw `co_order_count`, `confidence`, `lift = confidence / (C(b)/N)` for explainability (`reason: "Frequently bought with <seed>" + numbers in debug`) and for reconciliation with ClickHouse ANL-016.
- Multi-seed aggregation (cart with k items): `score(b) = Σ_seeds w_seed · score(seed→b)`, seeds equally weighted in CART, recency-weighted in HOME (§5.4). Normalized to 0..1 by the max in the candidate set.

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)
```

- Exponential decay ⇒ "trending now", not "all-time bestseller"; an item that stops selling fades with a 14-day half-life.
- Recomputed by the projector (decay makes incremental maintenance pointless; full recompute over facts is milliseconds at our scale).
- Stored per product and **per (product, category)** so the personalization step can pull "popular within customer's favored categories".

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 ids** — `PlaceOrderResponse` 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). |

- **New product** (no pairs, no sales): reachable via CURATED_RELATION (if merchandised), featured boost (if flagged), and SIMILAR_CONTENT (embedding exists from first catalog event — no interaction data needed). This closes product cold-start completely in Phase 3.
- **Result < minResults (3, config)**: response returns what exists; the storefront hides the section entirely (REC-010) — no half-empty strips; recommendations never delay or break a page (independent httpResource).

### 5.8 Evaluation methodology

- **Offline/CI**: golden-dataset BDD scenarios (seeded orders → exact expected outputs — possible *because* v1 is deterministic); reconciliation test: our `co_order_count` vs ClickHouse `analytics.basket_product_affinity` on identical event sets.
- **Live data-quality gate (per deploy)**: coverage % (eligible products reachable by ≥1 strategy), zero OOS/unpublished items sampled from real responses.
- **Online (Phase 4)**: `RecommendationServed`/`RecommendationClicked` events (outbox → `recommendation.events` → analytics → ClickHouse) ⇒ CTR per placement/strategy in Metabase. **This is the Phase-5 gate**: Gorse gets adopted only if heuristic CTR plateaus at meaningful traffic.

### 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`):**
- Preferred: `pgvector` in our own `recommendation` schema (`product_embedding(product_id PK, embedding vector(384), embedded_at)`, HNSW index, cosine `<=>`). Keeps vector load OFF the tightly-sized shared ES; kNN over thousands of rows is trivial; joins directly with `product_eligibility` in one query (eligibility filter inside the kNN query — elegant REC-001/002 enforcement). **CONFIRMED available**: CNPG image `harbor.blueshop.local/ghcr/cloudnative-pg/postgresql:17` is built on the standard image family that bundles pgvector. **CRITICAL CONSTRAINT**: `CREATE EXTENSION vector` must be **admin/GitOps-managed, NOT service Flyway** — pgvector is not a trusted extension and recommendation-service's DB role is non-superuser. So: a GitOps/admin bootstrap enables the extension once; the service's Flyway only creates the `product_embedding` table (assuming the type exists). Custom Postgres image only needed if a system/standard tag or CNPG extension-image path is blocked (low/medium effort, no sidecar).
- Fallback: ES `dense_vector` + kNN (VERIFIED: ES 8.17.0, free Basic tier supports dense_vector + kNN/BYO vectors). Downside: shared single-node ES has only 512Mi heap — pgvector wins on isolation and eligibility joins.

**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:**
- `backend/services/recommendation-service` — new Gradle module (settings.gradle.kts, CI build job `build-recommendation`, `ci/required-backend-images.list`, aggregate `bddTest` filter at `backend/build.gradle.kts:1013-1019`, PIT matrix entry per harness conventions MR !825).
- api-gateway: route `/api/recommendation/**` (config-only).
- Infra (GitOps, one MR): overlay `overlays/dev/recommendation/` (kustomization, resources, db-credentials, kafka-credentials patches), ApplicationSet entry, Vault DB role `recommendation-svc` + k8s auth role, Keycloak Kafka OIDC client `recommendation_service`, **Kafka ACLs Read/Describe on `order.orders`, `stock.stock-events`, `catalog.products` + group `recommendation-service`** (+ Write `recommendation.events` in P4), Harbor repo, ExternalSecrets. Start `replicas: 1` (analytics precedent), 100m/512Mi → 500m/1Gi.
- Frontend (storefront only): `RECOMMENDATION_API_URL` token; `recommendations.store.ts` (signalStore + httpResource; personalized resource returns `undefined` during SSR — Keycloak is browser-only — and activates post-init, exactly the `RecentlyViewedStore` pattern; anonymous HOME variant SSR-renders); one reusable `<app-recommended-products>` strip (reuses `ProductDetailProductCardComponent`); mounted in `home.component.html`, `cart.component.html`, `order-confirmation.component.html`; Transloco keys ×4 locales; replaces the disabled `EngagementStore.recommendations` stub.
- **One deliberate catalog touch**: bulk `GET /v1/products?ids=` + (separate MR) `ProductLookupStore` refactor to use it.
- Docs/specs/e2e: business rules page, events page + index entry, ADR-024, `recommendation.spec.yaml` + Makefile line, e2e specs + demo batch entry.

**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`):
- `REC-005`: anonymous HOME returns visible cards and product navigation works.
- `REC-015`: seeded CART recommendations rank curated catalog-manager relations first.
- `REC-003`/`REC-010`: seeded recommendations exclude the seed and return only catalog `PUBLISHED`, non-archived, non-`OUT_OF_STOCK` products.
- `REC-010`: HOME hides the recommendation strip gracefully when the recommendation API returns 503.
- `REC-016`: PDP semantic results use `SIMILAR_CONTENT`, exclude the current product, render localized cards, navigate, and emit the click beacon.
- `REC-016`: an isolated authenticated zero-order customer receives HOME recommendations seeded by recently viewed products.
- `REC-001`/`REC-002`: isolated curated targets disappear after authoritative OOS and archive events while eligible fallback results remain.
- `REC-007`: an isolated customer completes checkout, sees different order-confirmation recommendations, emits a measured click, then receives `PERSONAL_HISTORY` HOME results excluding the purchase.
- `REC-017`: an analytics viewer opens the protected recommendation CTR dashboard and sees all four live cards.

**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.
