diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md
new file mode 100644
index 0000000..b13312b
--- /dev/null
+++ b/specs/SD-0002-products-bulk-csv-import-export.md
@@ -0,0 +1,1429 @@
+# 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 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.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--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.`) 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), 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 (minimum dimensions for `rejected_low_res`) — pinned in SLICE-7's implementation plan against real merchant fixtures. | SLICE-7 session | SLICE-7 only |
+
+**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 |
+
+## 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.
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail b/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail
new file mode 100644
index 0000000..57d5134
Binary files /dev/null and b/ui/designs/SD-0002-products-bulk-csv-import-export/.thumbnail differ
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html b/ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html
new file mode 100644
index 0000000..e3ad2f5
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/Products - Bulk CSV Import.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+Products — Bulk CSV Import / Export
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json
new file mode 100644
index 0000000..236d2d9
--- /dev/null
+++ b/ui/designs/SD-0002-products-bulk-csv-import-export/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8/_adherence.oxlintrc.json
@@ -0,0 +1,378 @@
+{
+ "plugins": [
+ "react",
+ "import"
+ ],
+ "rules": {
+ "react/forbid-elements": [
+ "warn",
+ {
+ "forbid": []
+ }
+ ],
+ "no-restricted-imports": [
+ "warn",
+ {
+ "patterns": [
+ {
+ "group": [
+ "components/brand/**",
+ "components/cards/**",
+ "components/core/**",
+ "components/navigation/**",
+ "ui_kits/wiggleverse-www/**"
+ ],
+ "message": "Import design-system components from 'index.js', not component internals."
+ }
+ ]
+ }
+ ],
+ "no-restricted-syntax": [
+ "warn",
+ {
+ "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
+ "message": "Raw hex color — use a design-system color token via var()."
+ },
+ {
+ "selector": "Literal[value=/\\b\\d+px\\b/]",
+ "message": "Raw px value — use a design-system spacing token via var()."
+ },
+ {
+ "selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Space Grotesk|Inter|Fraunces))/i]",
+ "message": "Font not provided by the design system. Available: Space Grotesk, Inter, Fraunces."
+ },
+ {
+ "selector": "JSXOpeningElement[name.name='BrandLockup'] > JSXAttribute > JSXIdentifier[name!=/^(?:variant|size|href|showWordmark|assetBase|key|ref|className|style|children)$/]",
+ "message": " doesn't accept that prop. Declared props: variant, size, href, showWordmark, assetBase."
+ },
+ {
+ "selector": "JSXOpeningElement[name.name='BrandLockup'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|footer|onLight)$/]",
+ "message": " variant must be one of 'primary' | 'footer' | 'onLight'."
+ },
+ {
+ "selector": "JSXOpeningElement[name.name='BuildCard'] > JSXAttribute > JSXIdentifier[name!=/^(?:tag|title|children|key|ref|className|style|children)$/]",
+ "message": " doesn't accept that prop. Declared props: tag, title, children."
+ },
+ {
+ "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|href|onLight|disabled|type|onClick|key|ref|className|style|children)$/]",
+ "message": "