Skip to content

Delivery shim path — first-class BC platform primitives for subscriptions

Generated from a canonical source

This page is a read-only projection of docs/strategy/delivery-shim-path.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What this is. The subscription engine ships as a BigCommerce marketplace app, so today it supports subscriptions by working around the platform: recurrence terms live in an app-side shadow store — a copy of data BC should own but doesn't expose as first-class, so the app keeps its own authoritative version in its D1 database and carries the burden of keeping it in sync — and ride cart/order metafields, checkout consent is captured by injecting JavaScript, renewals charge through an atomic payment call, and lifecycle events flow through the app's own webhook pipeline instead of the platform's event bus. The "shim" approach is the minimal set of first-class, additive primitives — one bounded change per platform domain — that would let the app stop working around the platform and compose with it. This page is that changeset, domain by domain, with the tech-layer impact of each.

What "shim" means, precisely. A shim is an additive primitive (a typed field, a structured slot, an event topic) that lives inside one existing BC bounded context and that BC could ship without deciding subscription policy on our behalf. The marketplace app stays the runtime owner; each primitive lets it retire one workaround. This is not a native rebuild (that's the fork's Path B) and not app-only work (things we can fix without any platform change). The changeset below is the set that passes that bar; what fails it is listed under Boundaries.

Status. No BC primitive is committed — this is the design of the ask, not shipped work. Whether to pursue the lane is the strategy call in delivery-fork.md; this page is the technical changeset if you do.

The changeset, domain by domain

⭐ keystone · ⤷ cascades from a keystone. "Today" is the current marketplace-app workaround; "Primitive" is the first-class BC change that retires it.

BC domain What a subscription needs Today (the workaround) First-class primitive Tech-layer impact
Catalog products carry recurrence terms (interval, trial, commitment) D1 subscription_plans shadow store; every "is this subscribable?" query hits our store, not BC's product.recurrence_options — typed additive field on the product entity the shadow store drops to config-only; cart / storefront / search resolve recurrence natively
Pricing per-cadence price (monthly vs. annual vs. one-time) app-side pricing layer; BC's promo engine compounds discounts on the pre-subscription price recurrence_interval dimension on price-list rows native price resolution; fixes the compounding-discount bug merchants don't expect
Cart line items display recurrence terms recurrence encoded in a cart-level JSON metafield every theme must be taught to parse line_item.recurrence block (populated from Catalog) subscription lines render correctly on any native cart-summary theme (Stencil, Cornerstone)
Cart consent bound to the order before any charge consent threaded via order metafields in the store/order/created handler — a window where a renewal could fire pre-consent cart.subscription_consent_session_id (opaque, set at checkout) consent captured before checkout completes; the pre-consent charge window closes
Checkout a step to capture merchant-initiated (MIT) recurring-charge consent Scripts API JavaScript injection intercepting "Place Order" — breaks on Catalyst rebuilds, poor a11y a native checkout step slot (BC renders it in correct billing order) no JS injection; resilient by design to BC's planned 2H'26 reordering of checkout steps
Orders mark renewal orders; join order ↔ subscription order metafields + an app-side mapping table + an extra API call per renewal order.source enum + parent_subscription_id fields native admin order-list filtering; deterministic reconciliation joins; "Your Orders" distinguishes renewals
Returns tie an RMA to the subscription period it touches infer the period by correlating RMA order_id against delivery-instance records (brittle across billing boundaries) RMA subscription_period_ref (opaque) deterministic refund → period join
Payments capture-on-ship for EU physical goods atomic adapter.charge() (auth + capture in one) → EU physical-goods merchants non-compliant (our capture-timing decision, ADR-0038) authorize/capture split in the BC payment-adapter contract deferred capture becomes buildable; closes the EU capture-timing compliance gap
Payments correct SCA / MIT treatment on renewals is_recurring hint at PAT mint, not propagated to every PSP → needless SCA challenges, higher EU declines transaction_type (CIT/MIT) enum in the adapter contract first-class MIT signalling; fewer EU renewal declines
Payments / Vault list a customer's saved cards IAT-popup per instrument — a full capture UI just to view/switch cards customer-scoped stored-instrument read endpoint the picker becomes fetch-and-render — this endpoint already ships publicly; adopt it now, no BC ask needed
Storefront subscription-aware PDP + add-to-cart Scripts API widget injection + a Catalyst PDP monkey-patch (the source of a known theme-injection failure, the STENCIL_SCRIPT_NAME 422 bug) <SubscriptionTerms> storefront-kit slot + Catalyst add-to-cart hooks retires both injection layers; terms render on themes we don't control
Search a "Subscribe & Save" filtered listing a separate subscribable-IDs endpoint queried per search (N+1) is_subscribable facet on the storefront search index collapses to one native search call
Channels per-storefront subscription pricing a per-channel pricing-override shadow store keyed by (channel_id, product_id, interval) channel scope on the price-list recurrence dimension eliminates the per-channel pricing shadow store
Domain Eventing lifecycle events visible to BC services + merchants app outbox → Worker cron → merchant webhooks (our outbox + catch-up-cron decision, ADR-0027) — invisible to BC automations a sanctioned, signature-verified app-emitted MES topic (app/<client_id>/…; MES = BC's internal Message Event System) events compose with BC notifications / automation rules; the outbox stays the authoritative internal record

Sequencing — three keystones, then cascade

You cannot land every domain at once, and most primitives depend on three:

  • Catalog — product.recurrence_options (A1). Once recurrence is first-class product data, the reads cascade: Cart line_item.recurrence, Storefront <SubscriptionTerms>, and the Search is_subscribable facet all source from it. Broadest cascade, clearest public-goods argument, lowest platform friction (typed product fields are well-trodden).
  • Payments — authorize/capture split (A8). Unlocks the payments cluster (the MIT/CIT flag rides the same contract). Gated on BC GA-ing its capture/void transaction endpoints — authorize is GA today, capture/void return 404; until then the atomic charge stays load-bearing.
  • Domain Eventing — sanctioned MES topic (A15). Unlocks lifecycle-event composition with BC's notification and automation systems.

The secondary cascades: Pricing recurrence_interval (A2) → Channels scope (A14); Checkout step slot (A5) → the Cart consent field (A4). Everything else waits on at least one keystone to land or be declined.

Boundaries — what is NOT a shim

The additive-primitive-in-one-context bar excludes two classes:

  • Native (cross-domain or policy ownership) — needs BC to own subscription behavior, not just carry a field. B2B subscriptions is the headline: it requires identity federation across B2B Edition + Customers + Checkout — no single-domain primitive resolves it. Also here: a customer-scoped recurring-consent ledger, inventory holds for subscription commitment, mixed-cart recurrence policy.
  • Pure app (no platform change) — things we model ourselves: retry-aware charge idempotency, computed customer LTV / subscription-count fields.

Naming these matters because a mis-classified shim is worse than the workaround: it asks BC to make a policy decision that's ours, or anoints subscriptions as a BC-native domain by the back door (framing the ask in our domain's language and transferring ownership without the honest native conversation).

