Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
86 KiB
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, §§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) 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
Handleform 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
Handleis new creates a product; a knownHandleupdates 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 SrcURLs 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).
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 §§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 —
Handleis 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_iddirectly 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.
DescriptionHTML 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
productsdomain (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 viaobjectstore, and updates per-image status + run progress. This is the async seam: it is the only fire-and-forget work in the app; when catalogs outgrow the in-process model, this task body moves onto a real queue without changing its inputs/outputs (§6.9 covers crash recovery). - REST BFF — gains the
/api/products/*endpoints (§6.4); still owns no business logic (SD-0001 INV-6). - Admin SPA — gains the Products section (§5); renders what the BFF returns; live progress is polling (no websockets in the MVP — deliberate).
6.3 Data model & ownership
All owned by the products domain; system of record PostgreSQL. Monetary
values are NUMERIC; no currency column — the MVP is single-implicit-currency
(open question Q-1, §13).
| Entity | Key fields | Notes |
|---|---|---|
product |
id, storefront_id (FK, INV-14), handle (unique per storefront, INV-13), title, description_html (sanitized, INV-15), vendor, product_type (standalone; kit_virtual / kit_assembled reserved for #15), google_product_category, tags (text[]), status (draft | active | archived), published (bool), option1_name…option3_name, timestamps |
Option names live on the product; a no-option product has all names NULL and exactly one variant. |
variant |
id, product_id (FK), position, option1_value…option3_value (unique combo per product, INV-13), sku, barcode, price, cost, weight + weight_unit, volume + volume_unit, tax_id_1, tax_id_2, inventory_tracker, inventory_qty, image_id (FK → product_image, nullable), timestamps |
The no-option product's single variant has NULL option values (uniqueness via NULLS NOT DISTINCT). SKU is indexed (component references, #15) but not unique — it is data, not identity. |
product_image |
id, product_id (FK), position, source_url, alt_text, status (pending | fetched | rejected_low_res | rejected_not_image | failed), failure_reason, storage keys (original, thumb, card, detail), import_run_id (FK — the run that introduced/last fetched it), fetched_at |
Identity within a product is source_url: re-importing the same URL is "unchanged" — never re-fetched. A platform-hosted URL (an export's Image Src) resolves to the existing image record by storage key — never fetched (same storefront required, else row error; INV-12/14). A fetch happens only for a genuinely new URL, or as retry of a failed/rejected one. INV-16 governs serving. |
import_draft |
id, storefront_id, account_id, file_name, dialect, file blob (objectstore key), diff summary + fingerprint, unknown_columns, expires_at (~1 h), created_at |
The preview's server side. Canceled or expired drafts are deleted outright — no trace (PUC-3a). Never appears in history. |
import_run |
id, storefront_id, account_id, file_name, dialect, counts (products_added, products_updated, rows_errored), status (applying | fetching_images | complete | complete_with_problems), created_at, completed_at |
Created only at confirm (PUC-4). The durable record (PUC-8). |
import_run_error |
id, run_id (FK), line_number, column_name, message |
One row per rejected CSV row (PUC-5); merchant-language message. |
Deliberately absent: any component / kit table — the CSV format
reserves the columns (§6.5 dialect spec), but the entities, validation, and
meaning are #15's design. In this MVP a non-standalone Type or a
non-empty Component n column is a row error ("kits arrive in a coming
release").
6.3.1 Storage: GCP resources, layout & serving
Catalog data — Cloud SQL for PostgreSQL, no new resource. The §6.3
tables are additive forward-only migrations in the existing per-environment
Cloud SQL instance (PPE: ecomm-ppe-pg; prod: its sibling when prod stands
up). At MVP scale (≤ 5k products × ~30 columns + run/error records) this is
tens of megabytes. Image bytes never enter Postgres (§6.7).
Image bytes — one GCS bucket per environment, project
wiggleverse-ecomm, same region as the VM, Standard class, uniform
bucket-level access, versioning off, no public access. Provisioning is a
flotilla/launch-app gesture (a provision-bucket sibling of
provision-datastore), run by the operator when the first slice needing it
reaches PPE (handbook §8.5); the bucket name reaches the app via deployment
config (SD-0001 INV-8). The VM's service account gets
roles/storage.objectAdmin scoped to this bucket only. Local dev and tests
use the objectstore port's local-disk adapter under the identical key
scheme — no GCS, no emulator.
Key layout — tenant-scoped, immutable, cache-friendly:
gs://wiggleverse-ecomm-<env>-media/
storefronts/{storefront_id}/product-images/{image_id}/original ← bytes as fetched
storefronts/{storefront_id}/product-images/{image_id}/thumb.webp ← ~160px
storefronts/{storefront_id}/product-images/{image_id}/card.webp ← ~480px
storefronts/{storefront_id}/product-images/{image_id}/detail.webp ← ≤1600px
import-drafts/{draft_id}/upload.csv ← preview blobs
- The
storefronts/{storefront_id}/prefix extends INV-14 into storage. - Keys are
image_id-addressed and written once, never mutated — a changed image is a new image id, so every object is immutable and serves withCache-Control: public, max-age=31536000, immutable. - Renditions normalize to WebP; the
originalkeeps its source format (provenance; renditions can be re-derived if the bar or sizes change). import-drafts/carries a GCS lifecycle rule: delete after 1 day — the deterministic safety net behind app-level draft deletion (PUC-3a), so a crash can never leak a merchant's uploaded file.
Serving — app-served with a CDN seam (decided, §6.7). Surfaces load
images from GET /api/products/images/{image_id}/{rendition} (§6.4): the
app authorizes (image → storefront, INV-14/16), streams from the bucket, and
sets the immutable cache headers. Exported Image Src URLs therefore live
on the app's own domain — they survive the market.wiggleverse.org
rebrand (#23) via ordinary redirects, and hosted-URL recognition (§6.3) is a
same-origin path parse. The seam: when shopper-facing storefronts (#24)
bring real image traffic, a CDN-fronted bucket (LB + Cloud CDN +
images.<domain>) takes over the same keys; only the URL builder and the
serving route change.
6.4 Interfaces & contracts
All under /api/products/*; session cookie + storefront membership required
(SD-0001's gates); errors share SD-0001's shape
{"error": {"code", "message"}}. Adding fields is free; renaming/removing is
a spec change.
| Endpoint | In | Out | Errors |
|---|---|---|---|
POST /api/products/imports |
multipart CSV file | 201 {draft} — dialect, summary counts, unknown_columns, expires_at (draft persisted server-side, INV-11) |
400 not_csv · 400 missing_required_column · 400 unknown_dialect · 400 too_many_rows · 413 file_too_large (all PUC-5a — no draft created) |
GET /api/products/imports/drafts/{id} |
— | 200 {draft} summary (re-fetch for the preview shell) |
404 · 410 draft_expired |
GET /api/products/imports/drafts/{id}/records |
?kind=add|update|unchanged|error, paging |
200 {records: [{handle, title, kind, variant_count, detail}]} — detail carries field-level before → after diffs (PUC-3) or the error rows |
404 · 410 draft_expired |
POST /api/products/imports/drafts/{id}/confirm |
— | 201 {run_id} — diff re-computed and fingerprint-checked, applied in one transaction (INV-11), image task started, draft deleted |
404 · 410 draft_expired · 409 preview_stale (catalog changed since validation — re-upload) · 409 nothing_to_apply (PUC-10) |
DELETE /api/products/imports/drafts/{id} |
— | 204 — draft and its blob deleted, no trace (PUC-3a) |
404 (idempotent enough) |
GET /api/products/imports/runs |
paging | 200 {runs: [...]} — history, newest first (PUC-8) |
— |
GET /api/products/imports/runs/{id} |
— | 200 {run} — counts, status, error rows, image outcomes + image_progress {done, total}; the SPA polls this while status != complete* (PUC-4/7) |
404 |
GET /api/products/export |
?status=all|active|draft|archived |
200 text/csv (streamed) — canonical format, hosted image URLs (PUC-9, INV-12) |
409 empty_catalog |
GET /api/products/sample.csv |
— | 200 text/csv — the documented sample (PUC-11) |
— |
GET /api/products/summary |
— | 200 {product_count, image_problem_count, latest_run_id} — drives the Products page header + notice band (§5.2) |
— |
GET /api/products/images/{image_id}/{rendition} |
rendition ∈ original|thumb|card|detail |
200 image bytes — storefront-authorized, streamed from the bucket, Cache-Control: immutable (§6.3.1, INV-16) |
404 (incl. wrong storefront) · 409 not_fetched (status ≠ fetched → SPA shows placeholder) |
6.5 Per–Product-Use-Case design
6.5.1 The canonical CSV format (the contract behind every PUC)
UTF-8 (BOM tolerated) · comma-delimited · RFC 4180 quoting · header row required · caps per INV-18.
Row grammar (Shopify-style): consecutive rows sharing a Handle form one
product. The product's first row carries the product-level fields (Title
required there); each row may carry a variant (its Option n 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 |
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)
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_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)
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.idis 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/imagesagainst 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(missingTitlecolumn — 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_imageswithpendingimages 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 →
failedwith reason; run completescomplete_with_problems; retry is a re-import (PUC-7) → no alert (expected operation). - Failure mode: object storage unavailable → behavior: affected
images
failedhonestly; 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_imagestatuses 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:
productsdomain + §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_applyno-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) andplatform/images; the per-environment media bucket — provisioned via the flotillaprovision-bucketgesture 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:
/healthzunchanged (process, DB, migrations — SD-0001). Image-task health is data, not a health endpoint: runs stuck infetching_imagesare 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-bucketflotilla 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 Srcpointing 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-0032–0037 · 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.