##### Context Until now, the Remembrance Plan UI has displayed hardcoded gift cards ("Anniversary Roses $699", "Golf Experience $320") with no backing catalog, no vendor concept, and no product-to-plan binding. To move toward MVP, the Remembrance Plan module needs to attach real, persisted products to people, occasions, and delivery schedules. This requires a product catalog bounded context. Two constraints shape the design beyond a typical e-commerce catalog: 1. **Long-horizon execution.** Rempla plans frequently schedule deliveries years — sometimes decades — into the future. Vendor relationships will change over that horizon: vendors will be retired, replaced, or go out of business, and pricing will drift. The catalog and ordering model must tolerate vendor substitution at execution time without invalidating the customer's original purchase commitment. 2. **Strict domain separation.** Rempla has several adjacent domains that would each naturally pull catalog concerns inward if allowed: the Remembrance Plan domain (schedules, recipients, personalization), the Vault domain (physical storage with its own lifecycle and logistics), and the future Ordering/Fulfillment domain (orders, deliveries, vendor routing). Catalog must stand on its own as a descriptive "what can be bought, from whom, at what price" model, and must not accumulate plan-specific, vault-specific, or order-specific fields over time. ##### Decision Adopt a **three-tier catalog model** (Product → ProductVariant → VendorOffer) with **late-bound vendor resolution** at delivery execution time, **price contracts** frozen at order time, and **display snapshots** preserved for long-running orders. The catalog owns only descriptive data; plan/order/vault concerns live in their respective domains and reference catalog by ID only. **Three-tier catalog** | Tier | Purpose | Vendor-aware? | Carries price? | |------|---------|---------------|----------------| | **Product** | Abstract sellable concept — "Dozen Red Roses", "Premium Round of Golf" | No | No | | **ProductVariant** | Customer-facing option under a product — "12 stems", "24 stems"; size/color/configuration | No | No | | **VendorOffer** | A specific variant-at-specific-vendor tuple with pricing, SKU mappings, stock, lead time | Yes | Yes | One variant can have many offers (multiple vendors selling the same item, or the same vendor at different price tiers). Orders and plans reference **ProductVariantId**, not VendorOfferId — vendor is chosen at execution. **Core catalog entities** - `Product` — Name, description, default image, `DefaultLeadTimeDays`, status, single `ProductCategoryId`. - `ProductVariant` — Belongs to a Product. Variant-specific name ("12 stems"), display attributes (size, color), status. No price, no vendor. - `VendorOffer` — Belongs to a ProductVariant and a Vendor. Fields: `OurSku` (stable internal identifier, auto-generated, opaque, unique), `VendorSku` (whatever the vendor calls it), `VendorCost` (what the vendor charges us), `ListPrice` (what we charge customer), `LeadTimeDaysOverride` (optional), `Status` (Active | Disabled | OutOfStock), `Priority` (tie-breaker when multiple offers exist for one variant). - `Vendor` — Name, contact, `IntegrationType` (Manual | Api), `Status` (Active | Paused | Retired). Retiring a vendor cascades `VendorOffer.Status = Disabled`; the offer row is never deleted. - `ProductCategory` — Flat, seeded taxonomy (Flowers, Experiences, Jewelry, Food & Gourmet, Keepsakes, Charitable Donations). Single-valued per product for v1. - `Occasion` — Seeded (Birthday, Anniversary, Valentine's Day, Mother's Day, Father's Day, Christmas, Graduation, Memorial/Death Anniversary, Just Because). M:N with Product via `ProductOccasion` — a given product may fit several occasions. - `ProductImage` — N:1 to Product (and optionally Variant), ordered. - `VariantSubstitution` — admin-curated join table expressing "if Variant A goes unavailable, Variants B/C/D are acceptable substitutes." Used at execution time only when the governing order's substitution policy allows. **Late-bound vendor resolution (long-horizon execution)** Orders and plans reference ProductVariant, not VendorOffer. Vendor routing is a concern of the fulfillment execution step, not the order-creation step. - `OrderItem.ProductVariantId` — the "what", stable for the life of the order. - `OrderItem.PromisedUnitPrice` — the price the customer was quoted, **frozen at order creation**. Honored at execution regardless of what current VendorOffer pricing looks like. Price variance is the business's margin problem, not the customer's. - `OrderItem.SubstitutionPolicy` — one of `Exact | VariantEquivalent | ProductEquivalent | Cancel`. Governs what the fulfillment service is allowed to do if no VendorOffer for the original variant is available at execution time. - `Delivery.VendorOfferId` — resolved per-delivery at execution time. The fulfillment service queries active offers for the variant (filtered by substitution policy if needed), ranks by priority/availability, and records the winning offer on the Delivery row. If no offer is available and policy is `Cancel`, the delivery fails cleanly. **Display snapshots (long-horizon display)** Marketing data on products will drift over years (re-photography, description rewrites, rebranding). For customer-facing display of in-flight or historical orders, the order item captures a snapshot at order creation: - `OrderItem.ProductNameSnapshot` - `OrderItem.ProductDescriptionSnapshot` - `OrderItem.ProductImageUrlSnapshot` Plan summaries and delivery confirmations display the snapshot so the customer sees what they originally picked, even if the catalog entry has since been rewritten. The live catalog is used for pricing lookup, vendor routing, and new purchases — never for re-displaying historical orders. **Domain boundaries (what the catalog does NOT own)** The catalog does not carry these concepts, under any future pressure: - **Schedule / recurrence** — lives on the plan (annual, one-time, triggered-by-event). Products are scheduling-agnostic; the same "Dozen Red Roses" variant can be bought one-time or scheduled annually. - **Recipient binding** — lives on the plan. Catalog never references a Person. - **Personalization** (handwritten letters, engraving, custom contents) — lives on the plan or vault domain. Personalizable-only items are plan-domain constructs, not catalog variants. - **Vault/storage attributes** — box size, location, chain-of-custody, insurance policy, contents manifest. All vault domain. A vault subscription may eventually surface in the catalog as a pointer-SKU to the vault domain, but the fat model stays in vault. - **Pricing contracts / inflation adjustment / escrow** — plan/payment domain concerns. Catalog carries only current list prices; contracts are a plan construct. - **Order lifecycle / fulfillment state** — ordering domain. Catalog never carries order status. Whenever a schema change proposal adds a `PlanId`, `IsPersonalizable`, `IsScheduledOnly`, `RecipientId`, `VaultBoxSize`, or anything plan/vault/order-specific to `Product`, `ProductVariant`, or `VendorOffer`, treat it as a drift signal and reject or relocate. ##### Rationale - **Three-tier is the industry standard** (Shopify variants, Amazon ASIN+SKU, BigCommerce product+variant+channel listing). It separates the concept customers perceive from the variant they pick from the vendor-specific fulfillment reality. Rempla benefits from all three separations. - **Late-binding to vendor** is the only clean way to handle 10-year delivery horizons. Binding an order to a specific VendorOffer at creation would make every retired vendor a mass-order-invalidation event. Binding to Variant defers vendor selection to the moment it actually matters (delivery execution). - **Frozen promised price** cleanly separates customer commitment from vendor-side pricing volatility. The customer is quoted $699 for Anniversary Roses today; ten years later that customer still gets the Anniversary Roses delivery at no additional charge, regardless of whether the best current VendorOffer is $500 or $900. Margin variance accrues to the business and is the business's problem to hedge, not a surprise line item for the customer. - **Display snapshots** prevent a confusing customer experience where a long-scheduled plan suddenly shows a "different" product because marketing copy was updated. The snapshot is additive (3 nullable string columns on OrderItem) and cheap. - **Single category + M:N occasions** matches current UI reality (which has no filter UX yet, only two example products) while keeping flexibility where it actually matters — a bouquet legitimately serves birthday, anniversary, Mother's Day, and Just Because, and forcing a single "primary occasion" would immediately be wrong. - **Vendor retirement as a status change, not a delete** preserves the audit trail and historical substitution graph needed to reconstruct why any given past delivery was fulfilled by the vendor it was. - **Strict domain separation** prevents the slow accretion that turns every e-commerce catalog into a nightmare of flags (`IsDigital`, `IsSubscription`, `IsGiftCard`, `IsVirtual`, `IsSchedulable`, ...). Rempla is specifically vulnerable to this because plan, vault, and ordering all have legitimate adjacent needs. The ADR calls the boundary out explicitly so we can push back with a reference. ##### Consequences - **New migration** adds: `Product`, `ProductVariant`, `VendorOffer`, `Vendor`, `ProductCategory`, `Occasion`, `ProductOccasion`, `ProductImage`, `VariantSubstitution`. Seeded data for `ProductCategory` (~6 rows) and `Occasion` (~9 rows). - **Admin UI**: `Rempla.Admin` gains catalog management pages (list/detail for each entity), following the existing list+detail + shared-partials pattern established for Descope directory management. - **Order model (future ADR)**: orders reference `ProductVariantId` plus a snapshot of display fields; deliveries record `VendorOfferId` selected at execution. The Ordering/Fulfillment bounded context, its entities, and the execution worker are out of scope for this ADR and will land in ADR 009. - **Remembrance Plan integration (future work)**: the "Add Scheduled Gift" flow currently unwired in the UI gains a browse-catalog step (filter by category / occasion) → pick variant → attach to recipient + schedule. Recipient and schedule stay in the plan domain. - **SKU generation**: `VendorOffer.OurSku` is auto-generated on insert, opaque, sequential with a short prefix (e.g., `REM-000123`). No encoded meaning; renaming vendors or re-categorizing products never invalidates an existing SKU. - **Pricing displayed to customer** comes from `VendorOffer.ListPrice` of the currently-highest-priority active offer. When no offer is active for a variant, the variant is not listable (not shown in the catalog browse UI, not selectable on plans). - **Vault-as-SKU bridging** deferred. If and when the vault vertical needs to surface subscriptions in the catalog, a thin pointer-product mechanism will be added without moving vault domain attributes into catalog. --- **Decision Date**: April 19, 2026 **Author**: Joshua Angulo