Impact on the current architecture

Each primitive retires a workaround and converts a mechanism ADR:

  • Catalog A1 → the D1 subscription_plans shadow store becomes configuration-only.
  • Payments A8 → ADR-0038's EU capture-timing gap moves from "blocked" to buildable (adapter.authorize()/capture(), a store/shipment/created capture trigger, an auth-expiry sweep).
  • Payments A9 → the IAT-popup picker (StoredInstrumentsPicker.svelte) from our stored-instrument vault decision (ADR-0037) simplifies to fetch-and-render — and the read endpoint already ships, so this one is available today without any BC change.
  • Storefront A11/A12 → retire the Scripts API injection + Catalyst monkey-patch (ADR-0013, our storefront-components-shape decision, moves to interim), eliminating the theme-injection 422 class.
  • Domain Eventing A15 → the outbox-cron rail stays the authoritative internal record and gains a platform-amplification layer; ADR-0010 (our webhook/event-egress decision) — its egress split (merchant webhooks via us; BC-internal amplification via the topic) — is amended.

Throughout, the marketplace app keeps running as-is — primitives supersede specific mechanisms cluster by cluster, never wholesale, so there is no rebuild and no cutover.

What the marketplace app still owns

Shims move data and seams into BC; they don't move the subscription domain's behavior or policy. Even with every primitive above ratified, the marketplace app stays the runtime owner of:

  • The subscription lifecycle engine — scheduling, the state machine (active / paused / trialing / cancelled), cycle anchoring, proration, and pause / resume / skip / swap. BC has no concept of a subscription's lifecycle, so this is ours in every lane.
  • Charge orchestration — idempotency, retry, and the dunning-policy engine. The Payments split (A8) gives us the auth/capture primitives; when and whether to retry a failed renewal, and how to dun, stays app logic.
  • The consent ledger — A5 hands BC the checkout step slot, but the record of what was disclosed, accepted, and by whom is a legal/compliance artifact the app owns (the A5 split: BC owns the surface, we own the ledger).
  • Domain entities BC doesn't model — the D1 / Cloud SQL tables for concepts BC has no equivalent for: dunning policies, delivery instances, usage metering, entitlements, subscription promotions, the store-credit ledger. These are not shadow stores (there is nothing in BC to shadow) — they are the subscription domain's own data, and they stay app-owned.
  • Merchant-facing eventing — A15 amplifies our events into BC's internal bus, but merchant webhook delivery stays the app's outbox pipeline.
  • The subscriber portal, reconciliation and exception handling, and the OAuth install/uninstall and migration/import lifecycles.

The dividing line is consistent with the shim definition: BC owns first-class data and contracts; the app owns decisions, lifecycle, and the entities BC has no concept of. Shims shrink the app's integration surface, not its domain ownership.

References