##### Context ADR-013's amendment (2026-08-12) closed by naming this problem and deferring it: > `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. […] 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. This ADR is that follow-through. Three findings sharpen it, all verified in code rather than assumed. **1. `VendorProduct.Availability` was never a vendor fact.** It is not merely stale or unreliable — it is a constant. `VendorCatalogSyncService.ToImportModel` sets it literally, with a comment saying why: ```csharp // The product feed carries no stock signal — a retired SKU is discovered by offer sync. Availability = "In stock" ``` The feed DTO confirms it: `VendorFeedProduct` carries `VendorSku, Name, Description, VendorPrice, ImageUrl, ThumbnailUrl, VendorCategories` and nothing else. There is no stock or listing member to map from. **2. Its change detection cannot fire.** `VendorCatalogSyncService:213` compares the stored availability against the imported one to raise a `VendorProductChangeType.Availability` review event. Both sides are the same constant, so the comparison is always false. The enum member exists, the review path exists, and no sync can ever reach it. This is a check that cannot fail, and it has been reporting health by saying nothing. **3. The column is displayed as though it were data.** `Pages/Vendors/Catalog.cshtml:214` renders an "Availability" column reading `"In stock"` for every row of the assortment, including rows for products the vendor may have retired months ago. Meanwhile the signal that *is* real sits one level down. `VendorOffer.Availability` is written by `VendorOfferSyncService`, which calls `GetProductByCodeAsync(offer.VendorSku)` and treats a null return as retirement. That works, but it only ever runs over **bound** offers. Delisting is therefore invisible for the entire raw assortment: a vendor product nobody has bound can vanish from the feed and our table will keep asserting it is in stock indefinitely. The only trace is `DateLastImported` failing to advance, and nothing reads it. **A separate axis, easily conflated.** Delivery availability is not a property of a product at all. `checkdeliverydate?zipcode=X[&date=Y]` takes no product code and returns either a list of dates or a single `DATE_AVAILABLE` boolean. Deliverability is a **ZIP × date** question for the whole vendor, and it must not be folded into the same field as "does the vendor still sell this SKU". Two different words that both sound like "available". ##### Decision **1. Delisting is our observation, not the vendor's statement.** Replace the free-text `VendorProduct.Availability` with an explicit listing state, and name it so no reader mistakes it for a value the vendor sent: | Field | Meaning | |---|---| | `VendorProduct.ListingState` | `{Listed, Delisted}` — whether the vendor still publishes this SKU | | `VendorProduct.DateDelistedDetected` | when a sync first failed to find it; null while listed | `Delisted` is an **inference drawn from the feed's silence**, not a field anyone transmits. That distinction is the whole point of the rename: the previous column implied the vendor was telling us something, which invited the constant that satisfied the shape without carrying the meaning. **2. `VendorProduct` owns availability; `VendorOffer.Availability` is dropped.** Per ADR-013's amendment, availability is the vendor's product's property, so it belongs on the row representing the vendor's product. An offer's availability becomes derived: an offer is unavailable when the `VendorProduct` it is bound to is `Delisted`. Routing keeps failing closed exactly as ADR-013 established; only the source of the term moves. This removes the current oddity that delisting is detected per bound SKU by a second service, which is why it is invisible for everything unbound. **3. Catalog sync detects disappearance — but only from a provably complete read.** After the upsert pass, any existing `VendorProduct` for that vendor whose SKU did not appear in this run becomes `Delisted`, with a review event raised. A SKU that reappears returns to `Listed` and its `DateDelistedDetected` clears. **This step is conditional on the feed read having completed.** `ReadFeedAsync` pages until a short page and bails at `MaxPages = 100`; a truncated or errored read means the absence of a SKU proves nothing. If the walk did not finish cleanly, the sync must skip delisting entirely and say so, rather than delist the remainder of the catalog. This is the same rule the catalog-image sweep follows for its reference set, and for the same reason: an incomplete picture of what exists must never authorise acting on what is missing. Here the failure would be marking hundreds of live products retired on one bad HTTP response. **4. Delisting bubbles up to the catalog product as a derived state, not a stored one.** A `Product` is **not offered** when its selected offer's vendor product is `Delisted`. This is computed, never written to `Product`: - `Product.Status` `{Draft, Active, Archived}` stays purely the operator's editorial decision. A vendor retiring a SKU is not an editorial act and must not silently rewrite an operator's field — that is the two-writers-in-one-column mistake ADR-013 exists to correct. - The customer-facing consequence already works: routing resolves through the selected offer and its availability, so an unavailable selection removes the product from the browse grid. - What is missing is **visibility**, and that is what this adds. The product grid shows a single derived status — `Draft` / `Active` / `No offering` / `Archived` — where `No offering` replaces `Active` for an active product that cannot be sold. A delisted selected offer also raises a review, so the gap is worked rather than waited on. *Corrected 2026-08-17 during implementation.* This originally specified *Offered / Not offered* **alongside** `Status`, as a second column. Built that way it was immediately wrong on the screen: a row reading "Active" next to "Not offered" is a contradiction on its face, and a reader has to know which column outranks the other. One column with one answer is the honest presentation, and it costs nothing — the value is still derived and `Product.Status` is still written only by the operator. The editor keeps the three real statuses in its dropdown, because `No offering` is a consequence and not something an operator may choose; it explains the state in prose beneath instead. The correction is recorded rather than quietly applied because the two-column form is the obvious first design and the reason it fails is not obvious until it is on screen. An operator resolves it by selecting a different offer, binding a new vendor product, or archiving the product. Until they do, the product stays visible in admin as not offered — the state is a prompt, not a silent disappearance. **5. Unbound, uncarried, delisted products drop out of view rather than being deleted.** A delisted vendor product that is neither carried (`IsInProductCatalog = false`) nor bound to any offer is excluded from the vendor catalog screen's default filter. It "just disappears" from the operator's view, which is the intent, without the row being destroyed. Kept rather than deleted because deletion would discard two things worth keeping: an operator's `SelectionState = Excluded` decision, and the review history on `VendorProductReviewEvent`. A feed that drops a SKU for one sync and restores it the next would otherwise return it as `NeedsReview`, re-asking a question already answered. Purging genuinely dead rows is a separate housekeeping concern, and if it is wanted it should be a sweep with its own explicit confirmation — not a side effect of a sync. **6. Vendor capability scope: three levels, and one of them stores nothing.** The question "what belongs at vendor level and what at vendor-product level" has a third answer that neither level can hold: | Scope | Data | Storage | |---|---|---| | **Vendor** | `MaxAdvanceBookingDays`, `SupportsCancel`, `SupportsStatusPolling`, `NonDeliverableDays` | on `Vendor` | | **Vendor product** | `ListingState`, cost, list price | on `VendorProduct` | | **Per order** | is this ZIP deliverable on this date | **stored nowhere** | Deliverability for a ZIP and date is asked at the moment of use. It is not a product attribute and not a static vendor attribute, so it has no row, and `IsDeliveryDateAvailableAsync` remains authoritative. The vendor-level four divide further by how they are obtained: - **Operator-recorded** — `SupportsCancel`, `SupportsStatusPolling`. Not pullable by any means. These are statements about what the API *lacks*, and there is no capability or discovery endpoint (nor a `getcategories` — that 404s) for an absent endpoint to announce itself through. A human read the docs and tested; the record of that belongs in data. - **Observed** — `MaxAdvanceBookingDays`, `NonDeliverableDays`. Derivable from `checkdeliverydate?zipcode=X`: the furthest returned date gives the horizon, and the weekdays absent from the list give the pattern. That is how the current values (30 days, Sunday) were measured on 2026-07-16. Observed values are stored as a **timestamped cache, never as authority**, and the schema should make that hard to misread — a `DateCapabilitiesProbed` beside them, and the UI labelling them as last observed rather than configured. The result is per ZIP and FloristOne also skips holidays, which no day-of-week rule expresses. A stored `NonDeliverableDays` that looks like a setting will eventually be scheduled against instead of the API being asked, which is precisely the failure the existing adapter comment warns about. ##### Rationale - **A constant that satisfies a shape is worse than a missing field.** `Availability = "In stock"` gave every consumer something to read and nothing to learn. A nullable column with no value would have shown up as unpopulated; a constant reads as healthy on every screen and in every comparison. This is the same family as the checks that rendered "unknown" as passing. - **The name invited the defect.** A column called `Availability` on a row imported from a vendor implies the vendor supplied it. When the feed turned out not to carry it, the shape still had to be filled, and a literal was the only way. `ListingState` with an explicit "we inferred this" reading cannot be satisfied that way. - **Detection belongs where the whole set is walked.** `VendorOfferSyncService` sees only bound SKUs, so it can only ever discover retirement for products someone already chose. `VendorCatalogSyncService` walks the entire assortment and is the only place that can tell presence from absence. Putting detection anywhere else guarantees blind spots. - **Derived beats stored for a state with two contributing owners.** "Not offered" is a function of the operator's selection and the vendor's listing. Storing it would create a third value to keep in step with two others, which is how `VendorOfferStatus` went wrong. - **Absence is only evidence when the search was complete.** This is the load-bearing safety property, and it is worth stating as a principle because it recurs: the image sweep must not delete on an unreadable database, and the catalog sync must not delist on a truncated feed. Both are "I did not find it" being mistaken for "it is not there". ##### Consequences - **Status: proposed, not implemented.** As of this ADR the constant column, its unreachable review path, and its UI column are all still present. - **Migration, and it is not additive.** Adds `VendorProduct.ListingState` and `VendorProduct.DateDelistedDetected`; backfills every existing row to `Listed` (the honest default — the constant told us nothing, so nothing is known to be delisted); drops `VendorProduct.Availability` and `VendorOffer.Availability`. Adds the `Vendor` capability columns plus `DateCapabilitiesProbed`. - **The drop of `VendorOffer.Availability` is the risky step**, because ADR-013 made it a routing input. Both `GiftController` sites and the dashboard's unsellable count resolve through it today and must move to the bound `VendorProduct` in the same change, not after it. - **Sequencing against the shared database.** The dev SQL Server is shared with the Azure sites, which run pre-migration code; dropping a column those apps still select will break them. The additive half and the drops should be separate migrations with a deploy between, as the 2026-07-29 `Rempla:CredentialKey` episode showed for config and this would repeat for schema. - **The first real delisting run should be treated as a finding, not a routine sync.** All 502 rows currently claim to be in stock and none has ever been checked; the first complete feed comparison is the first honest answer and may delist a substantial number at once. Worth running it in report-only form before it writes. - **`VendorProductChangeType.Availability` becomes reachable for the first time.** It should be renamed alongside the field it describes. - **Testing** per ADR-021's two layers: pure-unit for the presence/absence diff and for the refusal to delist on an incomplete read; SQLite round-trip for the derived offer availability, the routing change, and the reappearance path that clears `DateDelistedDetected`. The truncated-feed case matters most — it is the one where a passing test and a broken catalog look identical from the outside. - **Deferred**: purging long-delisted unbound rows, and probing capabilities on a schedule. Both are housekeeping with their own failure modes and neither should ride along on a sync.