##### Context ADR-008 stood up the product catalog and explicitly reserved the Ordering/Fulfillment execution context for this ADR. ADR-010 then built the Scheduled Gift plan aggregate and deferred to it again, by name: *"per-delivery generation from the schedule, vendor resolution, the `Delivery` entity, Reserve ledger/release accounting, and installment charge execution."* The result is a real gap. `CustomerScheduledGift` records a **commitment and a schedule**; `ScheduledGiftTransaction` records the money and freezes `PromisedUnitPrice`. Nothing turns a scheduled occurrence into a delivery that actually happens. There is no `Delivery` row, no vendor routing, no Reserve release, and no worker. The catalog knows what can be bought and from whom; the plan knows what was promised to whom and when; the step between them does not exist. This ADR is now unblocked by a concrete forcing function: **FloristOne is approved as the first `Api` vendor** (`Vendor.IntegrationType = Api`). Its API was probed end to end against a live test key on 2026-07-16, including a successful test order. That probe replaced speculation with measurements, and the measurements — not a generic e-commerce model — are what shape this design. Full detail lives in the workspace note `docs/vendor-floristone-evaluation.md`; the five that carry design weight: 1. **The vendor booking window is T+1 → T+30, hard.** 26 bookable dates were returned for a 30-day span; a 2035 date returns `DATE_AVAILABLE: false`. A gift scheduled for 2035 **cannot** be booked in 2026. This is not a FloristOne defect to work around — it *confirms* ADR-008's late-bound vendor resolution. Fulfillment is a T-minus job. 2. **The payment token is single-use.** Payment is an Authorize.net Accept.js nonce; replaying a spent one returns `Invalid OTS Token`. **No payment method can be stored at commit and replayed at delivery**, which is precisely what a Reserve-funded, years-later order needs to do. 3. **There is no cancel, no refund, no status, and no webhook.** `cancelorder`/`refundorder`/`updateorder` all 404, and order lookup returns no status field. **Placement is irreversible and unobservable.** 4. **Sundays and holidays are not deliverable.** All four in-window Sundays were absent from the returned dates. A gift's anchor date frequently *will not be bookable*. 5. **The vendor is shaped nothing like our domain.** ISO in / US-format out, SCREAMING_CASE JSON, an `exit` code rather than HTTP status for business failures. Finding (3) is the one that should change instincts. Most ordering models assume orders can be cancelled, amended, and observed. Against this vendor, **an order placed in error cannot be recalled by software** — a wrong delivery is a phone call, and a duplicate is a second bouquet at a grieving family's door. Correctness of *at-most-once placement* outranks throughput, retries, and elegance everywhere in this context. ##### Decision Introduce the **Ordering/Fulfillment bounded context**: it owns `Delivery`, vendor routing, the Reserve ledger, and the vendor adapter port. It reads the gift aggregate and the catalog; it mutates neither (except a gift's terminal status). **1. `Delivery` — one row per scheduled occurrence.** The unit of execution, materialized from a `CustomerScheduledGift`'s schedule. It is the system of record for what happened, because the vendor cannot tell us. - Identity/lineage: `Id`, `CustomerScheduledGiftId`, `SequenceNumber` (1-based occurrence index within the gift). - Dates: `TargetDate` (derived from the gift's anchor + cadence — what the customer was promised), `ResolvedDeliveryDate?` (what the vendor actually accepted, after any shift). - Late-bound catalog binding: `ProductVariantId` (copied from the gift — the stable "what"), `VendorOfferId?` (**null until execution**, resolved per ADR-008 and stamped at placement). - Vendor result: `VendorOrderRef?` (their order number), `VendorChargedTotal?` (what we were actually charged). - Control: `Status`, `IdempotencyKey` (stable, derived — see below), `AttemptCount`, `LastError?`. - Audit: `DateCreated`, `DateUpdated`. `DeliveryStatuses`: `Scheduled` → `Due` → `Resolving` → `Placing` → `Placed` → (`Delivered`) — with `Failed`, `Skipped`, `Cancelled` as terminals. `Delivered` is reachable **only by manual/out-of-band confirmation**, never by polling: the vendor exposes no status. Naming it honestly beats a status we cannot populate. **2. `ReserveLedgerEntry` — Reserve accounting.** ADR-010 guarantees `Total` and holds it in the Reserve, but records only the commitment. Per-delivery release needs a ledger. - `Id`, `CustomerScheduledGiftId`, `DeliveryId?`, `EntryType`, `Amount`, `Description`, `DateCreated`. - `ReserveLedgerEntryTypes`: `Fund` (signup/installment payment in), `Release` (vendor cost out at placement), `Refund`, `Adjustment`. - Append-only. Balance is a fold over entries, never a mutable column — this is money, and a stored balance drifts. - At `Placed`, write one `Release` for `VendorChargedTotal`. Margin variance against `PromisedUnitPrice` is absorbed by Rempla, per ADR-008's frozen-promised-price posture. The ledger makes that variance *visible* rather than silent. **3. `IVendorFulfillmentAdapter` — the port.** *(Resolution superseded — see the 2026-07-21 amendment: the adapter is bound explicitly per vendor via a registry, not resolved from `IntegrationType`.)* ``` Task> GetProductsAsync(ct) Task> GetAvailableDeliveryDatesAsync(string zip, ct) Task PlaceOrderAsync(VendorOrderRequest req, ct) VendorCapabilities Capabilities { get; } ``` **`VendorCapabilities` is not ceremony — it is finding (3) made structural.** The port must not pretend every vendor supports cancel, status, or arbitrary lead times, because our first one supports none of them: - `MaxAdvanceBookingDays` (FloristOne: 30) — the fulfillment trigger reads this rather than hardcoding. - `SupportsCancel`, `SupportsStatusPolling` (FloristOne: both false). - `NonDeliverableDaysOfWeek` (FloristOne: Sunday). A `GetOrderStatusAsync` on the interface would be a lie against this vendor. Capability flags let the worker ask before assuming, and let a future richer vendor light up behavior without reshaping the port. **4. Fulfillment worker — a T-minus job, not a plan-time action.** Runs daily against `Active` gifts: - **Materialize.** Project the gift's schedule forward and create `Scheduled` deliveries for occurrences inside a horizon. Materialize a bounded window (not decades of rows), keyed by `(CustomerScheduledGiftId, SequenceNumber)` uniquely so re-runs cannot double-create. - **Trigger.** A delivery becomes `Due` at `TargetDate - LeadTimeDays`, where `LeadTimeDays` is safely inside `Capabilities.MaxAdvanceBookingDays` (~7 days for FloristOne, not 30 — leaving room to retry before the window closes). **This is the whole reason a 30-day vendor window can serve a 30-year promise.** - **Resolve.** Pick the active `VendorOffer` for the variant by priority (ADR-008); apply the gift's `SubstitutionPolicy` via `VariantSubstitution` if none is available; `Failed` cleanly if policy is `Cancel`. - **Date-shift.** If `TargetDate` is not in the vendor's returned dates (Sunday/holiday), shift by policy. **Default `PreferEarlier`**: for a birthday or a memorial anniversary, arriving the day *before* honours the occasion; arriving after is a failure the customer feels. Record both `TargetDate` and `ResolvedDeliveryDate` so the gap is auditable. - **Place.** Idempotently (below), then write the Reserve `Release`. **5. At-most-once placement.** Because placement is irreversible (3), this is the context's central safety property, not an optimization: - `IdempotencyKey` is **derived, not random**: a stable hash of `(CustomerScheduledGiftId, SequenceNumber)`. A retry after a crash recomputes the same key rather than minting a new one. - A **unique index on `Delivery.IdempotencyKey`** plus a guarded transition `Placing → Placed` (optimistic, `AttemptCount` incremented under the same update) makes a double-place a database error rather than a second bouquet. - **A timed-out or ambiguous placement is `Failed`, never auto-retried.** With no status endpoint, we cannot distinguish "never placed" from "placed, response lost". Auto-retry gambles a duplicate on that ambiguity. Park it for human review instead — the correct action for an unobservable, irreversible external effect. **6. Payment seam — defined here, chosen elsewhere.** Finding (2) leaves a real unsolved problem: the order needs a fresh single-use token from a chargeable card at fulfillment, years after the customer's card is gone. This ADR defines only the seam: ``` Task AcquireAsync(Delivery d, decimal amount, ct) ``` The implementation — virtual card issuing per delivery, a PCI-vaulted corporate card, or an invoiced partner account — is **an open decision, deliberately parked** (see `docs/vendor-floristone-evaluation.md` → "Open architectural fork"). The read-only half of the adapter and everything above is unblocked by that parking; the order-placing half is not, and must not ship until it is resolved. *Note (2026-08-12): ADR-010's amendment — the gift is always paid in full at signup — does **not** close this fork, and should not be cited as doing so. It guarantees the Reserve holds the money; this seam is about having a chargeable instrument to hand the vendor years later. Full funding removes the question "can we afford this delivery"; it leaves "what do we pay them with" untouched.* ##### Rationale - **Late binding is vindicated, not tolerated.** The 30-day window would be fatal to a design that booked vendors at plan time. ADR-008 chose otherwise for vendor-churn reasons, and that choice independently absorbs a constraint it never anticipated. Keep the seam exactly where it is. - **Model the vendor we have.** Capability flags and an honest `Delivered`-by-hand status describe a vendor with no cancel and no observability. A richer port would encode wishes and fail silently. - **The ledger is append-only because it is money.** A running-balance column invites drift and hides the margin variance ADR-008 says we accept. - **Rows, not decades.** Materializing a bounded horizon keeps a 30-year plan from creating 30 years of rows at signup while keeping `(gift, sequence)` a stable identity. - **Ambiguity parks; it does not retry.** Every automatic retry against an unobservable, irreversible endpoint is a wager that the failure happened before the side effect. Against flowers to a grieving family, that is not a wager to automate. ##### Consequences - **New migration**: `Delivery`, `ReserveLedgerEntry`, and the enums `DeliveryStatuses`, `ReserveLedgerEntryTypes`. Enums stored as int (house convention). Unique indexes on `Delivery.IdempotencyKey` and `(CustomerScheduledGiftId, SequenceNumber)`. - **Boundaries hold**: catalog is read-only here (ADR-008); the gift aggregate is read-only except terminal status. No fulfillment field leaks into either. - **Testing** per ADR-021's two-layer strategy: pure-unit for date-shift, trigger arithmetic, substitution resolution, and ledger folds (injected `TimeProvider`); SQLite round-trip for persistence and the idempotency constraint. The adapter is tested against a fake; **no test may hit the live vendor** — a test suite that places real orders is a test suite that mails real flowers. - **Operational reality to accept, not solve in code**: no delivery confirmation means "did it arrive?" is out-of-band. `Delivered` will be set by a human or not at all. Any customer-facing "delivered" claim must not outrun that. - **Deferred**: installment charge execution (ADR-010's other reservation) is scheduling + payments, and belongs with the payment fork rather than in the delivery path. Vendor-agnostic order aggregation (multiple deliveries per vendor order) is not modelled — one delivery, one order, until a vendor rewards batching. - **Blocked until the payment fork resolves**: `PlaceOrderAsync`, `IFulfillmentPaymentProvider`, and the Reserve `Release` path. The read-only adapter half (`GetProductsAsync` → `VendorOffer` sync, `GetAvailableDeliveryDatesAsync`) is unblocked and may proceed. --- ##### Amendment (2026-07-21): Vendor integration is layered — configuration for transport, code for meaning Building the vendor-management admin surfaced a design question the original decision skated past: *how much of a vendor integration can be data/configuration, and how much must be code?* The original text implied a single axis (an adapter "resolved by `IntegrationType`"). That framing was wrong and is corrected here. The integration is **three layers**, and the config-vs-code answer differs per layer. This is the standard ports-and-adapters / anti-corruption-layer pattern; the amendment records how it lands here. **Layer 1 — the domain port (`IVendorFulfillmentAdapter`): one hand-written interface, vendor-agnostic.** Unchanged from the original decision. The app codes against this and never names a vendor. Deep operations (catalog, delivery dates, and eventually `PlaceOrderAsync`) live here because they are inherently vendor-specific. **Layer 2 — per-vendor adapters: code, one class per vendor.** Translating the port to a specific vendor's API is *code*, not configuration, and deliberately so. Vendor APIs differ far below the URL — pagination, error conventions (FloristOne's HTTP-200-with-error-body), date formats, idempotency, partial failures. A configuration format expressive enough to describe all of that becomes an untyped, untestable programming language (the *inner-platform effect*). Adapters are code so they get types, tests, review, and a home for each vendor's quirks. `FloristOneClient` is the first. **Layer 3 — generic connect/test + connection configuration: data, shared infrastructure.** Reaching and authenticating against an HTTP vendor, and probing that it is live, *are* uniform across vendors and are correctly configuration-driven: - `VendorApiConnection` (base URL, `VendorAuthScheme` ∈ {None, Basic, Bearer, ApiKeyHeader}, header name, credentials, health-check path) — all DB-owned on the `Vendor` row. - `IVendorConnectivityTester` / `HttpVendorConnectivityTester` — a generic read-only `GET base + health-path` with the configured auth; any 2xx is "connected". Any HTTP vendor can be **onboarded, configured, and health-tested with zero code**. This is also the unit a future heartbeat reuses. **The guardrail:** the generic layer stops at *transport / auth / liveness*. The moment configuration would describe *semantics* — how to parse a response, which field is the price, how to build an order — it becomes a per-vendor adapter (Layer 2). Config for transport, code for meaning. **`VendorIntegrationType` is a capability flag, not a resolver.** It is now `{ Manual, Api }` — a product decision ("does this vendor talk to an API at all?"), surfaced as a toggle in admin. It does **not** select an adapter; the earlier `IntegrationType`-carries-the-provider approach coupled onboarding to a code/enum change and is dropped. **Adapter binding — the registry seam.** Which Layer-2 adapter drives an API vendor is an explicit binding, `Vendor.FulfillmentAdapterKey` (nullable string), chosen in admin from `IVendorFulfillmentAdapterRegistry.Available` — the set of adapters that actually exist in code. `Resolve(key, VendorApiConnection)` constructs the bound adapter from the vendor's stored connection; null when unbound (the vendor is connect/test-only) or the key is unknown to the deployment. Adding a vendor's adapter is one registry entry — no central enum, and it appears in the admin dropdown automatically. The dropdown lists *code that exists*; it never defines a vendor. `Resolve`'s first caller is the (still payment-blocked) order path — the binding is recorded now so that path has a resolved seam to build against. **Credentials — encrypted at rest in the DB.** With the whole connection DB-owned, credentials live on the `Vendor` row but only ever as ciphertext (ASP.NET Core Data Protection; the key ring lives outside the DB, so a DB dump alone cannot reveal them). They are write-only from the admin screen — plaintext is never rendered back — and decrypted only in-process at call time. This is a deliberate move of the original secret-in-store posture into the DB, chosen so a vendor is fully self-contained and operator-editable; the encryption preserves the "not readable from a DB dump" property. *Production requires a shared, persisted, KMS/certificate-wrapped key ring so the fulfillment worker can decrypt the same credentials and keys survive redeploys.* **Consequences of the amendment.** `IntegrationType` reverted to `{ Manual, Api }`; `Vendor` gained `FulfillmentAdapterKey`, `ApiBaseUrl`, `AuthScheme`, `AuthHeaderName`, `HealthCheckPath`, and encrypted `ApiKeyCipher`/`ApiPasswordCipher`. Connectivity moved off the fulfillment port onto the generic tester. None of the original ordering/Reserve/idempotency decisions change — this amendment is entirely about how vendors are configured and how the adapter is chosen, upstream of everything the original decision covers. --- ##### Amendment (2026-08-17): the purchase-time vendor snapshot, and processing as a best-match decision `VendorOrder` (added 2026-08-12) snapshots `VendorId`, `VendorSkuSnapshot` and `VendorCostSnapshot` so that what was sent to a vendor is a historical fact. That is correct and unchanged. What it cannot do is support the comparison the order-processing step actually needs, because **its snapshot is taken at placement — which is the moment of processing, not before it.** There are two moments, and only the later one records anything about a vendor: | Moment | Entity | Frozen | |---|---|---| | Customer pays, at signup | `ScheduledGiftTransaction` | product name, description, image path, `PromisedUnitPrice` — **no vendor data at all** | | Operator places the order, possibly years later | `VendorOrder` | `VendorId`, `VendorSkuSnapshot`, `VendorCostSnapshot`, `VendorOfferId` | So at processing time there is nothing from purchase time to compare against. An operator can compare what they sent against what is live only *after* they have sent it, which is the wrong order for a decision. **1. `ScheduledGiftTransaction` gains the purchase-time vendor snapshot.** `VendorIdSnapshot`, `VendorSkuSnapshot`, `VendorCostSnapshot`, and `VendorOfferIdSnapshot` (nullable, provenance only, `SetNull` on offer deletion — the same treatment `VendorOrder.VendorOfferId` already has). Written once at submit, from the product's selected offer as it stood then; never rewritten. This makes processing a three-way comparison rather than a two-way one: - **Promised** — what the customer bought, on the transaction - **Underwritten** — the vendor, SKU and cost we expected to fulfill through, at the price the Reserve was sized against - **Available now** — the product's currently selected offer and its live cost **2. The customer is never charged more, so cost drift is ours to absorb — which is precisely why it must be recorded.** A scheduled gift is paid in full at signup: `ScheduledGiftPricingService` charges `deliveries × PromisedUnitPrice` as the base, adds a time-compounded 3.5% inflation hedge and a 10% service fee, and holds base + hedge in the Gift Reserve (ADR-010). There is no mechanism to bill the customer again and there must not be one. A vendor raising its price is absorbed by the hedge and the Reserve. That is the argument *for* the snapshot rather than against it. Because the customer's side is closed, the only place a cost increase can show up is our margin — and without the underwritten cost recorded, the drift between what we priced against and what we eventually paid is unobservable. Today the system can tell you the customer's frozen price and today's vendor cost, and cannot tell you the spread you actually committed to. A hedge whose adequacy cannot be measured is a guess. **3. Processing is a best-match decision, not a lookup.** The underwritten snapshot is a **reference, never an instruction**. The order path must not simply re-order `VendorSkuSnapshot`, because by then the vendor may have delisted it (ADR-014), repriced it, or been retired outright — and the product's selected offer may deliberately have moved to a better source. Processing therefore resolves the closest acceptable fulfillment for what was promised, presenting the operator with the comparison rather than a decision already made: - The selected offer matches the underwritten SKU → order it; record the cost difference. - The SKU is delisted or the vendor retired → the operator picks a substitute, informed by what was originally underwritten. ADR-014's derived "not offered" state is what surfaces the product as needing this. - The selected offer has moved to a different vendor product → that is a legitimate improvement, not an error; order what is selected and record the divergence from the snapshot. In every branch `VendorOrder` records what was *actually* sent. The two snapshots then bracket the decision: the transaction says what we intended, the order says what we did, and the difference between them is the thing worth reporting. **Fidelity to what was promised is the constraint, and it is not the SKU.** The customer bought a product at a price, and ADR-013 already established that which vendor fulfills it is ours to change. So a substitute must honour the promised product presentation — the name, description and image snapshotted on the transaction, which is what the customer saw — not the vendor SKU. Matching on SKU would make the promise stricter than it is and would block fulfillment every time a vendor reorganised its catalog. **4. An offer is *not* frozen when an order is placed against it. The catalog stays mutable and the documents copy what they need.** The obvious alternative to snapshotting is to lock the offer — once something has been ordered against it, forbid changing its vendor product, cost, or binding. That is recorded here as **rejected**, because it is the question a future reader will ask when they find an editable offer with orders behind it, and the reasoning should not have to be rediscovered. The split this ADR relies on is the standard one between **master data** and **transactional documents**: | | Catalog: `Product`, `VendorOffer`, `VendorProduct`, `Vendor` | Documents: `ScheduledGiftTransaction`, `VendorOrder` | |---|---|---| | Answers | what we can sell, and from whom, *now* | what was agreed, and what we did | | Lifecycle | mutable, always current | immutable once committed | | Holds | live configuration | its own copy of everything it needs | | References | — | soft, nullable, provenance only | The intuition is the invoice test: an invoice from three years ago must still render correctly today even if the product has since been renamed, repriced, or deleted. No system achieves that by freezing its product catalog — the invoice copied what it needed at the moment it was issued. In the same terms ADR-009 already uses, catalog and order are separate contexts: they reference each other by identity and never share mutable state. **Why locking is the wrong lever here specifically:** - **It makes catalog editability depend on unrelated history.** A single order placed years ago would permanently pin a product's sourcing. For a business selling the same products across decades, that is backwards. - **It ossifies almost at once.** With recurring gifts nearly every offer accumulates orders, so nearly everything locks. The escape hatch becomes cloning the offer, and the catalog fills with near-duplicates whose only difference is which era they belong to. - **It fails hardest exactly when flexibility is needed.** When a vendor delists a SKU (ADR-014), a locked offer pins us to a product that no longer exists and forbids the substitution that section 3 requires. - **It protects something already protected.** Locking only helps if the documents failed to copy what they needed. Once they do, there is nothing left for the lock to defend — and the main legitimate reason to edit an offer is that the vendor's cost changed, which is precisely what an operator must be able to record. **The rule that replaces it.** For every value the order path needs: *if the offer row vanished, would this order still be fully described?* If not, snapshot it. If so, the foreign key is for drill-down only. A document must never read **through** its reference for anything it needs in order to be correct. **The dependency stays one-way: document → catalog, never catalog → document.** Nothing on `Product`, `VendorOffer`, `Vendor`, or `VendorProduct` references `VendorOrder`, and nothing should. The moment the catalog knows about orders it inherits history as a constraint, and locking starts to look necessary again for the same reason it looks necessary now. "Which orders went through this offer" is a query from the order side. **The trade-off this accepts knowingly.** `DeleteOfferAsync` hard-deletes, which sets `VendorOrder.VendorOfferId` to null. The snapshots keep the order fully *described*, but drill-through to the originating offer is lost — the position ADR-012 and ADR-013 both took deliberately for `VendorSku` and `VendorProductId`. If that provenance later proves worth keeping, the answer is to archive offers rather than delete them, not to lock them; `VendorOffer` has no archive flag today, ADR-013's amendment having removed `IsDisabled`. **The heavier alternative, also rejected for now.** Effective-dated offers — validity periods per price, so any historical price is reconstructible by date — is the other standard answer (the ERP price-list model). It is rejected because it answers a *reporting* question ("what did anything cost on date X"), not the order question, and it imposes temporal queries across the whole catalog to do it. Snapshots answer what fulfillment needs. Revisit only if finance requires price reconstruction for products that were never actually sold. **Consequences of the amendment.** - **Additive migration.** Four nullable columns on `ScheduledGiftTransaction`; no drops, no existing column changes meaning. Unlike ADR-014's schema work this is safe to apply to the shared dev database in one step, because pre-migration code on the Azure sites neither selects nor writes the new columns. - **Existing transactions cannot be backfilled honestly.** The information was never captured, and the product's *current* selected offer is not evidence of what was selected at signup. The columns stay null for rows that predate the change, and processing must treat null as "unknown, compare against nothing" rather than inferring a value. Backfilling from today's selection would manufacture a cost basis that was never underwritten — worse than admitting the gap. - **`ScheduledGiftSubmissionService` becomes the writer**, alongside the product snapshots it already takes at submit. A gift submitted while its product has no selected offer records nulls, which is the honest answer and also a signal: it was sold with no source identified. - **Order processing gains the comparison view**, and it is the point of the amendment — without it the snapshot is storage with no reader, which is the failure mode ADR-014 records for the availability column. - **Reporting becomes possible for the first time**: promised versus underwritten versus paid, per gift and in aggregate, which is what tells you whether the 3.5% hedge is holding. That is a finance question this ADR does not attempt to answer, only to make answerable. - **Still blocked, unchanged**: `PlaceOrderAsync` and the automated path remain behind the payment fork. This amendment is about what the manual path records and compares, which needs no payment decision.