##### Context ADR-008 made `VendorOffer` the tier where a vendor meets an abstract sellable `Product`, and ADR-012 gave the offer a durable provenance link to the vendor's feed. Neither settled **who owns an offer's status**, and the field that resulted carries two parties' facts in one column. `VendorOfferStatus` is `{Active, Disabled, OutOfStock}`: - **`Disabled` is ours.** `VendorOfferSyncService` skips disabled offers entirely — it is the operator's only way to stop sourcing a product from a vendor. - **`OutOfStock` is the sync's.** It is written when the vendor stops listing the SKU and cleared when the SKU returns. No operator action produces it and none should. - **`Active` is neither** — it is the residue of the other two being false. Three consequences have now all been observed rather than predicted: 1. **The field cannot be edited safely.** Until 2026-08-11 `SaveOfferAsync` wrote `offer.Status = input.Status` unconditionally, so saving *any* unrelated field on an out-of-stock offer promoted it to `Active` — returning a SKU the vendor no longer publishes to customer-facing routing until the next sync flipped it back. The fix (`ResolveOfferStatus`) reads the operator's choice as "not disabled" and preserves the sync's value. That narrowing works, but it exists only because one column has two writers. 2. **The screen cannot be labelled.** The offer editor's control is titled "Status" beside fields named "Vendor", "Vendor product", "Vendor SKU" and "Vendor cost". It has been read as *vendor* status twice by its own author. A label cannot fix this, because the field genuinely is partly the vendor's. 3. **`Priority` is a silent failover, not a tiebreak.** Both customer-facing routing sites — `GiftController.cs:154` and `:508` — resolve an offer with `.Where(o => o.Status == Active).OrderBy(o => o.Priority).FirstOrDefault()`. When the chosen vendor drops a SKU, the sync marks that offer `OutOfStock` and routing slides to a different vendor with no announcement. Because the browse grid derives `listPrice` from the same expression, **the price shown to customers changes at the same moment, for the same reason, equally silently.** Nothing in admin reports that a substitution happened. There is also no notion of a *chosen* offer at all. Nothing enforces or expresses "this vendor fulfills this product" — `Priority` is an ordering over whatever happens to be `Active`, and all six offers in the shared dev database are `Active` simultaneously with `Priority = 0`. ##### Decision Split the one column into **three fields with one owner each**, and make the customer-facing choice explicit. | Concept | Owner | Storage | In the offer editor | |---|---|---|---| | **Availability** | `VendorOfferSyncService` | `VendorOffer.Availability` | read-only, informational | | **Disabled** | operator, per offer | `VendorOffer.IsDisabled` | a control | | **Selection** | operator, per product | `Product.ActiveVendorOfferId` | chosen on the product | **1. `VendorOffer.Availability` — the vendor's fact, never ours.** Replaces `OutOfStock`. Written only by `VendorOfferSyncService`; no admin path may set it. It is presented in the editor exactly as `VendorSku` now is — visible, explanatory, not editable — because it is the same kind of value: something the vendor told us, which we display rather than decide. **2. `VendorOffer.IsDisabled` — the operator's per-offer sourcing switch.** Replaces `Disabled`, unchanged in meaning: do not source this product from this vendor, and do not sync the offer. It stays per-offer because a product with two offers can drop one vendor and keep the other. Disabled is deliberately *not* the same as unselected. An unselected offer is still worth keeping cost-synced as a live alternative; a disabled one is not. Collapsing them would destroy the ability to hold a warm second source. **3. `Product.ActiveVendorOfferId` — the customer-facing choice, on the product.** A nullable FK from `Product` to `VendorOffer`, replacing `Priority` as the routing input. Single-select is **structural**: the column cannot hold two values, so there is no invariant to police, no filtered index to maintain, and no way for a sync to violate a constraint the operator never touched. It lives on `Product` because that is where the question lives — "who fulfills this product?" is a property of the product, not a rank attached to each of its offers. **4. Routing fails closed.** A product is sellable only when its selected offer exists, is not disabled, and is available. When the selected offer becomes unavailable the product stops being offered until an operator selects another. This is the substantive behaviour change and it is the point of the ADR. Explicit selection and silent substitution contradict each other: if the system may quietly route around your choice, the choice was advisory. Failing closed converts an invisible vendor-and-price swap into a visible gap that admin can report. The existing customer query already degrades this way — `GiftController.cs:161` filters `.Where(a => a.listPrice.HasValue)`, so a product with no resolvable offer is already absent from the browse grid rather than shown broken. Fail-closed reuses that path rather than adding one. **5. Migration preserves today's routing exactly.** At cutover, each product's `ActiveVendorOfferId` is set to the offer today's expression would have picked — lowest `Priority` among `Active` offers, null where there is none. No product's behaviour changes at the moment of the migration; only its behaviour on the *next* stock change does. Status maps as: `Active` → available, not disabled. `OutOfStock` → unavailable, not disabled. `Disabled` → disabled, availability left available for the next sync to establish. ##### Rationale - **One column with two writers cannot be made safe by discipline.** The 2026-08-11 clobber was fixed by narrowing what the editor may write; the same shape recurred the same day when a field was removed from the form and still bound as `0`. Each fix is correct and neither addresses the cause. Ownership has to be expressed in the schema for the compiler and the migration to help. - **The label problem is a modelling problem.** A field that is partly the vendor's and partly ours has no honest name. Splitting it makes both names obvious and makes the read-only presentation of the vendor's half self-evidently correct. - **A pointer beats a ranking for a decision that has one answer.** `Priority` models a preference order over a set, which is the right tool when the system may choose. Once the operator chooses, the data should hold the choice, not the inputs to a choice the system no longer makes. - **Silent failover is the worst failure mode this system has shown.** Every serious defect found in this area — the null credential arriving as a vendor rejection, the status clobber, the SKU drifting from its binding — was invisible until someone looked. Routing that substitutes vendors and prices without a trace belongs to that family, and the fix is the same one: make it announce itself. - **Keeping disabled separate from unselected preserves the second source.** The value of binding two vendors to one product is that the second is ready. That only holds if the unselected offer keeps getting cost refreshes. ##### Consequences - **Status: accepted, not yet implemented.** As of the decision date the split does not exist; `VendorOfferStatus` and `Priority` are still in the schema and still drive routing. - **New migration**, and unlike ADR-012's it is **not additive**. It adds `VendorOffer.Availability` and `VendorOffer.IsDisabled`, adds `Product.ActiveVendorOfferId` (nullable FK → `VendorOffer`, indexed), backfills all three from `Status`/`Priority` per section 5, then drops `VendorOffer.Status` and `VendorOffer.Priority`. The backfill is the load-bearing part and needs verifying against the shared dev database before the drop, not after. - **The FK direction needs care at the database level.** `Product` → `VendorOffer` and `VendorOffer` → `Product` together form a cycle, and SQL Server rejects multiple cascade paths. `Product.ActiveVendorOfferId` should therefore be `ON DELETE NO ACTION`, with `DeleteOfferAsync` explicitly clearing any product pointing at the offer it deletes. This is an implementation hazard, not a design one, but it will surface as a migration error rather than a compile error. - **Both `GiftController` sites change** (`:154` and `:508`), and they are the only customer-facing product queries in the system. Their new form resolves through `Product.ActiveVendorOffer` and checks disabled/availability, rather than filtering and ordering the offer collection. - **`VendorOfferSyncService` changes writer**, setting `Availability` instead of `Status`, and skipping on `IsDisabled` instead of `Status == Disabled`. Its restock path — which today flips `OutOfStock` back to `Active` — becomes an availability change that cannot resurrect a disabled offer, which is what it already intends. - **Admin gains a way to select the offer**, on the product editor rather than the offer editor, plus a report of products whose selected offer has gone unavailable. Without that report, failing closed is worse than the failover it replaces — the gap must be visible or it is just a quieter silence. - **The offer editor loses its Status dropdown**, gaining a disabled control and a read-only availability line. `ResolveOfferStatus` retires with the field it was written to protect. - **Testing** per ADR-021's two layers: pure-unit for the backfill mapping, SQLite round-trip for the FK behaviour, the delete-clears-selection path, and the fail-closed routing. The migration backfill additionally needs a check against real data, since it is the one step no unit test can prove correct for this database. - **Deferred**: operator-approved fallback chains. They were considered and rejected for now as `Priority` with more machinery; if continuity turns out to matter more than explicitness, that is the shape to revisit, and it should be an amendment here rather than a reintroduction of ranking. --- ##### Amendment (2026-08-12): the per-offer disabled flag is removed — two concepts, not three Implemented the same day it was accepted, and the three-way split turned out to be one concept too many. `VendorOffer.IsDisabled` is dropped; the model is availability (the vendor's) and selection (ours). **The flag was redundant in both directions it could point.** Not wanting a vendor at all is already `Vendor.Status` `{Active, Paused, Retired}`, which sits at the level the decision is actually made. Not wanting that vendor for one product is simply not selecting their offer — which the selection column now expresses exactly. A `VendorOffer` is an attachment between one of our products and one vendor product; attaching something is not a statement about whether we want it, and giving the attachment its own opinion invented a third place to look. **The argument that kept it in the original decision does not survive contact.** Section 2 justified `IsDisabled` on the grounds that an unselected offer keeps getting cost refreshes while a disabled one does not, preserving a "warm second source". That is a sync optimisation described as a domain concept. What it actually bought was skipping one API call per unwanted offer — against six offers, and against a feed the catalog sync already walks in full. The cost was a column, a form control, and a second thing an operator had to reason about before a product would sell. **It also could not be labelled.** The editor rendered it as "Do not source from this vendor" under a heading of "Sourcing" — a negative checkbox, where ticking the box makes something not happen, under a word that had named an unrelated screen in the same application earlier that day. That the control resisted every honest label is the same signal ADR-013 already recorded about the field it replaced: a name is hard to find when the concept does not belong where it is being put. Consequences of the amendment: - **`VendorOfferSyncService` refreshes every bound offer**, filtered only by the presence of a vendor SKU. An unselected offer is still worth refreshing — selecting it later should not mean selecting a stale cost. - **Routing simplifies to selection plus availability.** Both `GiftController` sites and the dashboard's unsellable count drop the disabled term. Failing closed is unchanged. - **The offer editor loses the control entirely**, and with it `OnPostArchiveAsync`, which set the flag and which no view had linked to since before this ADR was written. - **Selection becomes a radio per row** on the product editor, posting on change. Mutual exclusion is then visible in the control rather than only in the schema. - **A second migration** drops the column. Verified first that no row held `IsDisabled = 1`, so nothing was lost rather than merely assumed lost. **Still unresolved, and worth naming rather than leaving implicit.** `VendorOffer.Availability` remains on the offer, while `VendorProduct.Availability` — a free-text string from the feed, `"In stock"` across all 502 rows — already describes the same subject one level up. Availability is the vendor's fact about the vendor's product, so it belongs on `VendorProduct`; it sits on the offer only because `VendorOfferSyncService` is what currently detects a SKU disappearing. The deeper gap is that `VendorCatalogSyncService` upserts by SKU and **never notices a product leaving the feed at all** — `DateLastImported` goes stale and nothing reads it. Moving availability up should be done together with teaching the catalog sync to detect disappearance, not before it.