Files
wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md
T

86 KiB
Raw Blame History

status
status
graduated

Solution Design: Products — bulk CSV import/export

Anchor wiggleverse/wiggleverse-ecomm#13 (type/feature, priority/P1) — parent epic #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) · research/shopify/products · Prototype: wiggleverse-ecomm-prototype · Reference: Shopify product CSV · Builds on: SD-0001 · 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, §§114, 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) 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) 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, list + read-only detail) and extend it to bundled offerings (#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.

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.

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.

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.

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.

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-00320037). 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)

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)

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)

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

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)

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

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)

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)

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)

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)

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)

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/ 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 §§45, 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

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_nameoption3_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_valueoption3_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 PerProduct-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 Values + 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
Option13 Name product with values e.g. "Size"; a value without its name is a row error
Option13 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 12 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 110 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 CategoryGoogle Product Category, Cost per itemVariant 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)

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)

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_urlpending 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)

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 ≈ 1015 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 (58)
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 68

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-58 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 barRESOLVED 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 · epic #12 · siblings #14, #15 · SD-0001 · corpus rfcs 11-05-00320037 · research/shopify/products · Shopify product-CSV documentation (external; the dialect fixture is the binding artifact, §6.5.1) · handbook §§3.3, 4.3, 8.5, 9.