##### Context ADR-008 established the three-tier catalog (`Product` → `ProductVariant` → `VendorOffer`) and made the upper two tiers deliberately vendor-agnostic: a `Product` is an *abstract sellable concept*, not a vendor's listing. ADR-009 and its 2026-07-21 amendment built the vendor integration layers — the domain port, per-vendor adapters, and generic connect/test. The 2026-07-24/25 vendor-catalog work then added a fourth thing that sits **outside** that model: `VendorProduct`, a mirror of the vendor's raw published assortment, imported by `VendorCatalogSyncService`, keyed by the vendor's own SKU, overwritten on every sync, with an append-only `VendorProductReviewEvent` history behind it. Nothing joins the two. Specifically: 1. **`VendorProduct.IsInProductCatalog` is inert.** It is read only by the vendor catalog screen that writes it. Ticking "In catalog" records an intention and produces no sellable anything. The flag has meant "someone ticked a box" since the day it was added. 2. **The only durable link is a fragile string.** `VendorOffer.VendorSku` is nullable free-text, hand-typed in the offer editor. `VendorCatalogSyncService` upserts `VendorProduct` **by SKU** — so a vendor renaming a SKU creates a *new* `VendorProduct` row and silently orphans whatever offer referenced the old string. Nothing detects that. 3. **The binding direction is backwards for a feed.** A `VendorOffer` can only be created from the product side (Products → Variant → Offer → pick vendor), which requires already knowing which of our variants a vendor product corresponds to. That is workable for the three hand-entered manual offers we have. It is useless against a FloristOne feed of several hundred products, which is the forcing function here. *(Direction re-decided — see the 2026-07-30 amendment: the product side becomes the primary bind surface, searching the vendor's whole catalog filtered by vendor and category. What this paragraph correctly identifies is that the product side cannot be the **only** path at feed scale; that is an argument about bulk, not about primacy.)* The existing code is already clear about where the boundary sits. `VendorOfferSyncService` states it directly: *"A vendor's product is not a `ProductVariant`; deciding that 'our Sympathy Cross is fulfilled by their T232-2A' is admin curation. This only refreshes offers a human already bound."* That posture is correct and this ADR preserves it — the gap is that the curation act it defers to has no implementation. ##### Decision Treat `VendorProduct` as a **staging tier outside the catalog**, and define the bridge as an explicit, human-owned **bind** act that produces a `VendorOffer`. Three separate acts, with distinct owners and distinct outputs: | Act | Owner | Produces | |-----|-------|----------| | **Import** | machine (`VendorCatalogSyncService`) | `VendorProduct` rows + `VendorProductReviewEvent` history | | **Shortlist** | merchandiser | `IsInProductCatalog = true` — **intent only**, no catalog rows | | **Bind** | merchandiser | `VendorOffer` bound to a chosen `ProductVariant` | **1. `VendorOffer.VendorProductId` — a nullable FK, the provenance link.** The offer gains `VendorProductId` (nullable `int`, FK → `VendorProduct`, `ON DELETE SET NULL`, indexed). Nullable is load-bearing, not laziness: manual vendors (Rempla Florist Network, Rempla Print Co) have offers with no feed behind them at all, and those must keep working untouched. A null `VendorProductId` means "this offer was not sourced from a vendor feed," which is a legitimate permanent state, not a missing value. `VendorSku` is retained alongside it as a human-readable breadcrumb, but it is no longer the join. The FK survives vendor SKU renames; the string does not. `SET NULL` rather than `RESTRICT` because ADR-008 is absolute that offer rows are never deleted — an offer must outlive the loss of its provenance, degrading to the same state as a manually-created one. **2. Bind creates the offer; bulk-create lands the product in `Draft`.** *(Bulk path deferred, not dropped — see the 2026-07-30 amendment. The single-bind half of this section stands and moves to the offer editor.)* Binding a `VendorProduct` either attaches it to an existing `ProductVariant` or, for genuinely new items, creates the catalog entries for it. The bulk path — the one that makes a several-hundred-product feed tractable — creates, per selected vendor product: - one `Product` with `Status = ProductStatus.Draft`, seeded `Name`/`Description` from the feed - one `ProductVariant` (`Status = Active`) — a vendor feed does not express our variant axis, so one vendor product yields exactly one variant - one `VendorOffer` with `VendorProductId` set, `OurSku` from the existing `GenerateSkuAsync`, `Status = Active` **`Draft` is the safety gate, and it is a verified one:** `Rempla.Web/Controllers/RemembrancePlan/GiftController.cs` filters `Status == ProductStatus.Active`, and it is the only customer-facing product query in the system. A bulk-created draft cannot reach a customer. Promotion to `Active` is a deliberate merchandiser act after the name, description, category, and imagery have been made ours rather than the vendor's. This is what protects ADR-008's "abstract sellable concept" boundary. The protection is the **gate**, not the absence of automation — vendor-shaped rows may enter the catalog, but never in a customer-visible state. **Bulk-create is idempotent.** A vendor product that already has any `VendorOffer` for that vendor is skipped, not duplicated. The FK makes that a cheap existence check, and re-running the action after a partial failure is safe. **3. Pricing — the seed/own split, inherited from ADR-008.** At bind time, `VendorOffer.VendorCost` is seeded from `VendorProduct.Cost`, and `VendorOffer.ListPrice` is *suggested* from `VendorProduct.ListPrice`. After that moment, **`ListPrice` is ours**. No sync path may write it. This is not a new rule — it is exactly what `VendorOfferSyncService` already enforces, on ADR-008's grounds that vendor-side price movement must not silently re-price our catalog and hand the customer our margin variance. The bridge must not become the loophole through which that rule is broken. **4. The two syncs stay distinct.** With a FK now joining them, it will be tempting to merge them. Do not: - `VendorCatalogSyncService` refreshes the **assortment** (what the vendor publishes) and *flags changes for human review*. - `VendorOfferSyncService` refreshes the **cost of bound offers** and *acts without review*. They differ in what they are allowed to do unattended. A price move on an unbound vendor product is merchandising information; a price move on a bound offer is an operational cost update. Collapsing them would either make assortment drift silently authoritative or make cost updates require review they do not need. **5. Un-shortlisting a bound product is blocked, not cascaded.** Clearing `IsInProductCatalog` on a `VendorProduct` that backs one or more offers is rejected with a message naming those offers. It does not delete them, and it does not auto-disable them. Auto-disabling is the tempting shortcut and it is wrong: per ADR-009, `Delivery` resolves an offer at execution time, so disabling the only active offer for a variant makes that variant unroutable and turns in-flight scheduled gifts into `Failed` deliveries. That consequence is far too large to trigger from an unticked checkbox on a curation screen. Retiring an offer stays an explicit act on the offer. **6. Category mapping never mutates our taxonomy.** `VendorProduct.Category` is free text from a feed; `ProductCategory` is our seeded, flat taxonomy (ADR-008). On bulk-create, map by exact name match; on no match leave `Product.ProductCategoryId` null (it is nullable) for the merchandiser to set during promotion. A vendor must never be able to create a Rempla category by publishing a new string. **7. No new status column.** The merchandiser's work queue — shortlisted but not yet bound — is derived (`IsInProductCatalog && !Offers.Any()`), not stored. `IsInProductCatalog` remains the record of *intent*; the presence of an offer is the record of *effect*. Storing a third "bound" flag would create a state that can disagree with the rows it describes. ##### Rationale - **The boundary ADR-008 drew is about customer visibility, not data provenance.** Once that is seen clearly, `Draft` is the natural enforcement point, and the choice between "protect the catalog by forbidding automation" and "make a large feed tractable" dissolves — the gate gives both. - **A nullable FK beats a matched string** for a link that must survive renames, and nullable honestly models manual vendors rather than forcing a synthetic `VendorProduct` row for offers that never came from a feed. - **Intent and effect are genuinely different states.** A merchandiser shortlisting 40 products and binding 12 of them today is a normal working pattern; a model that cannot represent the other 28 loses the work queue that makes the screen useful. - **Blocking the un-shortlist is the conservative reading of ADR-009.** Against a vendor with no cancel and no status, anything that can silently make a delivery unroutable deserves an explicit act, not a side effect. - **Keeping the two syncs separate preserves the review loop** built on 2026-07-27, which exists precisely because assortment changes need a human and cost refreshes do not. ##### Consequences - **Status: accepted, not yet implemented.** Nothing in this ADR is built as of the decision date; the vendor catalog screen, review history, and both sync services exist, the bridge does not. - **New migration** adds `VendorOffer.VendorProductId` (nullable int), FK to `VendorProduct` with `SET NULL`, and `IX_VendorOffer_VendorProductId`. Additive; no existing column changes and no data migration — every existing offer legitimately starts null. - **New service** in `Rempla.Core` for the bind operations (bind-to-existing-variant, bulk-create-as-draft, idempotency guard), following the `VendorCatalogSyncService` / `VendorProductReviewService` pattern of taking `RemplaDbContext` + `TimeProvider` so it is testable against the SQLite harness. - **Admin UI**: the vendor catalog screen gains a bind action and a per-row indication of what a product is bound to; the offer editor gains a read-only provenance line. `CatalogAdminService` delegates, as it does for sync and review. *(Location re-decided — see the 2026-07-30 amendment: the bind action lives in the offer editor, not on the vendor catalog screen. The per-row bound-to indication survives, as read-only information.)* - **`SaveVendorCatalogSelectionsAsync` gains a rejection path** for un-shortlisting bound products, which is a behavior change to an existing method and needs its own tests. - **Draft accumulation is the accepted cost** of the chosen approach. Bulk-creating 40 drafts and promoting 12 leaves 28 un-promoted products in admin. They are invisible to customers and harmless, but a "drafts pending promotion" count belongs on the catalog dashboard so they are not forgotten. - **Testing** per ADR-021's two-layer strategy: pure-unit for the mapping/seed/idempotency rules, SQLite round-trip for the FK behavior, the `SET NULL` degradation, and the un-shortlist rejection. - **Deferred**: multi-variant vendor products (a vendor expressing sizes/tiers in its feed) — a per-vendor Layer-2 adapter concern under the ADR-009 amendment, not a bridge concern. Also deferred: automatic re-binding when a vendor SKU rename produces a new `VendorProduct` row; the FK makes the orphan *detectable*, and surfacing it is enough for now. --- ##### Amendment (2026-07-30): Bind is a product-side act over the whole vendor catalog; bulk-create is deferred Running the vendor catalog screen against the live FloristOne feed made the entry-point question concrete in a way the original decision did not have to face: **502 imported · 0 in catalog · 501 needing review**. The original text located the bind action on the vendor catalog screen (see Consequences, "Admin UI") and treated the product-side path as the thing being replaced. That location is what this amendment corrects, along with the ordering it implied — shortlisting is no longer a step on the way to binding. The three acts still exist and the FK, the `Draft` gate, the separation of the two syncs, the un-shortlist rejection, and the category rule all stand unchanged. **1. There are two questions here, not one, and they start from opposite ends.** - *"Of this vendor's 502 products, which do we want?"* — assortment triage. Starts from the feed, is inherently per-vendor, and is what the catalog screen is already good at. - *"We sell a Sympathy Cross — who fulfills it?"* — sourcing. Starts from our catalog, is inherently cross-vendor, and is a question the catalog screen structurally cannot ask. The original Context §3 judged the product-side path unusable at feed scale. That judgement is correct about it being the *only* path and is not disturbed. It does not follow that it should be the *secondary* path — those are different claims, and the original conflated them. **2. Bind moves to the offer editor, over the vendor's whole published assortment.** Products → Variant → Offer gains a vendor-product picker: choose a vendor, then narrow that vendor's **entire catalog** by vendor category and a name/SKU search, and pick one. Selecting sets `VendorProductId`, seeds `VendorCost` from `VendorProduct.Cost`, and *suggests* `ListPrice` — the seed/own split of section 3, unchanged. `VendorSku` stops being hand-typed and becomes a populated, read-only breadcrumb, which is what section 1 always intended it to be. **Vendor and category are the two filters that matter**, and they are what make several hundred products tractable — not a pre-curated subset. The picker is therefore a search over a result list rather than a `