# Solution Design: Products — bulk CSV import/export
| | |
| --- | --- |
| **Anchor** | [wiggleverse/wiggleverse-ecomm#13](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/13) (`type/feature`, `priority/P1`) — parent epic [#12](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/12) |
| **Author(s)** | Claude (session ecomm-0030), for Ben Stull |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.0 |
| **Source artifacts** | BDD corpus: [prototype rfcs 11-05-\* (products import/export)](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/rfcs) · [research/shopify/products](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/research/shopify/products/products.md) · Prototype: [wiggleverse-ecomm-prototype](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype) · Reference: Shopify product CSV · Builds on: [SD-0001](./SD-0001-mvp-sign-up-and-single-storefront.md) · Supersedes: — |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-11 | v0.1.0 | Initial draft — §1 Business Context (session ecomm-0030) | Claude + Ben |
---
## 1. Business Context
*The business lens — solution-agnostic throughout. No mechanism is proposed until §2.*
### 1.1 Executive Summary
A merchant's catalog already exists before they ever meet this platform — in
spreadsheets, in their current platform's export, in the working files they
price and re-price every season. The value at stake is letting that catalog
*become* their storefront without re-entering it, letting catalog-wide changes
(a season's prices, a supplier's new costs) land as one gesture instead of
hundreds, and guaranteeing the catalog can leave whole at any time — so
adopting the platform never means retyping a business in, and never means
locking a business up. The beneficiaries are merchants — most acutely those
migrating from another platform — and the platform itself: every downstream
capability of an ecomm (browsing, orders, checkout) presupposes a real catalog
existing, and today none can.
### 1.2 Background
ecomm is Wiggleverse's multi-tenant Shopify alternative. Its first Solution
Design ([SD-0001](./SD-0001-mvp-sign-up-and-single-storefront.md)) shipped
sign-up, identity, and single-storefront creation: a merchant can exist and
their storefront can exist — and that storefront is, by design, an honestly
empty shell ("There's nothing to manage yet").
The Products MVP epic ([#12](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/12))
is the next move: give the storefront a real product catalog. The epic makes a
deliberate boundary decision: the MVP ships **no per-item editing** — bulk
ingestion and extraction is the *only* way product data moves. Its sibling
Features display the catalog
([#14](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/14),
list + read-only detail) and extend it to bundled offerings
([#15](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/15),
kits); **this** Feature is the mechanism they all feed from.
The backdrop that makes bulk-first the right boundary: real merchant catalogs
are not built one item at a time inside an admin tool. Merchants switching
platforms hold their entire catalog as an export of their current system —
hundreds of products, each with variations, images, and prices. Merchants who
stay maintain their catalogs batch-wise: a seasonal price list, a supplier's
updated cost sheet, a spreadsheet kept as the working source of truth. The
catalog's natural lifecycle is bulk in, bulk update, bulk out.
### 1.3 Business Actors / Roles
Real-world roles — they exist whether or not this platform does.
| Role | Responsible for (in the business) |
| --- | --- |
| **Merchant** | Owns a storefront's catalog: what is sold, at what price, shown how. Keeps it current as seasons, suppliers, and stock change. |
| **Migrating merchant** | A merchant switching platforms. Their catalog already lives, complete, in their current system and its export files; they decide whether switching is worth the cost. |
| **Platform builder** | Builds and validates the capabilities a catalog feeds (browsing, orders, demos). Needs realistic catalogs to exist before those capabilities can be exercised. |
### 1.4 Problem Statement
A storefront can exist but cannot hold anything sellable: there is no way to
get product data into the platform at all, and the MVP deliberately offers no
per-item editing to fall back on. For the merchant whose catalog already
exists elsewhere — the common case — hand re-entry of hundreds of products,
each with variations, imagery, and prices, is a switching cost that simply
ends the conversation. The gap recurs after adoption: catalog maintenance is
batch-shaped (whole-catalog price changes, supplier updates), and one-at-a-time
upkeep mismatches how merchants actually work their data. And data that goes
in must come out: a catalog that cannot be extracted whole makes the platform
a trap, which careful merchants correctly treat as a reason not to enter.
### 1.5 Pain Points
| # | Pain | Who feels it | Cost / frequency today |
| --- | --- | --- | --- |
| PP-1 | A created storefront is a dead end — nothing to sell, nothing to manage, nothing to show | Merchant | Total: blocks any real use of the platform; felt by every merchant immediately after sign-up |
| PP-2 | An existing catalog would have to be re-keyed item by item to move over | Migrating merchant | Hundreds of products × dozens of fields each — a prohibitive switching cost; prospective merchants don't switch |
| PP-3 | Catalog-wide changes (season prices, supplier costs) have no whole-catalog gesture | Merchant | Recurring — every season, supplier change, or repricing event; effort scales with catalog size |
| PP-4 | No realistic catalog can be stood up to build, demo, or validate against | Platform builder | Continuous — every product-dependent capability (#14, #15, future orders) is blocked or faked |
| PP-5 | Whatever goes in cannot be gotten back out — the catalog would be trapped | Merchant | Felt at the adoption decision (a reason to say no) and at every backup/audit/leave moment after |
### 1.6 Targeted Business Outcomes
| # | Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured |
| --- | --- | --- | --- | --- | --- |
| BO-1 | Merchants with existing catalogs adopt — populating a storefront is hours, not weeks | # storefronts holding a real catalog; effort to first full catalog | 0 storefronts can hold a catalog → a several-hundred-product catalog lands in under an hour of merchant effort | Sign-up → storefront flow (SD-0001) stays intact | Platform records (operationalized §8.1); reviewed monthly |
| BO-2 | Catalog upkeep happens *in* the platform — merchants refresh in bulk rather than letting data go stale or leaving | # storefronts performing repeat bulk updates | No upkeep possible → catalog-holding storefronts update in bulk as their prices/data change | Existing catalog data never corrupted by an update | Platform records; reviewed monthly |
| BO-3 | "My data would be trapped" is a non-objection — the catalog is extractable whole, always | Full-catalog extraction available and exercised | No extraction exists → every storefront's complete catalog can be taken out on demand, at any time | Extraction completeness: nothing the merchant supplied is unrecoverable | Platform records; reviewed quarterly |
| BO-4 | Product-dependent work is unblocked — sibling and downstream capabilities build against real catalogs | #14 / #15 / future order work proceeding against realistic data | Nothing to build against → realistic catalogs standable in minutes | — | Delivery record of #14/#15; per Feature |
### 1.7 Scope (business)
- **In scope:** populating a storefront's catalog in bulk from records the
merchant already holds; updating the catalog in bulk; extracting the
complete catalog at any time. "Catalog" here means products in full
commercial reality: variations (size/color and the like), imagery,
organization (vendor, type, tags, category), pricing/cost, stock levels,
and publication state.
- **Out of scope:** per-item create/edit (a later epic — the MVP's deliberate
boundary); removing products in bulk (nothing is deleted by this mechanism —
see §2); automated or scheduled synchronization with other platforms;
movement of non-catalog data (customers, orders — other epics); the meaning
and behavior of bundled offerings (kits — Feature #15, which rides this
mechanism but defines its own semantics).
- **Non-goals:** real-time integration with other commerce systems (the
supplied file is the boundary, deliberately); making the MVP a catalog
*editing* tool of any kind.
### 1.8 Assumptions · Constraints · Dependencies
- **Assumptions:** merchants hold, or can readily produce, their catalog as a
structured file (a spreadsheet, their current platform's export). *Risk if
wrong:* a merchant with no digital catalog at all has no entry path in this
MVP. Catalogs at MVP scale run to a few thousand items. *Risk if wrong:*
larger catalogs need heavier machinery (a named seam, §2).
- **Constraints:** bulk-only is Epic #12's deliberate MVP boundary — this
design must not quietly grow per-item editing. One storefront per account
(SD-0001 INV-4) — a merchant's catalog context is unambiguous.
- **Dependencies:** SD-0001's shipped surfaces (identity, storefront, admin
shell) — live as of v0.4.0. Sibling #14 displays what this ingests; sibling
#15 extends what the records can express — both sequence after this
mechanism exists.
### 1.9 Business Use Cases
**BUC-1 — As a migrating merchant, I can bring my existing catalog into my storefront in one bulk gesture, so that switching platforms doesn't mean re-keying my business.**
```gherkin
Scenario: BUC-1 — An existing catalog comes over whole
Given a merchant whose catalog of several hundred products lives in an
export from their current platform
When they supply that catalog to their storefront in one gesture
Then the storefront holds the full catalog — every product with its
variations, imagery, organization, and prices
And nothing had to be re-entered by hand
```
- **BUC-1 acceptance criteria:** the catalog the storefront holds matches the
records the merchant supplied, completely; the merchant's effort is one
supply-and-review gesture, not per-product work.
**BUC-1a — unhappy path: unusable records are reported, not silently dropped.**
```gherkin
Scenario: BUC-1a — Unusable records come back with actionable feedback
Given a merchant supplies catalog records of which some are incomplete,
contradictory, or unreadable
When the storefront cannot accept some of the records
Then the merchant is told exactly which records failed and why, in terms
they can act on
And they can correct and re-supply the problem records
And nothing is silently dropped, guessed at, or invented
```
- **BUC-1a acceptance criteria:** every rejected record is identified with an
actionable reason; accepted vs. rejected is fully accounted for — the
merchant is never left wondering what made it in.
**BUC-2 — As a merchant, I can see exactly what a bulk change will do to my catalog before it happens, so that I never damage my own data by accident.**
```gherkin
Scenario: BUC-2 — A bulk change is reviewed before it takes effect
Given a merchant about to apply a bulk change to a catalog their business
depends on
When they supply the new records
Then they see what will be added, what will change (and to what), and what
is in error — before anything takes effect
And the change happens only when they say so
```
- **BUC-2 acceptance criteria:** the preview is complete and faithful (what it
shows is exactly what applying does); declining costs nothing; nothing
changes without the merchant's explicit go-ahead. *(OHM: consent & agency —
the merchant authorizes the change knowing what it is.)*
**BUC-3 — As a merchant, I can update my whole catalog at once, so that batch-shaped changes (seasonal prices, supplier costs) cost one gesture instead of hundreds.**
```gherkin
Scenario: BUC-3 — A catalog-wide change lands as one gesture
Given a storefront holding a catalog, and a season's repricing touching
most of it
When the merchant supplies the updated records
Then the affected products are updated to the new values
And the unaffected products are untouched
And no duplicates are created
```
- **BUC-3 acceptance criteria:** updates land on the products they target;
re-supplying unchanged records changes nothing (supplying the same catalog
twice never doubles it); the merchant's effort does not scale with catalog
size.
**BUC-4 — As a merchant, I can take my complete catalog out at any time, so that my business data is never trapped in the platform.**
```gherkin
Scenario: BUC-4 — The catalog leaves whole, on demand
Given a storefront holding a catalog the merchant built up over time
When the merchant asks for their catalog
Then they receive the complete catalog — every product, variation, image,
and price — in a form they can use elsewhere
And what they receive could rebuild the same catalog faithfully
```
- **BUC-4 acceptance criteria:** extraction is always available, complete
(nothing the merchant supplied is unrecoverable), and round-trips — what
comes out can go back in and reproduce the same catalog. *(OHM: agency —
leaving is always possible; the platform earns retention, never extorts
it.)*
---
## 2. Solution Proposal
**Build software:** CSV file import/export in the storefront admin — a new
**Products** surface where a merchant uploads a CSV to add and update products
in bulk, and downloads a CSV of the full catalog at any time.
**The shape of it:**
- **One canonical CSV format**, Shopify-flavored: one row per variant,
consecutive rows sharing a `Handle` form one product (with its options,
variants, and images). The column set is a deliberate superset of Shopify's
product CSV — adding component columns (the kits seam, #15), volume,
cost/tax fields — so a Shopify export is structurally familiar and our
format never needs to churn when siblings arrive. The full column
specification is §6.
- **Two accepted dialects, auto-detected:** the canonical format, and
Shopify's own product-CSV export (mapped onto canonical at the boundary).
The header row identifies the dialect; the merchant never picks from a
menu. This is the migrating merchant's path (BUC-1): their existing export
imports directly.
- **Import is upsert-only.** A row whose `Handle` is new creates a product; a
known `Handle` updates it (variants matched by option-value combination).
Nothing is ever deleted by an import — the mechanism is non-destructive by
construction, so a partial file can never silently destroy catalog
(BUC-2's safety, structurally).
- **Validate-all → preview → confirm.** The whole file is validated and
diffed against the catalog before anything is written. The merchant sees a
summary — N products to add, M to update, K rows in error — and can drill
into any record to see exactly which fields will change and to what.
Nothing applies until they confirm; confirming applies the valid records
and leaves the error rows listed, unapplied, and actionable (BUC-1a,
BUC-2). Unrecognized columns are warned about in the preview and ignored,
never silently dropped.
- **Synchronous, with a stated capacity** of ~5,000 rows per file. No job
queue in the MVP; the design names the async seam for when catalogs
outgrow it (§6.9). One deliberate exception rides that seam's first rail:
**images**.
- **Images are fetched and hosted.** `Image Src` URLs are downloaded into
platform object storage after the catalog rows commit, with renditions
(thumbnail / card / detail) generated at fetch. Images that can't be
fetched, aren't images, or fall below the resolution bar are **rejected**
(the product shows a placeholder), with the outcome reported per image.
The catalog is self-contained: exports reference our hosted copies, and
the storefront never depends on a third-party host staying alive.
- **Imports are first-class records.** Every import run is kept — file name,
dialect detected, who ran it, add/update/error counts, image-fetch
outcomes — with a detail view the merchant can leave and return to
(the post-commit image phase reports there and on the Products page).
- **Export is one gesture:** the complete catalog (optionally filtered by
status) as a canonical CSV. Export → re-import is lossless and idempotent —
the acceptance bar from #13 and BUC-4's round-trip guarantee.
**Why this approach.** CSV is the medium merchant catalogs already live in:
every platform exports it, every spreadsheet tool speaks it, and the
migrating merchant (the sharpest pain, PP-2) holds one today. Accepting
Shopify's dialect directly converts the largest migration population's
existing artifact into a working catalog with zero transformation effort.
Upsert-only plus preview-confirm makes a powerful bulk mechanism safe to hand
to a non-technical merchant: the destructive operations simply don't exist,
and every change is consented to with full knowledge (OHM: consent & agency).
**Alternatives weighed** (detail in §6.7): a per-item editing UI first
(rejected — Epic #12's explicit boundary, and it does nothing for migration);
integration APIs / direct platform connectors (rejected — heavy, narrow, and
premature before the catalog model exists); accepting spreadsheet formats
(XLSX) (rejected for MVP — CSV covers the actual artifacts merchants hold;
XLSX is an adapter behind the same boundary later); do-nothing / concierge
data entry by the operator (rejected — doesn't scale past a demo and builds
no product capability).
**Solution-specific non-goals:** no deletion semantics of any kind in the
CSV; no scheduled/automated re-import; no foreign dialects beyond Shopify's
product CSV in the MVP; no kit *semantics* (the format carries the columns;
#15 defines their meaning); no image editing/cropping — the platform hosts
what the merchant's URLs provide, or rejects it.
## 3. Product Personas
| Product persona | In ecomm | Maps to business role(s) |
| --- | --- | --- |
| **Merchant** | The authenticated storefront owner (SD-0001 §3), now extended: manages their catalog from the admin's new **Products** section — imports, reviews, confirms, exports. The MVP's only authenticated user type. | Merchant; Migrating merchant |
The **Platform builder** (§1.3) gets no persona of their own: they exercise
the same Merchant surface with demo data (stand up a storefront, import a
realistic catalog). The **Operator** persona (SD-0001) is untouched by this
design.
## 4. Product Use Cases
Corpus grounding: scenario IDs cite the prototype corpus's products
import/export scenarios
([rfcs 11-05-0032–0037](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/rfcs)).
Divergences are marked — the load-bearing one: the corpus matches rows to
products **by SKU** (11-05-0035); this design matches products **by `Handle`**
and variants **by option-value combination**, with SKU as correctable data
(rationale in §6.7).
**PUC-1 — The Products section exists, honestly** *(product-only; creates the surface #14 fills in)*
```gherkin
Scenario: PUC-1 — Products page, before and after a catalog exists
Given a Merchant in their storefront admin
When they open "Products" in the admin navigation
Then with an empty catalog they see an empty state: "No products yet",
a primary "Import products" action, and links to the sample CSV and
column reference
And with a populated catalog they see the product count, "Import products"
and "Export" actions, and the import history — with an honest note that
the browsable product list arrives with a coming release (#14)
```
**PUC-2 — Upload a file to import** *(realizes BUC-1; corpus 11-05-0034)*
```gherkin
Scenario: PUC-2 — A Merchant starts an import
Given a Merchant on the Products page
When they choose "Import products" and select a .csv file
Then the file uploads and a "Validating…" state shows progress
And they land on the import preview (PUC-3) when validation completes
```
**PUC-3 — Preview: see exactly what will happen** *(realizes BUC-2; the consent gate)*
```gherkin
Scenario: PUC-3 — The preview summarizes and lets the Merchant drill in
Given a Merchant whose file has been validated
Then they see which dialect was recognized ("Canonical" or "Shopify
product CSV — mapped")
And a summary: N products to add, M to update, U unchanged, K rows in error
And any columns that will not be imported, listed by name
And selecting any product in the preview shows its record-level detail:
fields to be set (for adds) or a field-by-field before → after diff
(for updates)
And nothing has been written to the catalog yet
```
```gherkin
Scenario: PUC-3a — Declining costs nothing
Given a Merchant on the import preview
When they choose "Cancel"
Then they return to the Products page and the catalog is unchanged
And no trace of the canceled import remains in the import history
```
**PUC-4 — Confirm: the import applies** *(realizes BUC-1, BUC-3)*
```gherkin
Scenario: PUC-4 — Confirming applies the valid records
Given a Merchant on a preview showing additions and updates
When they choose "Import N products"
Then an "Importing…" state shows progress
And they land on the import run's detail page: products added, products
updated, rows in error (each listed and unapplied), and the image-fetch
phase begins (PUC-7)
And the catalog now holds exactly what the preview showed
```
**PUC-5 — Row errors come back actionable** *(realizes BUC-1a; corpus 11-05-0036)*
```gherkin
Scenario: PUC-5 — Bad rows are reported, good rows are not blocked
Given a Merchant's file where some rows are invalid (e.g. a malformed
price, a variant row whose options contradict its product)
Then the preview's error table lists each failed row: line number, the
column at fault, and what's wrong, in plain language
And confirming imports the valid rows and leaves the error rows unapplied
And the same error table remains on the import run's detail page
```
```gherkin
Scenario: PUC-5a — A file the importer cannot work with is rejected whole
Given a Merchant uploads a file that is not parseable CSV, is missing a
required column (e.g. "Title"), exceeds the row capacity (§2), or
matches no known dialect
Then the upload is rejected with one clear message naming the problem
And nothing is imported and no import run is recorded
```
**PUC-6 — A Shopify export imports directly** *(realizes BUC-1; the migration path)*
```gherkin
Scenario: PUC-6 — A migrating Merchant brings their Shopify export
Given a Merchant with the product CSV their Shopify store exported
When they upload it unmodified
Then the preview shows "Shopify product CSV — mapped" and the standard
summary
And Shopify columns with no canonical equivalent are listed as not
imported (warned, never silent)
And confirming lands their catalog: products, variants, options, images,
prices, tags, status
```
**PUC-7 — Image outcomes are reported, not assumed** *(realizes BUC-1a for imagery)*
```gherkin
Scenario: PUC-7 — The image-fetch phase reports per image
Given a confirmed import whose records reference image URLs
Then the run detail shows live progress ("Fetching images: 312 of 4,950")
And when complete, each image's outcome: fetched, rejected (below the
resolution bar / not an image), or failed (unreachable)
And products whose images were rejected or failed show a placeholder and
are surfaced on the Products page — pre-#14 as an aggregate notice
("N products have image problems") linking to the run detail; per-product
badges arrive with #14's list view
And the Merchant can re-import corrected URLs to retry (upsert, BUC-3)
```
**PUC-8 — Import history** *(realizes BUC-2's accountability)*
```gherkin
Scenario: PUC-8 — Past imports are reviewable
Given a Merchant who has run imports before
When they open the import history on the Products page
Then they see each run: when, file name, dialect, who ran it, and its
add / update / error / image-outcome counts
And opening a run shows its full detail page (PUC-4, PUC-5, PUC-7),
unchanged since the run finished
```
**PUC-9 — Export the catalog** *(realizes BUC-4; corpus 11-05-0032, 11-05-0033 — diverges: MVP filter is status only, vendor/collection filters deferred)*
```gherkin
Scenario: PUC-9 — One gesture produces the whole catalog
Given a Merchant on the Products page with a populated catalog
When they choose "Export" (optionally filtering by status:
all / active / draft / archived)
Then a canonical-format CSV downloads containing every matching product —
all variants, options, tags, prices, and image URLs (pointing at the
platform's hosted copies)
And with an empty catalog the "Export" action is disabled, with a note
```
**PUC-10 — The round-trip is visibly lossless** *(realizes BUC-3, BUC-4; the #13 acceptance bar)*
```gherkin
Scenario: PUC-10 — Re-importing an export is a no-op, and says so
Given a Merchant who exports their catalog and re-imports the file
unmodified
Then the preview shows every product as "unchanged" — nothing to add,
nothing to update, no errors
And the import action is unavailable, with the note "Nothing to change —
your catalog already matches this file"
And canceling leaves the catalog identical
```
**PUC-11 — Learning the format** *(realizes BUC-1 for the cold-start merchant)*
```gherkin
Scenario: PUC-11 — The sample CSV and column reference teach the shape
Given a Merchant who has never seen the canonical format
When they download the sample CSV from the Products page or import screen
Then it opens in their spreadsheet tool with a handful of example products —
including one with variants and one with images
And the linked column reference documents every column: name, meaning,
required or optional, accepted values
```
## 5. UX Layout
Text is the source of truth; wireframes are generated from these descriptions
on demand and never committed. All surfaces live inside SD-0001's admin shell
(its header: storefront name left, signed-in email + sign out right).
### 5.1 Design tooling decision
- **Decision:** Code directly.
- **Rationale:** rubric score **−1** (claude-design-vs-code.md): Q1 **+1**
(net-new Products section, no data-surface precedent in the codebase),
Q2 **−1** (this section describes the UI to wireframe level — named
components, enumerated states), Q3 **0** (one admin section, multi-step but
utilitarian), Q4 **+1** (no design system in the ecomm app yet), Q5 **−1**
(solo build, operator is the only reviewer), Q6 **−1** (work is dominantly
logic: parsing, diffing, upsert, image pipeline). Default at −1 is Code; no
override fires — the import preview is novel but structural, and prose
specifies it adequately.
### 5.2 Screen: Products (serves PUC-1, PUC-7, PUC-8, PUC-9, PUC-11)
- **Purpose:** the catalog's home in the admin; where imports start, exports
download, and past imports are reviewed. Created by this design; #14 fills
in the browsable list.
- **Layout (top → bottom):**
- **Header row:** title "Products" + product count (e.g. "Products · 412");
actions right-aligned: secondary **"Export"** (opens a small menu: status
filter All / Active / Draft / Archived, then downloads — PUC-9; disabled
with a note when the catalog is empty), primary **"Import products"**
(→ §5.3).
- **Notices band (conditional):** image-problems aggregate notice — "N
products have image problems" linking to the most recent run's detail
(PUC-7); shown while any product's image status is rejected/failed.
- **Primary content, empty catalog:** empty state — "No products yet. Bulk
import is how product data gets in." with the **"Import products"**
action repeated, plus links **"Download sample CSV"** and **"Column
reference"** (PUC-11).
- **Primary content, populated catalog:** the honest pre-#14 state — the
product count and one line: "Your catalog is loaded. The browsable
product list arrives with an upcoming release." (replaced by #14's list).
- **Import history (section):** table of runs, newest first — date · file
name · dialect · added / updated / errors · image outcomes (e.g.
"4,938 ✓ · 12 ✗") · status (Importing… / Fetching images… / Complete /
Complete with problems). Row click → run detail (§5.5). Empty state: "No
imports yet."
- **States:** loading: skeleton for count + history · error: honest failure
banner with retry (INV-9 spirit) · empty/populated per above.
- **Notifications:** none beyond the notices band (no toasts here; outcomes
live on durable surfaces).
### 5.3 Screen: Import — upload (serves PUC-2, PUC-5a, PUC-11)
- **Purpose:** choose and validate a file.
- **Layout:**
- **Header:** "Import products", back to Products.
- **Primary content:** drop zone / file picker ("CSV, up to 5,000 rows");
beneath it the format help: "Works with the canonical format or a Shopify
product export." + links to sample CSV and column reference (PUC-11).
- **Actions:** none beyond the picker — selecting a file starts upload +
validation immediately (one fewer click; canceling is free, PUC-3a).
- **States:** idle · uploading/validating: progress ("Validating… 2,140 of
4,950 rows") with cancel · file-level rejection (PUC-5a): error banner
naming the one problem ("This file is missing the required column
'Title'"), picker remains for retry · success: → preview (§5.4).
- **Notifications:** none (errors render in place).
### 5.4 Screen: Import — preview (serves PUC-3, PUC-3a, PUC-5, PUC-6, PUC-10)
- **Purpose:** the consent gate — see exactly what will happen, then decide.
- **Layout:**
- **Header:** "Import preview — ⟨file name⟩"; dialect line: "Recognized:
Shopify product CSV — mapped to canonical" or "Canonical format" (PUC-6).
- **Warnings band (conditional):** columns not imported, listed by name
(unknown-column warning); collapsible when long.
- **Summary tiles:** **N to add · M to update · U unchanged · K errors** —
each tile filters the detail list below.
- **Detail list:** one row per product (grouped from its variant rows):
handle, title, change kind (add / update / unchanged / error), variant
count. Expanding a row drills in (PUC-3): adds show the fields to be
set; updates show a field-by-field **before → after** diff (only changed
fields, per variant where applicable); errors show the error detail.
- **Error table (under the errors filter):** line number · column · plain-
language problem ("row 214: 'Variant Price' — '12,50' is not a price").
- **Actions (sticky footer):** primary **"Import N products"** (disabled
when nothing valid to apply — all-unchanged reads "Nothing to change —
your catalog already matches this file", PUC-10); secondary **"Cancel"**
(returns to Products, no trace, PUC-3a).
- **States:** the preview *is* a state machine of its content (mixes of
add/update/unchanged/error per above); plus stale-session expiry: a preview
left open past its validity window (§6) prompts re-validation rather than
applying stale diffs.
- **Notifications:** none.
### 5.5 Screen: Import run detail (serves PUC-4, PUC-5, PUC-7, PUC-8)
- **Purpose:** the durable record of one confirmed import — what applied, what
errored, how the image fetch went. The post-confirm landing page and the
import-history destination.
- **Layout:**
- **Header:** file name · imported ⟨relative time⟩ by ⟨email⟩ · dialect.
- **Result summary:** added / updated / errors counts (mirrors the preview's
final numbers).
- **Images section:** while fetching — progress "Fetching images: 312 of
4,950" (updates live); when done — outcome summary ("4,938 fetched ·
9 rejected · 3 failed") and an outcomes table listing only problems:
product handle · variant · image URL · outcome (rejected: below the
resolution bar / not an image · failed: unreachable) · what to do
("correct the URL and re-import"). All-clean collapses to one line.
- **Error table:** the run's unapplied rows (line · column · problem),
exactly as the preview showed them (PUC-5).
- **States:** applying ("Importing…") · fetching images · complete · complete
with problems (errors and/or image problems present).
- **Notifications:** in-app only (decided: no email summaries in the MVP).
## 6. Technical Design
Builds on SD-0001's shipped architecture (FastAPI backend, layered
`main → domains → platform` with import-linter enforcement; React/Vite admin
SPA; PostgreSQL everywhere). Invariant numbering continues from SD-0001
(INV-1…9) so citations stay unique app-wide.
### 6.1 Invariants
- **INV-10 — Import never deletes.** No code path of the import/export
mechanism removes a product, variant, or image record from the catalog.
The domain API exposes no bulk-delete; "upsert" is the only mutation.
*Enforced:* domain surface (no delete function exists) + tests asserting
catalog cardinality never decreases across any import.
- **INV-11 — Nothing applies without confirmation.** Validation and preview
write nothing to catalog tables; only an explicit confirm mutates, and it
applies exactly the diff the preview showed — or refuses as stale
(BUC-2's faithfulness, mechanically). *Enforced:* the preview path holds
no write connection to catalog tables (tests assert zero writes); confirm
recomputes the diff and compares its fingerprint to the previewed one
(§6.5, PUC-3/4).
- **INV-12 — Export → import round-trips to a no-op.** Exporting a catalog
and re-importing the file unmodified produces an empty diff (PUC-10,
issue #13's acceptance bar). The canonical codec is lossless for every
field the catalog stores. *Enforced:* property-style test — for arbitrary
catalogs: `diff(catalog, import(export(catalog))) = ∅`.
- **INV-13 — `Handle` is product identity; the option-value combination is
variant identity.** Per storefront, one product per handle; per product,
one variant per (Option1, Option2, Option3) value combination. SKU is
correctable *data*, never identity. *Enforced:* unique indexes
(`(storefront_id, handle)`; `(product_id, option1_value, option2_value,
option3_value)`).
- **INV-14 — Every catalog row carries its storefront.** All product-domain
tables reference `storefront_id` directly or via their product; every
query is storefront-scoped (extends SD-0001 INV-5 to its first real
tenant-data domain). *Enforced:* FK constraints + service-layer scoping +
tests with two storefronts asserting zero bleed.
- **INV-15 — Imported HTML is sanitized at the boundary.** `Description`
HTML is sanitized against an allowlist (formatting tags; no scripts,
event handlers, or embeds) before storage. The catalog never stores HTML
we would not render. *Enforced:* sanitizer in the import path + tests
with hostile fixtures.
- **INV-16 — Surfaces serve only hosted images.** No storefront or admin
surface ever hotlinks a merchant-supplied URL; an image is
fetched-and-hosted (with renditions) or it is a placeholder with a
tracked status. The source URL is retained as data (provenance, retry).
*Enforced:* render paths take storage keys, not URLs; tests.
- **INV-17 — Dialects normalize at the boundary.** Every accepted file is
mapped to the canonical row model *before* validation and diffing; the
products domain knows exactly one model. A new dialect (XLSX, another
platform) is a new edge adapter, never a second code path through the
domain. *Enforced:* layering — dialect adapters live at the API/codec
edge; domain functions accept only canonical rows.
- **INV-18 — Import work is bounded and fails honestly.** Hard caps,
rejected whole-file with a named reason (PUC-5a): ≤ 5,000 data rows,
≤ 10 MB file. Image fetching is bounded per image (≤ 20 MB, 30 s
timeout, content-type must be an image format we process). Over a cap is
an honest refusal, never a partial silent attempt (SD-0001 INV-9).
*Enforced:* checks at the upload/fetch boundaries + tests at the caps.
### 6.2 High-level architecture
```mermaid
flowchart LR
subgraph Browser
SPA[Admin SPA
Products section §5]
end
subgraph "ecomm backend (FastAPI, one process)"
BFF[REST BFF
main.py]
subgraph domains
ACC[accounts]
SF[storefronts]
PROD[products ◄ new
catalog · import/export
csv codec · dialects · diff]
end
subgraph platform
DB[db
Postgres + migrations]
OBJ[objectstore ◄ new
port: local / GCS]
IMG[images ◄ new
validate + renditions]
DEPS[deps]
end
BG[image-fetch task
in-process, post-commit ◄ new]
end
STORE[(PostgreSQL)]
GCS[(Object storage
GCS · dev: local disk)]
HOSTS[Merchant image hosts
egress fetch]
SPA -->|JSON + multipart /api/*| BFF
BFF --> PROD
BFF --> ACC
BFF --> SF
PROD --> DB
PROD --> OBJ
PROD --> IMG
DB --> STORE
OBJ --> GCS
BG --> HOSTS
BG --> OBJ
BG --> DB
```
- **`products` domain (new)** — owns the catalog (products, variants,
images) and the import/export use cases: the canonical row model, the CSV
codec (parse/serialize), dialect detection + the Shopify mapping
(INV-17), validation, the diff engine (catalog × canonical rows →
add/update/unchanged/error), upsert application (INV-10/11/13), import
drafts and runs. Must never mint identity or storefronts; everything it
touches is storefront-scoped (INV-14).
- **`platform/objectstore` (new)** — a small port (`put / get / delete /
public_url`) with two adapters per the mailer pattern: local-disk
(dev/tests) and GCS (PPE/prod; bucket + credentials from deployment
config, SD-0001 INV-8). Owns no semantics.
- **`platform/images` (new)** — pure image processing: verify the bytes are
a decodable image (jpeg/png/webp), check the resolution bar, emit
renditions (thumb ~160px / card ~480px / detail ≤ 1600px — downscale
only). Model-free, deterministic, no I/O of its own.
- **Image-fetch task (new)** — the post-commit phase (PUC-7): an in-process
asyncio task per run that walks the run's pending images, fetches with
INV-18's bounds, runs `platform/images`, stores renditions via
`objectstore`, and updates per-image status + run progress. **This is the
async seam:** it is the only fire-and-forget work in the app; when
catalogs outgrow the in-process model, this task body moves onto a real
queue without changing its inputs/outputs (§6.9 covers crash recovery).
- **REST BFF** — gains the `/api/products/*` endpoints (§6.4); still owns no
business logic (SD-0001 INV-6).
- **Admin SPA** — gains the Products section (§5); renders what the BFF
returns; live progress is polling (no websockets in the MVP — deliberate).
### 6.3 Data model & ownership
All owned by the `products` domain; system of record PostgreSQL. Monetary
values are `NUMERIC`; no currency column — the MVP is single-implicit-currency
(open question Q-1, §13).
| Entity | Key fields | Notes |
| --- | --- | --- |
| `product` | `id`, `storefront_id` (FK, INV-14), `handle` (unique per storefront, INV-13), `title`, `description_html` (sanitized, INV-15), `vendor`, `product_type` (`standalone`; `kit_virtual` / `kit_assembled` reserved for #15), `google_product_category`, `tags` (`text[]`), `status` (`draft \| active \| archived`), `published` (bool), `option1_name`…`option3_name`, timestamps | Option *names* live on the product; a no-option product has all names NULL and exactly one variant. |
| `variant` | `id`, `product_id` (FK), `position`, `option1_value`…`option3_value` (unique combo per product, INV-13), `sku`, `barcode`, `price`, `cost`, `weight` + `weight_unit`, `volume` + `volume_unit`, `tax_id_1`, `tax_id_2`, `inventory_tracker`, `inventory_qty`, `image_id` (FK → `product_image`, nullable), timestamps | The no-option product's single variant has NULL option values (uniqueness via `NULLS NOT DISTINCT`). SKU is indexed (component references, #15) but not unique — it is data, not identity. |
| `product_image` | `id`, `product_id` (FK), `position`, `source_url`, `alt_text`, `status` (`pending \| fetched \| rejected_low_res \| rejected_not_image \| failed`), `failure_reason`, storage keys (`original`, `thumb`, `card`, `detail`), `import_run_id` (FK — the run that introduced/last fetched it), `fetched_at` | Identity within a product is `source_url`: re-importing the same URL is "unchanged" — never re-fetched. A **platform-hosted URL** (an export's `Image Src`) resolves to the existing image record by storage key — never fetched (same storefront required, else row error; INV-12/14). A fetch happens only for a genuinely new URL, or as retry of a `failed`/`rejected` one. INV-16 governs serving. |
| `import_draft` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, file blob (objectstore key), diff summary + fingerprint, `unknown_columns`, `expires_at` (~1 h), `created_at` | The preview's server side. Canceled or expired drafts are deleted outright — no trace (PUC-3a). Never appears in history. |
| `import_run` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, counts (`products_added`, `products_updated`, `rows_errored`), `status` (`applying \| fetching_images \| complete \| complete_with_problems`), `created_at`, `completed_at` | Created only at confirm (PUC-4). The durable record (PUC-8). |
| `import_run_error` | `id`, `run_id` (FK), `line_number`, `column_name`, `message` | One row per rejected CSV row (PUC-5); merchant-language message. |
**Deliberately absent:** any `component` / kit table — the CSV format
reserves the columns (§6.5 dialect spec), but the entities, validation, and
meaning are #15's design. In this MVP a non-`standalone` `Type` or a
non-empty `Component n` column is a row error ("kits arrive in a coming
release").
### 6.4 Interfaces & contracts
All under `/api/products/*`; session cookie + storefront membership required
(SD-0001's gates); errors share SD-0001's shape
`{"error": {"code", "message"}}`. Adding fields is free; renaming/removing is
a spec change.
| Endpoint | In | Out | Errors |
| --- | --- | --- | --- |
| `POST /api/products/imports` | multipart CSV file | `201 {draft}` — dialect, summary counts, `unknown_columns`, `expires_at` (draft persisted server-side, INV-11) | `400 not_csv` · `400 missing_required_column` · `400 unknown_dialect` · `400 too_many_rows` · `413 file_too_large` (all PUC-5a — no draft created) |
| `GET /api/products/imports/drafts/{id}` | — | `200 {draft}` summary (re-fetch for the preview shell) | `404` · `410 draft_expired` |
| `GET /api/products/imports/drafts/{id}/records` | `?kind=add\|update\|unchanged\|error`, paging | `200 {records: [{handle, title, kind, variant_count, detail}]}` — `detail` carries field-level before → after diffs (PUC-3) or the error rows | `404` · `410 draft_expired` |
| `POST /api/products/imports/drafts/{id}/confirm` | — | `201 {run_id}` — diff re-computed and fingerprint-checked, applied in one transaction (INV-11), image task started, draft deleted | `404` · `410 draft_expired` · `409 preview_stale` (catalog changed since validation — re-upload) · `409 nothing_to_apply` (PUC-10) |
| `DELETE /api/products/imports/drafts/{id}` | — | `204` — draft and its blob deleted, no trace (PUC-3a) | `404` (idempotent enough) |
| `GET /api/products/imports/runs` | paging | `200 {runs: [...]}` — history, newest first (PUC-8) | — |
| `GET /api/products/imports/runs/{id}` | — | `200 {run}` — counts, status, error rows, image outcomes + `image_progress {done, total}`; the SPA polls this while `status != complete*` (PUC-4/7) | `404` |
| `GET /api/products/export` | `?status=all\|active\|draft\|archived` | `200 text/csv` (streamed) — canonical format, hosted image URLs (PUC-9, INV-12) | `409 empty_catalog` |
| `GET /api/products/sample.csv` | — | `200 text/csv` — the documented sample (PUC-11) | — |
| `GET /api/products/summary` | — | `200 {product_count, image_problem_count, latest_run_id}` — drives the Products page header + notice band (§5.2) | — |
### 6.5 Per–Product-Use-Case design
#### 6.5.1 The canonical CSV format (the contract behind every PUC)
UTF-8 (BOM tolerated) · comma-delimited · RFC 4180 quoting · header row
required · caps per INV-18.
**Row grammar (Shopify-style):** consecutive rows sharing a `Handle` form one
product. The product's first row carries the product-level fields (`Title`
required there); each row may carry a variant (its `Option n Value`s +
`Variant *` fields), an image (`Image Src` + `Image Position`/`Alt Text`),
or both. Image-only rows (just `Handle` + `Image *`) are valid — that's how
a product carries more images than variants.
| Column | Level | Required | Notes |
| --- | --- | --- | --- |
| `Handle` | product | every row | identity (INV-13): lowercase letters, numbers, dashes |
| `Title` | product | first row of a product | |
| `Description` | product | — | HTML allowed; sanitized (INV-15) |
| `Vendor` | product | — | free text |
| `Type` | product | — | `standalone` (default); `kit_virtual` / `kit_assembled` reserved — non-`standalone` is a row error until #15 |
| `Google Product Category` | product | — | taxonomy string |
| `Tags` | product | — | comma-separated within the cell |
| `Status` | product | — | `draft \| active \| archived`; default `active` on add |
| `Published` | product | — | `TRUE \| FALSE`; default `TRUE` on add |
| `Option1–3 Name` | product | with values | e.g. "Size"; a value without its name is a row error |
| `Option1–3 Value` | variant | per variant | the variant's identity combo (INV-13) |
| `Variant SKU`, `Variant Barcode` | variant | — | SKU indexed, not identity |
| `Variant Price`, `Variant Cost` | variant | — | decimal; no currency (Q-1) |
| `Variant Weight` + `Unit`, `Variant Volume` + `Unit` | variant | — | decimal + unit string |
| `Variant Tax ID 1–2` | variant | — | opaque references |
| `Variant Inventory Tracker`, `Variant Inventory Qty` | variant | — | qty integer ≥ 0 |
| `Variant Position` | variant | — | display order; defaults to file order |
| `Image Src`, `Image Position`, `Image Alt Text` | image | — | URL (or platform-hosted URL on re-import — §6.3) |
| `Variant Image` | variant | — | URL for this variant's specific image |
| `Component 1–10 SKU` / `Quantity` | variant | — | **reserved for #15** — non-empty is a row error ("kits arrive in a coming release") |
**Update semantics (blank vs absent):** a column *absent from the file* is
untouched by the import; a cell *present but empty* clears that field (or
resets it to its default). The preview's diff shows clears explicitly — this
is previewable, deliberate behavior, not a footgun (INV-11).
**The Shopify dialect** is recognized by its exact header set and mapped at
the boundary (INV-17): `Body (HTML)` → `Description`, `Product Category` →
`Google Product Category`, `Cost per item` → `Variant Cost`,
`Variant Grams`/`Variant Weight Unit` → weight fields; `Handle`, `Title`,
`Vendor`, `Tags`, `Status`, `Published`, options, SKU/barcode/price,
inventory and image columns map directly. Shopify columns with no canonical
home — `Type` (Shopify's free-text type), `Compare At Price`, SEO fields,
Google Shopping fields, fulfillment/shipping/tax-code flags, `Gift Card` —
are **warned as not-imported** in the preview (PUC-6); the implementation
plan pins the exhaustive mapping table as a fixture.
#### 6.5.2 Import: upload → validate → preview (PUC-2, PUC-3, PUC-3a, PUC-5a, PUC-6)
```mermaid
sequenceDiagram
actor M as Merchant
participant S as SPA
participant A as BFF
participant P as products domain
participant O as objectstore
participant D as Postgres
M->>S: select CSV file
S->>A: POST /api/products/imports (multipart)
A->>P: import_validate(storefront, file)
P->>P: parse · detect dialect · map to canonical (INV-17)
alt file-level problem (PUC-5a)
P-->>A: not_csv / missing_required_column / unknown_dialect / too_many_rows
A-->>S: 400 — no draft, no trace
else valid file
P->>P: validate rows · diff against catalog (read-only, INV-11)
P->>O: store file blob (private)
P->>D: insert import_draft (summary, fingerprint, expires_at)
A-->>S: 201 draft {dialect, counts, unknown_columns}
S-->>M: preview (§5.4); records paged via GET …/records
end
opt cancel (PUC-3a)
M->>S: Cancel
S->>A: DELETE …/drafts/{id}
A->>P: discard(draft) — row + blob deleted, no trace
end
```
The diff engine compares canonical rows to the storefront's catalog
per-product: field-by-field over product, variants (matched by option combo,
INV-13), and images (matched by `source_url`, with platform-hosted URLs
resolved to existing records — §6.3 — so an exported file diffs clean,
INV-12). Every product classifies as add / update / unchanged / error;
errors carry line + column + merchant-language message (PUC-5).
#### 6.5.3 Import: confirm → apply (PUC-4, PUC-5, PUC-10)
```mermaid
sequenceDiagram
actor M as Merchant
participant S as SPA
participant A as BFF
participant P as products domain
participant D as Postgres
participant T as image-fetch task
M->>S: "Import N products"
S->>A: POST …/drafts/{id}/confirm
A->>P: import_apply(draft)
P->>P: recompute diff · compare fingerprint (INV-11)
alt fingerprint differs
P-->>A: 409 preview_stale → re-upload
else nothing to apply (PUC-10)
P-->>A: 409 nothing_to_apply
else applies
P->>D: ONE transaction: upsert products/variants/images (INV-10/13),
insert import_run + errors, delete draft
P->>T: start(run) — images with status=pending
A-->>S: 201 {run_id}
S-->>M: run detail (§5.5), polling
end
```
Per-image work in the transaction: new `source_url` → `pending` row;
unchanged URL → untouched; platform-hosted URL → resolved, untouched;
prior `failed`/`rejected` URL re-supplied → reset to `pending` (the retry
path, PUC-7).
#### 6.5.4 The image-fetch phase (PUC-7)
```mermaid
sequenceDiagram
participant T as image-fetch task
participant H as merchant image host
participant I as platform/images
participant O as objectstore
participant D as Postgres
loop pending images of the run (bounded concurrency ~4)
T->>H: GET source_url (INV-18 bounds: 20 MB · 30 s · SSRF guard §6.6)
alt unreachable / oversize / wrong type
T->>D: status=failed + reason
else bytes received
T->>I: decode · resolution bar · renditions (thread pool)
alt not an image / below the bar
T->>D: status=rejected_* + reason
else ok
T->>O: put original + thumb/card/detail
T->>D: status=fetched + storage keys
end
end
T->>D: bump run image_progress
end
T->>D: run status → complete / complete_with_problems
```
Idempotent per image (keyed by image id; statuses only move
`pending → terminal`), so the task can die and resume at any point (§6.9).
The SPA polls `GET …/runs/{id}` for progress and outcomes.
#### 6.5.5 Export (PUC-9) · the simple PUCs
Export streams the catalog (optionally status-filtered) through the canonical
serializer — the same codec the importer parses (one codec, two directions;
INV-12 is a property of that codec plus hosted-URL resolution). `Image Src`
carries the hosted `detail`-rendition URL (or the original `source_url` for
never-fetched/`failed` images, so nothing is lost). PUC-1 (Products page) is
`GET /api/products/summary`; PUC-8 (history) is the runs list/detail
endpoints; PUC-11 (sample CSV) is a static documented asset served at
`GET /api/products/sample.csv` and linked from the column reference (DOC-2,
§11).
### 6.6 Non-functional requirements & cross-cutting concerns
- **Security & privacy:** all endpoints behind SD-0001's session +
storefront-membership gates; every query storefront-scoped (INV-14).
**SSRF guard on image fetch:** http/https only, public IPs only (resolve
and reject private/link-local/metadata ranges), no redirects across the
guard, bounded size/time (INV-18). HTML sanitization at the boundary
(INV-15). Uploaded file blobs are private objects, deleted with their
draft; catalog data is merchant business data (no shopper PII in this
feature). Run records and errors are retained indefinitely (they are the
merchant's audit trail); drafts expire in ~1 h.
- **Performance & scale:** validate + diff ≤ 5,000 rows in < 10 s
(one catalog read, in-memory diff); apply in one transaction < 5 s;
export streams (no whole-catalog buffering); image phase: ~4 concurrent
fetches, Pillow in a worker thread pool — a 5,000-image run completes in
tens of minutes *in the background* without touching request latency.
- **Availability & resilience:** the import transaction is short; the image
task is resumable across restarts/deploys (§6.9); a dead merchant image
host degrades to per-image `failed`, never a hung import.
- **Observability hooks:** `import_run.id` is the correlation id — the
fetch task and all run-scoped log lines carry it (substance in §9).
- **Accessibility:** preview/summary tiles and tables keyboard-navigable;
progress updates `aria-live`; diff colors paired with +/− glyphs (not
color-only).
### 6.7 Key decisions & alternatives considered
| Decision | Chosen | Alternatives considered | Why chosen |
| --- | --- | --- | --- |
| Matching identity | `Handle` for products, option-combo for variants (INV-13) | SKU as identity (the corpus's 11-05-0035) | Real catalogs have missing/duplicated SKUs; a SKU typo must be *correctable by import*, which identity can't be. Divergence from corpus recorded in §4. |
| Image handling | Fetch-and-host with renditions; reject low-res | Hotlink merchant URLs; validate-only + hotlink; accept low-res with warning | Hotlinking leaks shopper traffic to third parties, rots, and breaks INV-16; rejected-low-res keeps every hosted image renderable at all surfaces (operator decision). |
| Image timing | Post-commit background phase | Fetch during import (stalls minutes); fetch at preview (wasted on cancel) | Catalog lands in seconds; images are the async seam's first tenant. |
| Background model | In-process asyncio task, resumable | Job queue (Cloud Tasks/celery); cron sweep | No queue infra for an MVP with one fire-and-forget job; the task body is queue-portable when scale demands (§6.9 names the seam). |
| Preview state | Server-side `import_draft` + fingerprint | Client holds the diff and posts it back; re-validate blindly at confirm | The server applying exactly what it previewed is INV-11; a client-posted diff is unverifiable; fingerprint check catches catalog drift honestly (`409 preview_stale`). |
| Format | Canonical = Shopify-flavored superset | Invent a clean format; exact Shopify schema | Familiarity + migration head start without inheriting Shopify's gaps (no kit seam, no volume/tax fields). |
| Shopify free-text `Type` | Warned, not imported | Map into `Tags`; map into canonical `Type` | Canonical `Type` is structural (`standalone`/kits, #15); silently folding a category string into it (or tags) invents data the merchant didn't write. |
| Blank vs absent | Absent column untouched; empty cell clears | Empty cell ignored (Shopify-ish); empty cell always clears including absent | Merchants must be able to clear a field via import; the preview shows every clear (INV-11), making the sharper semantic safe. |
| Progress delivery | SPA polls run detail | WebSockets/SSE | One poll endpoint, no connection infra; seconds-stale progress is fine for this UX. |
| Notifications | In-app only | Email summaries via SmtpMailer | Operator decision; revisit if imports go async/long. |
| Object storage | New `platform/objectstore` port (local/GCS) | Store images as DB blobs; reuse a public bucket directly in domain code | Follows the mailer port pattern (SD-0001); DB blobs bloat backups and can't serve renditions efficiently. |
### 6.8 Testing strategy
Two-tier per handbook §9: unit/integration locally and in CI; E2E browser
tests (Playwright) against localhost and PPE as the pipeline gates.
- **Unit — codec & dialects:** parse/serialize round-trip property tests
(INV-12: `diff(catalog, import(export(catalog))) = ∅` over generated
catalogs); a real Shopify export fixture mapping correctly; unknown-column
warning extraction; blank-vs-absent semantics; every §6.5.1 row-error rule
has a fixture.
- **Unit — diff engine:** add/update/unchanged/error classification;
option-combo matching incl. the no-option single variant; hosted-URL
resolution (no re-fetch); clear-vs-untouched fields; fingerprint stability.
- **Unit — images:** `platform/images` against fixture bytes (good jpeg/png/
webp, tiny image, not-an-image, oversize); rendition dimensions.
- **Unit — sanitizer:** hostile HTML fixtures (scripts, handlers, embeds).
- **Integration:** apply transactionality (a mid-apply failure leaves zero
catalog change); two-storefront isolation (INV-14); fetch-task resume
after a simulated kill; SSRF guard against private-range URLs.
- **E2E browser scenarios (named):**
`e2e_import_preview_confirm` (canonical file → preview counts → drill-in
diff → confirm → run detail → summary count updated);
`e2e_import_shopify_dialect` (unmodified Shopify fixture, mapped banner +
not-imported warnings); `e2e_import_errors_actionable` (bad rows: error
table lists line/column/reason; valid rows import);
`e2e_import_file_rejected` (missing `Title` column — whole-file message,
no run recorded); `e2e_import_cancel_no_trace`;
`e2e_roundtrip_noop` (export → re-import → all-unchanged, import action
disabled); `e2e_export_download` (status filter respected);
`e2e_image_outcomes` (fixture image host serving one good / one 404 / one
tiny image → statuses, placeholder, Products-page notice). The E2E suite
runs against a local fixture image host (no real egress in tests).
- **Bootstrap:** SD-0001's INV-1 test extends — fresh DB → sign-up →
storefront → import sample CSV → export matches.
### 6.9 Failure modes, rollback & flags
- **Failure mode:** crash mid-apply → **behavior:** transaction rolls back —
zero catalog change; draft intact; SPA shows honest error →
**rollback:** none needed → **alerted by:** ALR-2 (§9.3).
- **Failure mode:** process restart (deploy) mid image-fetch →
**behavior:** on startup, a recovery scan finds runs in `fetching_images`
with `pending` images and resumes the task; per-image idempotency (§6.5.4)
makes re-entry safe → **alerted by:** TEL/log line on recovery; ALR-3 if a
run stays incomplete past threshold.
- **Failure mode:** merchant image host down/slow → **behavior:** per-image
timeout → `failed` with reason; run completes `complete_with_problems`;
retry is a re-import (PUC-7) → no alert (expected operation).
- **Failure mode:** object storage unavailable → **behavior:** affected
images `failed` honestly; uploads (drafts) refuse with a clear error →
**alerted by:** ALR-3.
- **Failure mode:** draft expiry / catalog drift between preview and confirm
→ **behavior:** `410 draft_expired` / `409 preview_stale`; UI prompts
re-upload — never applies stale diffs (INV-11).
- **Feature flag / kill switch:** none — the Products section ships whole;
rollback is redeploying the prior version (pipeline §7.3). Migrations are
additive new tables, so the prior version runs unaffected against the
migrated schema (SD-0001 INV-7 forward-only).
- **The async seam, named:** if catalogs outgrow the in-process model
(imports > 5k rows, image sets taking hours), the fetch-task body moves
onto a real queue (Cloud Tasks or a worker process) with identical
inputs/outputs; `import_run`/`product_image` statuses already model the
asynchrony, so the UX and API do not change shape.
### 6.10 Migration & compatibility
Greenfield within a live app: no existing catalog data to migrate. New
additive tables (§6.3) via forward-only migrations (SD-0001 INV-7); the
admin nav gains "Products"; no existing endpoint or contract changes.
SD-0001's INV-1 (empty is a working state) holds — an empty catalog is the
designed Products-page empty state. **Backwards compatibility:** none owed
yet (no external API consumers). **Cutover:** ships whole behind the
ordinary release pipeline; no dual-write, no point of no return short of
merchants having imported real data (which INV-10/12 protect).
## 7. Delivery Plan
*(not yet authored)*
## 8. Success Metrics & Measurement
*(not yet authored)*
## 9. Telemetry, Observability & Monitoring
*(not yet authored)*
## 10. Operations
*(not yet authored)*
## 11. Documentation plan
*(not yet authored)*
## 12. Traceability matrix
*(not yet authored)*
## 13. Open Questions & Decisions log
*(not yet authored)*
## 14. Glossary & References
*(not yet authored)*