---
status: graduated
---
# 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** | `approved` |
| **Version** | v0.3.1 |
| **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 |
| 2026-06-11 | v0.2.0 | Full draft complete, §§1–14, authored section-at-a-time with operator review at each boundary (session ecomm-0030) | Claude + Ben |
| 2026-06-11 | v0.3.0 | Claude Design hi-fi bundle committed (`ui/designs/SD-0002-…`); §5.1 decision amended to Design → Code (D-14); §5.2 toast refinement adopted | Claude + Ben |
| 2026-06-11 | v0.3.1 | Status → `approved` at session finalize — every section operator-reviewed at its boundary during authoring (autonomous posture, handbook §6.5) | 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; the committed **Claude Design hi-fi bundle** at
[`ui/designs/SD-0002-products-bulk-csv-import-export/`](../ui/designs/SD-0002-products-bulk-csv-import-export/)
renders these descriptions interactively (all four screens + states, built on
the wiggleverse design-system bundle) and serves as SLICE-5's visual
bootstrap artifact. Where bundle and prose disagree, this prose governs
(GUIDE §3.2). All surfaces live inside SD-0001's admin shell (its header:
storefront name left, signed-in email + sign out right; the bundle's sidebar
treatment is indicative — the shell remains SD-0001's).

### 5.1 Design tooling decision

- **Decision:** Design → Code, with the Claude Design project scoped to: the
  Products section (Products page · import upload · preview · run detail).
  Export bundle committed at `ui/designs/SD-0002-products-bulk-csv-import-export/`;
  per the rubric's integration note it is a **bootstrap artifact, not a
  sync** — after SLICE-5 lands it, iteration happens in code, and Claude
  Design returns only when a new spec independently scores for it.
- **Rationale:** the rubric scored **−1 → Code directly** as drafted (Q1 +1
  net-new section, Q2 −1 wireframe-level prose, Q3 0, Q4 +1 no app design
  system, Q5 −1 solo review, Q6 −1 logic-dominant). The operator then ran
  the Design step anyway — the shareable-artifact override in effect — and
  produced the hi-fi bundle from this spec's §§4–5, validating the prose
  rather than replacing it. Both the score and the actual path are recorded
  so neither is re-litigated (D-14, §13).
- **Design notes carried into implementation:** the bundle's thin
  single-weight geometric icon set is flagged (by the bundle itself) as a
  **net-new admin icon set**, not yet house brand; the design-system bundle
  (`_ds/wiggleverse-design-system-…`) is the same one SD-0001's designs use.

### 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:** toasts for the two fire-and-forget gestures — export
  started/completed and sample-CSV downloaded (a design-bundle refinement);
  import outcomes never toast — they live on durable surfaces (preview, run
  detail, notices band).

### 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<br/>Products section §5]
  end
  subgraph "ecomm backend (FastAPI, one process)"
    BFF[REST BFF<br/>main.py]
    subgraph domains
      ACC[accounts]
      SF[storefronts]
      PROD[products  ◄ new<br/>catalog · import/export<br/>csv codec · dialects · diff]
    end
    subgraph platform
      DB[db<br/>Postgres + migrations]
      OBJ[objectstore  ◄ new<br/>port: local / GCS]
      IMG[images  ◄ new<br/>validate + renditions]
      DEPS[deps]
    end
    BG[image-fetch task<br/>in-process, post-commit  ◄ new]
  end
  STORE[(PostgreSQL)]
  GCS[(Object storage<br/>GCS · dev: local disk)]
  HOSTS[Merchant image hosts<br/>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.3.1 Storage: GCP resources, layout & serving

**Catalog data — Cloud SQL for PostgreSQL, no new resource.** The §6.3
tables are additive forward-only migrations in the existing per-environment
Cloud SQL instance (PPE: `ecomm-ppe-pg`; prod: its sibling when prod stands
up). At MVP scale (≤ 5k products × ~30 columns + run/error records) this is
tens of megabytes. Image *bytes* never enter Postgres (§6.7).

**Image bytes — one GCS bucket per environment**, project
`wiggleverse-ecomm`, same region as the VM, Standard class, uniform
bucket-level access, versioning off, **no public access**. Provisioning is a
flotilla/launch-app gesture (a `provision-bucket` sibling of
`provision-datastore`), run by the operator when the first slice needing it
reaches PPE (handbook §8.5); the bucket name reaches the app via deployment
config (SD-0001 INV-8). The VM's service account gets
`roles/storage.objectAdmin` scoped to this bucket only. Local dev and tests
use the `objectstore` port's local-disk adapter under the identical key
scheme — no GCS, no emulator.

**Key layout** — tenant-scoped, immutable, cache-friendly:

```
gs://wiggleverse-ecomm-<env>-media/
  storefronts/{storefront_id}/product-images/{image_id}/original        ← bytes as fetched
  storefronts/{storefront_id}/product-images/{image_id}/thumb.webp      ← ~160px
  storefronts/{storefront_id}/product-images/{image_id}/card.webp       ← ~480px
  storefronts/{storefront_id}/product-images/{image_id}/detail.webp     ← ≤1600px
  import-drafts/{draft_id}/upload.csv                                   ← preview blobs
```

- The `storefronts/{storefront_id}/` prefix extends INV-14 into storage.
- Keys are `image_id`-addressed and **written once, never mutated** — a
  changed image is a new image id, so every object is immutable and serves
  with `Cache-Control: public, max-age=31536000, immutable`.
- Renditions normalize to **WebP**; the `original` keeps its source format
  (provenance; renditions can be re-derived if the bar or sizes change).
- `import-drafts/` carries a **GCS lifecycle rule: delete after 1 day** —
  the deterministic safety net behind app-level draft deletion (PUC-3a), so
  a crash can never leak a merchant's uploaded file.

**Serving — app-served with a CDN seam (decided, §6.7).** Surfaces load
images from `GET /api/products/images/{image_id}/{rendition}` (§6.4): the
app authorizes (image → storefront, INV-14/16), streams from the bucket, and
sets the immutable cache headers. Exported `Image Src` URLs therefore live
on the **app's own domain** — they survive the `market.wiggleverse.org`
rebrand (#23) via ordinary redirects, and hosted-URL recognition (§6.3) is a
same-origin path parse. **The seam:** when shopper-facing storefronts (#24)
bring real image traffic, a CDN-fronted bucket (LB + Cloud CDN +
`images.<domain>`) takes over the same keys; only the URL builder and the
serving route change.

### 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) | — |
| `GET /api/products/images/{image_id}/{rendition}` | `rendition ∈ original\|thumb\|card\|detail` | `200 image bytes` — storefront-authorized, streamed from the bucket, `Cache-Control: immutable` (§6.3.1, INV-16) | `404` (incl. wrong storefront) · `409 not_fetched` (status ≠ fetched → SPA shows placeholder) |

### 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),<br/>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. |
| Image serving | App-served route + private bucket, CDN seam named (§6.3.1) | Public bucket with direct GCS URLs; CDN + custom domain now; signed URLs | Exported URLs stay on the app domain (rebrand-safe #23, hosted-URL recognition trivial); bucket stays private; CDN waits for shopper traffic (#24). Signed URLs ruled out — expiry poisons the round-trip (INV-12). |
| Rendition format | WebP renditions, original kept as-source | Keep source format everywhere; AVIF | WebP: universal browser support + good compression; original retained so renditions can be re-derived (incl. a future AVIF tier). |

### 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

Slice numbering continues app-wide from SD-0001 (SLICE-1…4), like the
invariants: this design owns **SLICE-5…8**.

### 7.1 Approach / strategy

**Spine first, infra second, compatibility last.** The codec + diff engine is
the design's novel heart and its riskiest logic — it lands first, with the
full preview/confirm UX, so the consent model (INV-11) is real from day one.
Export lands second and immediately locks the round-trip property (INV-12)
while the data model is still images-light — cheaper to fix losslessness bugs
before the image pipeline complicates identity. Images land third (the
infra-heaviest slice: first GCS bucket, first background work). The Shopify
dialect lands last: by then it is a pure mapping at a stable, well-tested
boundary (INV-17), not a moving target. SLICE-8 can run in parallel with
SLICE-7 if sessions overlap — it touches only the dialect adapter and docs.

Execution convention per the standard: each slice is its own coding session —
just-in-time implementation plan → execute → verify → ship through the §9
pipeline → merge + version bump — in dependency order. The design pass
happens once, here; if executing a slice proves this spec wrong, amend the
spec, then replan.

### 7.2 Slicing plan

#### SLICE-5 — Import spine (canonical, no images) → completes PUC-1, PUC-2, PUC-3 (+3a), PUC-4, PUC-5 (+5a), PUC-8, and PUC-11's sample download

- **Depends on:** — (SD-0001 shipped surfaces only)
- **Ships:** `products` domain + §6.3 tables (images table present;
  fetch phase stubbed to no-op); canonical parser, validation, diff engine,
  draft/confirm/run endpoints (§6.4); Products page, upload, preview with
  drill-in, run detail (no images section), import history; sample CSV.
- **Definition of done:** BUC-1/1a/2 acceptance criteria met for the
  canonical path; INV-10/11/13/14/15/18 enforced + unit/integration suites
  green; E2E green: `e2e_import_preview_confirm`,
  `e2e_import_errors_actionable`, `e2e_import_file_rejected`,
  `e2e_import_cancel_no_trace`; TEL-1/2/6 emitting + ALR-2 live (§9);
  DOC-1 updated; DOC-3 sample shipped.

#### SLICE-6 — Export & the round-trip lock → completes PUC-9, PUC-10

- **Depends on:** SLICE-5
- **Ships:** canonical serializer (one codec, two directions), streamed
  export endpoint + status filter UI, `nothing_to_apply` no-op preview
  behavior, the INV-12 property test over text-field catalogs.
- **Definition of done:** BUC-3/4 acceptance criteria met; INV-12 property
  test green in CI; E2E green: `e2e_export_download`, `e2e_roundtrip_noop`;
  TEL-3 emitting; DOC-1 updated.

#### SLICE-7 — Images pipeline end-to-end → completes PUC-7

- **Depends on:** SLICE-6 (export must exist so hosted-URL round-trip is
  testable the day hosted URLs exist)
- **Ships:** `platform/objectstore` (local + GCS adapters) and
  `platform/images`; **the per-environment media bucket — provisioned via
  the flotilla `provision-bucket` gesture when this slice reaches PPE
  (§6.3.1, handbook §8.5)**; fetch task with SSRF guard, bounds, and
  restart recovery; image-serving endpoint; hosted-URL resolution in diff
  and export (INV-12 extended over images); run-detail images section,
  Products-page notice band; INV-16 in force.
- **Definition of done:** PUC-7 acceptance met; INV-12 property test
  extended over image URLs; integration: fetch-resume after kill, SSRF
  fixtures; E2E green: `e2e_image_outcomes` (fixture image host); TEL-4/5 +
  ALR-3 live; DOC-1 updated (bucket gesture documented in the operator
  guide).

#### SLICE-8 — Shopify dialect + public format docs → completes PUC-6, PUC-11

- **Depends on:** SLICE-5 (codec boundary); parallelizable with SLICE-7
- **Ships:** header-set dialect detection, the Shopify→canonical mapping
  with its exhaustive fixture table (§6.5.1), preview's "mapped" banner +
  not-imported warnings; the **column reference** public doc + final sample
  CSV (DOC-2/3 — the §7.3 launch gate for the migration path).
- **Definition of done:** BUC-1 acceptance met for an unmodified real
  Shopify export fixture; E2E green: `e2e_import_shopify_dialect`; DOC-2/3
  published; traceability matrix (§12) audited complete.

### 7.3 Rollout / launch plan

Via the mandatory pipeline (handbook §9), per slice: localhost + E2E → PPE
(`https://ecomm-ppe.wiggleverse.org`) + E2E → prod. **Prod does not exist
yet** (SD-0001's prod stand-up under `market.wiggleverse.org` is the cursor's
next stop): any slice completing before it lands ships to a green PPE and
rides the prod stand-up when it arrives — the PPE gate is never skipped
either way. Infra provisioned on demand at the stage that needs it
(§8.5-handbook): the media bucket at SLICE-7's PPE arrival (and again at its
prod arrival), operator-run. Prod is a single in-place slot (no blue/green
yet, per SD-0001). Rollback per slice = redeploy prior version (additive
migrations keep it safe, §6.10). **Non-code launch gates:** DOC-2/3 (column
reference + sample) published before SLICE-8 exposes the migration path;
§9.3 monitoring live with its slice; support path = tracker, pre-v1 (§10.2).

### 7.4 Risks & mitigations

| Risk | Likelihood / impact | Mitigation |
| --- | --- | --- |
| Diff-engine edge cases (blank-vs-absent, option matching, no-option variants) breed subtle catalog corruption | M / H | Property tests + per-rule fixtures (§6.8); INV-11 preview faithfulness makes any miss visible before it applies; INV-10 caps the blast radius (nothing deletes) |
| 5k-row validate+diff misses the < 10 s target on the shared VM | M / M | Measure at SLICE-5 with a 5k fixture; the cap is config, can drop without spec change (INV-18); async seam already named |
| Shopify export format drifts or has regional variants | M / M | Detection by exact header set fails *honestly* to `unknown_dialect` (never misparses); mapping is fixture-pinned; unknown columns warn rather than fail |
| Image fetch is an attack surface (SSRF, decompression bombs) | M / H | §6.6 guard (public IPs only, no cross-guard redirects, size/time/type bounds, Pillow decode caps) + integration fixtures |
| Deploy cadence kills in-process fetch tasks mid-run | M / L | Restart recovery is designed-in (§6.9) and integration-tested; per-image idempotency |
| Bucket provisioning gesture stalls SLICE-7 at PPE (operator availability) | L / L | Gesture named in the slice DoD; flotilla command handed to operator at session start, not discovery at deploy time |
| Scope creep toward in-app editing ("just one edit form") | L / M | §1.7 boundary + INV-10; editing is a future epic, not a slice |

## 8. Success Metrics & Measurement

### 8.1 Business success metrics

| Outcome | Measured by (source) | Baseline captured how | Owner | Review cadence |
| --- | --- | --- | --- | --- |
| BO-1 (catalog adoption) | # storefronts with `product_count > 0`, and upload→confirmed-run conversion time (TEL-1, TEL-2 + DB) | Trivially zero pre-launch (no mechanism exists) | Ben | Monthly |
| BO-2 (in-platform upkeep) | # storefronts with ≥ 2 confirmed runs; days between runs (TEL-2) | Zero pre-launch | Ben | Monthly |
| BO-3 (no lock-in) | Export availability (uptime of the endpoint) + exports performed (TEL-3) | n/a — exists from SLICE-6 | Ben | Quarterly |
| BO-4 (downstream unblocked) | #14 / #15 delivery proceeding against imported catalogs | Tracker record, not telemetry | Ben | Per Feature |

### 8.2 Product success metrics

| # | Metric (scores PUC-n) | Target | Fed by | Review cadence |
| --- | --- | --- | --- | --- |
| PM-1 | Import completion: share of merchants who, having uploaded a file, reach a confirmed run within 24 h (PUC-2→4) | ≥ 80% | TEL-1 + TEL-2 | Monthly |
| PM-2 | Row-error rate per confirmed run — proxies how learnable the format and how actionable our errors are (PUC-5, PUC-11) | median < 5% | TEL-2 | Monthly |
| PM-3 | Image fetch success per run (PUC-7) | ≥ 95% fetched | TEL-4 | Monthly |
| PM-4 | Upload→preview latency, ≤ 5k-row files (PUC-3) | p95 < 10 s | TEL-1 | Monthly |

## 9. Telemetry, Observability & Monitoring

Scaled to the single-VM deployment: structured log events + a handful of
log-based alerts. Deliberately omitted: metrics stack, tracing, dashboards —
the runs list (PUC-8) *is* the import dashboard; revisit at shopper-traffic
scale (#24).

### 9.1 Telemetry

Log-event telemetry (structured JSON, app log). No file names, no URLs, no
catalog content in events — counts and durations only (file names/URLs stay
in DB records the merchant owns). No secret bytes ever (hard rule §6.3-handbook).

| # | Event / signal | Trigger | Payload (shape) | Privacy class / retention | Consumed by |
| --- | --- | --- | --- | --- | --- |
| TEL-1 | `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` | none / log retention (30 d) | PM-1, PM-4, BO-1 |
| TEL-2 | `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` | none / 30 d | BO-1, BO-2, PM-1, PM-2 |
| TEL-3 | `catalog_exported` | export streamed | `storefront_id, status_filter, product_count, duration_ms` | none / 30 d | BO-3 |
| TEL-4 | `image_phase_completed` | a run's fetch task finishes | `run_id, fetched, rejected, failed, duration_ms` | none / 30 d | PM-3, ALR-3 |
| TEL-5 | `image_phase_recovered` | startup recovery resumes a run | `run_id, pending_resumed` | none / 30 d | ALR-3, RB-3 |
| TEL-6 | `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` | none / 30 d | ALR-2 |

### 9.2 Observability

- **Logs:** structured JSON to stdout → journald (Cloud Logging where the VM
  agent ships them); every run-scoped line carries `run_id` (§6.6); 30-day
  retention.
- **Metrics / traces:** none beyond TEL events — deliberate at this scale.
- **Health:** `/healthz` unchanged (process, DB, migrations — SD-0001).
  Image-task health is *data*, not a health endpoint: runs stuck in
  `fetching_images` are visible in the runs list and caught by ALR-3.
- **Dashboards:** none; "is the import healthy?" → the import history
  (PUC-8); "why did it fail?" → logs filtered by `run_id`.

### 9.3 Monitoring & alerts

SLO/ALR numbering continues app-wide (SLO-1/ALR-1 are SD-0001's health-check
pair under the deploy contract).

| # | SLO | Window | Protects |
| --- | --- | --- | --- |
| SLO-2 | 95% of validations (≤ 5k rows) complete < 10 s | 30 d | PUC-3 responsiveness (PM-4) |
| SLO-3 | 99% of image phases reach a terminal state < 60 min | 30 d | PUC-7 — outcomes arrive while the merchant still cares |

| # | Alert / monitor | Condition / threshold | Severity | Notifies | Runbook |
| --- | --- | --- | --- | --- | --- |
| ALR-2 | Import apply failing | any TEL-6 event | notify (email, log-based alert) | operator (Ben) | RB-2 |
| ALR-3 | Image phase stuck | run in `fetching_images` > 2 h, or same run recovered (TEL-5) ≥ 3× | notify | operator (Ben) | RB-3 |

Pre-v1 honesty: "notify" is a log-based email alert, no paging.

## 10. Operations

### 10.1 Operational plan

- **Operators / roles:** Ben (operator); no on-call — pre-v1 alerts are
  notify-only.
- **Routine tasks:** none daily. Per release: the §7.3 pipeline. Monthly:
  glance at bucket size/cost against the §10.3 guardrail. One-time per
  environment: the `provision-bucket` flotilla gesture (SLICE-7).
- **Runbooks** (RB-1 is SD-0001's health-restore; numbering continues):

| # | Runbook | Answers | Lives at |
| --- | --- | --- | --- |
| RB-2 | Import apply failed: locate by `draft_id`/`run_id` in logs; confirm rollback left catalog unchanged (INV-11); advise re-upload; file bug | ALR-2 | operator guide (framework repo), DOC-1 |
| RB-3 | Image phase stuck: check task log by `run_id`; verify bucket reachability + SA permissions; restart app — startup recovery resumes idempotently (§6.9) | ALR-3 | operator guide, DOC-1 |
| RB-4 | Provision the media bucket (the flotilla gesture, per environment) | SLICE-7 stand-up | operator guide, DOC-1 |

### 10.2 Support & incident response

Pre-v1, one line: the operator self-supports; problems are filed on the
tracker (`wiggleverse/wiggleverse-ecomm` issues); affected merchants (when
they exist) hear back on their issue.

### 10.3 Maintenance & lifecycle

- **Dependency patching:** Patchwatch → remediation pipeline, as app-wide.
  Note the new patch-sensitive surface: **Pillow** (image decoding of
  untrusted bytes) and the HTML sanitizer — prioritize their advisories.
- **Backup / restore:** catalog data rides Cloud SQL automated backups
  (existing). Image objects: immutable keys + GCS soft delete (7 d) are the
  MVP story; **no cross-region bucket backup yet** — accepted pre-v1 risk
  (originals are re-importable only while merchant URLs live), revisit at
  prod stand-up alongside the restore drill (Q-2, §13).
- **Capacity & cost:** ~5k images ≈ 10–15 GB ≈ well under $1/mo storage;
  serving egress rides the VM. Guardrail: the project billing budget alert
  (scaffold default) flags drift.
- **Deprecation / sunset:** this mechanism is the MVP's only mutation path;
  when a future editing epic arrives, import/export persists as the bulk
  path (no sunset) — only its "only mechanism" status retires.

## 11. Documentation plan

- **Where docs live:** spec — content repo (this file); developer docs +
  operator guide — framework repo; merchant-facing format docs — served by
  the app itself (the admin links them, PUC-11).

| # | Document | Audience | Location | New / updated | Owned by slice |
| --- | --- | --- | --- | --- | --- |
| DOC-1 | Operator guide: import/export ops — RB-2/3/4, bucket gesture, caps/config surface | operator | framework repo operator guide | updated | every slice (5–8) |
| DOC-2 | CSV column reference (every column: meaning, required, accepted values; dialect notes) | merchant | app-served page, source in framework repo | new | SLICE-8 |
| DOC-3 | Sample CSV (worked examples incl. variants + images) | merchant | app-served asset (`sample.csv`) | new SLICE-5; finalized SLICE-8 | SLICE-5 / SLICE-8 |
| DOC-4 | `products` domain dev notes (canonical model, diff engine, dialect adapters, objectstore/images ports) | developer | framework repo | new | SLICE-5, extended 6–8 |

DOC-2/3 are the §7.3 launch gate for the migration path: published before
SLICE-8 ships. Docs update in the same PR/slice as the change they describe
(§4.1-handbook).

## 12. Traceability matrix

| Pain | Business UC | Product UC | Slice | Tests | Measured by |
| --- | --- | --- | --- | --- | --- |
| PP-1 dead-end storefront | BUC-1 | PUC-1, PUC-2, PUC-3, PUC-4 | SLICE-5 | `e2e_import_preview_confirm`; bootstrap test (§6.8) | BO-1 / PM-1 ← TEL-1, TEL-2 |
| PP-2 re-keying to migrate | BUC-1, BUC-1a | PUC-5, PUC-5a, PUC-6, PUC-11 | SLICE-5, SLICE-8 | `e2e_import_shopify_dialect`, `e2e_import_errors_actionable`, `e2e_import_file_rejected` | BO-1 / PM-2 ← TEL-1, TEL-2 |
| PP-3 no batch gesture | BUC-2, BUC-3 | PUC-3, PUC-3a, PUC-4, PUC-10 | SLICE-5, SLICE-6 | `e2e_import_cancel_no_trace`, `e2e_roundtrip_noop`; INV-12 property test | BO-2 / PM-1 ← TEL-2 |
| PP-3 (imagery upkeep) | BUC-1a | PUC-7 | SLICE-7 | `e2e_image_outcomes`; fetch-resume + SSRF integration | PM-3 ← TEL-4, TEL-5 |
| PP-4 builders blocked | BUC-1 | PUC-1…PUC-11 (the mechanism itself) | SLICE-5–8 | bootstrap test end-to-end | BO-4 (tracker record) |
| PP-5 trapped data | BUC-4 | PUC-9, PUC-10 | SLICE-6 | `e2e_export_download`, `e2e_roundtrip_noop` | BO-3 ← TEL-3 |

Audit: every PUC lands in a slice (PUC-11's sample in SLICE-5, finalized
SLICE-8); every BUC is realized; every BO has a §8.1 source; every
TEL event is consumed (§9.1); both alerts have runbooks (§9.3 → §10.1).

## 13. Open Questions & Decisions log

Question/decision numbering is per-spec (SD-0001's D-1…8 and Q-1…3 are its
own; cite cross-spec as "SD-0002 D-13").

**Open**

| # | Question | Owner | Blocks |
| --- | --- | --- | --- |
| Q-1 | Currency: prices are bare decimals with one implicit currency. When does a storefront get a currency setting, and what happens to existing imports then? | Ben | nothing in this MVP; revisit before checkout/orders work |
| Q-2 | Bucket backup: no cross-region backup of image objects pre-v1 (accepted risk, §10.3). Decide backup + restore drill at prod stand-up. | Ben | prod hardening, not this Feature |
| ~~Q-3~~ | ~~The exact image resolution bar~~ — **RESOLVED in SLICE-7 → D-15.** | — | — |

**Resolved** (operator decisions, brainstorming session ecomm-0030; technical
rationale for engineering choices lives in §6.7)

| # | Decision | Resolution | Date |
| --- | --- | --- | --- |
| D-1 | Canonical column set | Shopify-flavored superset, operator-specified (§6.5.1): options/variants, images, inventory, cost/weight/volume/tax, kit-seam `Component` columns | 2026-06-11 |
| D-2 | Foreign dialects | Canonical + Shopify product CSV, auto-detected by header set; merchant never picks | 2026-06-11 |
| D-3 | Deletion semantics | Upsert-only; the CSV mechanism never deletes (INV-10) | 2026-06-11 |
| D-4 | Kits scope | Seam only: format reserves `Type` values + `Component` columns; semantics are #15's | 2026-06-11 |
| D-5 | Import flow | Validate-all → preview with per-record drill-in diffs → confirm; sync now (~5k rows), async seam named | 2026-06-11 |
| D-6 | Error delivery | On-screen (preview + run detail) only; no email summaries | 2026-06-11 |
| D-7 | UI home | A Products page created by this design; #14 fills in the list | 2026-06-11 |
| D-8 | Variant identity | Option-value combination; SKU is correctable data (diverges from corpus 11-05-0035, §6.7) | 2026-06-11 |
| D-9 | Images | Fetch-and-host with renditions at fetch; post-commit background phase; **reject** low-res (placeholder) rather than accept-with-warning | 2026-06-11 |
| D-10 | Export shape | Full catalog + status filter; canonical format; hosted image URLs | 2026-06-11 |
| D-11 | Format learning | Sample CSV + column reference, app-served (DOC-2/3) | 2026-06-11 |
| D-12 | Re-import of images | Platform-hosted and already-fetched URLs are never re-fetched; fetch only new URLs + retries of failed/rejected (round-trip integrity, INV-12) | 2026-06-11 |
| D-13 | Storage & serving | Catalog in existing Cloud SQL (additive); images in a per-env private GCS bucket, app-served with immutable caching; CDN named as the #24 seam (§6.3.1) | 2026-06-11 |
| D-14 | Design artifact | Operator ran the Claude Design step (overriding the rubric's Code-directly score); hi-fi bundle committed at `ui/designs/SD-0002-…/` as SLICE-5's bootstrap artifact; prose (§5) remains the source of truth (§5.1) | 2026-06-11 |
| D-15 | Image resolution bar (resolves Q-3) | `rejected_low_res` when the **shorter side < 500 px** (`MIN_IMAGE_SHORT_SIDE` in `platform/images.py`, a tunable constant). Rejects icons/thumbnails while accepting normal product photos; consistent with the downscale-only rendition ladder (detail ≤1600 / card ~480 / thumb ~160 never upscale a ≥500 source). Pinned in the SLICE-7 plan (session ecomm-0034). | 2026-06-12 |

## 14. Glossary & References

- **Handle** — a product's stable identifier in the CSV and the catalog:
  lowercase letters, numbers, dashes (INV-13).
- **Variant** — one purchasable form of a product, identified by its
  option-value combination; every product has ≥ 1 (a no-option product has
  exactly one).
- **Option** — a named axis of variation ("Size"); names live on the
  product, values on the variant.
- **Canonical row model** — the internal representation every dialect maps
  to before validation (INV-17).
- **Dialect** — an accepted CSV header-shape: canonical or Shopify product
  CSV.
- **Draft vs run** — a *draft* is an unconfirmed, expiring preview (no
  trace if canceled); a *run* is the durable record of a confirmed import.
- **Rendition** — a derived image size (thumb / card / detail), generated at
  fetch, immutable.
- **Hosted URL** — an `Image Src` pointing at this platform's image-serving
  route; resolves to an existing image record, never fetched.
- **Upsert** — add-or-update by identity; this mechanism's only mutation.
- **Round-trip** — export → re-import of the unmodified file diffs to
  nothing (INV-12).
- **References:** anchor [#13](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/13) ·
  epic [#12](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/12) ·
  siblings [#14](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/14),
  [#15](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm/issues/15) ·
  [SD-0001](./SD-0001-mvp-sign-up-and-single-storefront.md) ·
  corpus [rfcs 11-05-0032–0037](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) ·
  Shopify product-CSV documentation (external; the dialect fixture is the
  binding artifact, §6.5.1) · handbook §§3.3, 4.3, 8.5, 9.
