Skip to content
Pages

Platform

Product pipeline

The fixed stages every product passes through when it is imported, created or corrected (validate, avatar, evaluate the details, refined avatar, review and watch), what each reads and writes, the stage interface, the statuses, and who owns what.

Updated 21 October 2026View as Markdown

Every product passes through the same stages, in the same order, whenever it is imported (store sync, CSV, feed), created or edited in the admin app, or corrected in the partner app. Each stage records what it read and what it decided, and is skipped next time when nothing it reads has changed. Every stage is safe to run again.

The code is in packages/core/src/server/pipeline/. The avatar function is packages/core/src/shared/avatar.ts.

The stages

# Stage (name) Reads Writes On failure
1 Validate (validate) Name, category, size, price, photo address, stored colour and colour name Issue codes, status ok, warn or blocked. The codes in the table below also go into products.issues Recorded as failed; the next stages still run
2 Avatar, first pass (avatar) Category, real size, look attributes, details (specs), what was corrected An avatar (below) Never fails: missing details give a plain avatar of the right kind and size
3 Evaluate the details (evaluate) Photo, name, category Look attributes and details with a confidence each, and a coherence verdict Planned, owned by another session. Today a slot that records noop
4 Avatar, refined (avatar_refined) What stage 2 reads, plus stage 3's output The avatar again, with confident photo readings merged in Same as stage 2
5 Review (review) The flags from stages 1 to 4 that need a staff decision open when there are flags, ok when there are none; staff set approved Stays in the queue
6 Watch Changed products, newer stage versions Re-runs only the stages that are out of date

The avatar runs twice because it depends on the details, but every product should have one at once. It is the same pure function both times.

Stage 1: validate

Version 1. Deterministic rules; nothing is fetched.

Code Severity When Data
no_dimensions block Width, depth or height missing. Already hides the product (isVisible()); not written by this stage, productIssues() adds it
no_price block Price missing, zero or not a number. A store sync never saves such a product (it skips it); one entered in the admin app is kept but flagged
size_implausible warn A measure outside the category's range in shared/sizes.ts (SIZE_RANGE), for example a "sofa" 69 cm wide or a dining table 8 cm high fields (the measures that are off), range
kind_mismatch warn The name says another category. Read with detectCategory() on the name alone, plus two rules it lacks: "tool" (chair) in a sofa's name when the size fits an armchair, and "öökapp" (bedside cabinet) filed as a side table suggested: the category to use
no_colour warn No colour from the store, or only the placeholder brown (#B5865A or #C49A62) with no colour name. The same code the rest of the platform already uses
no_photo warn No photo address
photo_invalid warn A photo address that is not an http or https address. The photo is not fetched here

"Blocked" is used only for these two hard failures; it hides nothing that was not hidden before. Warnings never hide a product. Validate owns the codes above in products.issues: it adds them and removes them when they stop applying, and leaves the codes other code writes there (the sync's notes, such as colour_from_name).

A wide, low chest of drawers ("Kummut Softoak", 129 × 39 × 84 cm) is often a sideboard, but 124 of the 173 real dressers in October 2026 had that shape: Estonian shops call sideboards "kummut" too. Size alone cannot tell them apart, so this is left to stage 3's coherence check.

Stages 2 and 4: the avatar

avatarFor(piece, { corrected, details }) returns an AvatarSpec:

Field What it is
kind The category, or a room piece's kind
builder Which 3D builder draws it: storage, table, chair, lounge, recliner, sofa, bed, plant, lamp, rug or box
size The size it is drawn and placed at, from visualSize(): a corner sofa's reach is the store's corner_reach when given, otherwise estimated from its length
look Colour, material, wood, tone, finish, style, handles, handle colour, upholstery
params What the builder needs: legs (after defaults), the front layout of storage (doors, drawers, open, or doors with drawers and where), rounded ends, a table's round top, base, lower shelf and drawer, an armchair's frame and back, a sofa's extension and corner side, the seat height
sources For every look field and parameter: correction, store, photo or default
approx True when a default changes the outline (no legs or fronts given) or the corner reach is estimated
builderVersion AVATAR_BUILDER_VERSION

Values are merged with this precedence: a correction from the store or staff > the store's own data > a photo reading at or above 0.7 confidence (PHOTO_CONFIDENCE) > a default.

The 3D room (buildPiece in core/client/room3d.ts) and product cards (toScenePiece in packages/ui/src/ProductCard.tsx) draw from the avatar; no builder reads raw attributes or picks its own defaults. A scene piece may carry a stored avatar; it is used when its builderVersion is current, and otherwise worked out again from the piece's data. Today the shop works the avatar out in the browser from the product's data, which gives the same result as stage 2. Drawing the stored stage 4 avatar in the shop is planned, for when stage 3 adds photo readings. Staff see the stored avatar on the review page and the product page.

Where details come from is one function (facts() in avatar.ts): look columns, products.specs and its corrections, and stage 3's readings. A new source of details plugs in there, without touching the builders.

Stage 3: evaluate the details (contract)

Planned; another session builds it. Until then the stage records noop with no output, and stage 4 equals stage 2.

It is given a function through PipelineDeps.evaluate({ name, category, imageUrl, product }) (PIPELINE_DEPS in run.ts), so tests can fake it and no network call happens in other stages. It must return EvaluatedDetails:

{
  fields: {                     // keys: look attributes (VOCAB keys, "colour" as #RRGGBB) or detail keys (shared/taxonomy.ts)
    [key: string]: { value: string | number | boolean; confidence: number }   // confidence 0–1
  },
  coherence: {                  // one call also answers "does the photo show what the name and category say?"
    matches: boolean | null,    // null: can't tell
    category: string | null,    // the category the photo shows, when it differs
    confidence: number,
    note: string | null         // short, for staff
  } | null
}
  • Values must be valid for the product's category: a vocabulary value, or a detail value parseSpecs() accepts. Anything else is ignored.
  • Readings below 0.7 confidence are kept in the record but not drawn; the stage notes low_confidence with the field names.
  • A confident matches: false notes photo_mismatch with the category and note.
  • The stage's input hash covers the name, category and photo address, so it re-runs only when one of them changes or its version is bumped.
  • It never writes products columns; corrections stay with stores and staff.

Stage 5: review

The admin app's Products → Review page (/products/review, permission products.manage) lists products whose review is open, oldest first. Each shows the store photo, the stored avatar drawn with productThumb, and the flags. Staff can:

  • Approve ("Looks right"): the review becomes approved and the product leaves the queue until its flags change.
  • Use the suggested category for kind_mismatch: saved as a correction (products.overrides.category), never in the synced column. The pipeline then runs again.
  • Edit the product in the admin form, whose changes to a synced product are corrections too.

Every decision is audited (review_approve, review_set_category). Only flags that need a decision open a review: size_implausible, kind_mismatch, no_price, photo_invalid, low_confidence, photo_mismatch and stage_failed (REVIEW_CODES in stages.ts). Missing data a store can add (no_colour, no_photo, no_dimensions) shows on the product and in the partner app instead: two in five real products had no colour. Example products are left out of the queue.

Stage 6: watch

The runner itself. A stage runs when it has no record, when its input hash changed, or when its version is newer than the record's; otherwise it is skipped. Because each stage's hash includes what the earlier stages decided, a change flows on to the stages that depend on it and no further. A forced run keeps an approved review while its flags are the same.

The stage interface

interface Stage<O> {
  name: StageName;                   // "validate" | "avatar" | "evaluate" | "avatar_refined" | "review"
  version: number;                   // bump to re-run it for every product
  inputHash(input, ctx): string;     // hash of everything the stage reads
  run(input, ctx): { output: O; issues: StageIssue[]; status: StageStatus } | Promise<…>;
}
  • input is { product, corrected, colourHex }: the product as the shop sees it (rowToProduct), the keys of its corrections (details as specs.<key>), and the colour as stored.
  • ctx is { earlier, stored, deps }: the records of the stages before it in this run, the stage's own last record, and the injected side effects.
  • A StageIssue is { code, severity: "warn" | "block", data? }.
  • Stages are pure where possible. Anything with side effects (network, Claude) comes in through deps.

Statuses

Status Meaning
ok Nothing to note
warn Issues that stay on the product
blocked A hard failure that already hides the product, or keeps it out of the shop
failed The stage threw. Its issue is stage_failed; the next stages still run and nothing is hidden
noop The stage is not built yet (stage 3)
open, approved Review only: waiting for staff, or decided

The runner and the record

runPipeline(productIds, { from?, only?, force?, deps? }) in run.ts runs the stages in order for each product. from starts at a stage, only runs the named stages, and force (true or a list of stage names) runs them even when nothing changed. It never throws to the caller; a product whose run breaks is counted as failed. runPipelineSafely() is the version for saves and syncs. Products are read and their records written 200 at a time, one statement per batch: on 10,000 products a first run took 30 seconds with a write per record and takes 6 seconds now (in-memory database; a database over the network gains more, since about 60,000 queries became about 150).

Each run is recorded in product_pipeline, one row per product and stage (migration 021_product_pipeline):

Column What it holds
product_id, stage The primary key
status One of the statuses above
input_hash, version What decides whether the stage runs again
output The stage's output: validate's codes, the avatar, stage 3's readings, the review's flags
issues The stage's issues, with their data
run_at When it last ran
decided_by, decided_at Review only: who approved it and when

Triggers

When What runs
A store sync saves products (runSync() in integrations/sync.ts) Every saved product, after the sync is recorded
An admin creates or edits a product That product, after the response is sent (Next's after()), so the save never waits
A store corrects a product in the partner app That product, after the response is sent
A staff member changes a category on the review page That product, before the response, so the page shows the result
The backfill job Every product; only out-of-date stages run

Pipeline errors never break a sync or a save: they are logged, and the product is saved as before.

The backfill job is POST /api/cron/pipeline in the partner app (GET also works), with Authorization: Bearer $CRON_SECRET, the same secret as the scheduled syncs. ?force=validate,avatar or ?force=all re-runs those stages for every product. A call goes through the products in id order and stops after ?seconds= (default 240, under a hosted function's time limit); its answer then has next, and ?after=<next> goes on from there. next is null when every product has been through. npm run pipeline:backfill calls it on a running partner app until it is done (-- --force <stages> passes force). Call it after every deploy and once a day.

Adding or changing a stage

  • A new rule in validate, or a change to a stage's output: change the stage and bump its version. The next backfill runs it for every product.
  • A change to the 3D builders that changes how existing products look: bump AVATAR_BUILDER_VERSION in shared/avatar.ts. It is the version of both avatar stages, and drawings cached under the old version are not reused.
  • A new stage: add it to STAGES in stages.ts at its place in the order, with a unit test in packages/core/tests/pipeline.test.ts, and a row on this page.

Who owns what

Part Owner State
Stage interface, runner, record, triggers, backfill job This pipeline Built
Stage 1, validate This pipeline Built
Stages 2 and 4, avatar, and the builders reading it This pipeline Built
Stage 3, evaluate the details (reading photos with Claude) Another session Planned; the slot and contract are built
Stage 5, review page This pipeline Built
The details' vocabulary Furniture details by category Built
Finding the same product sold by two stores Not decided Out of scope
Real 3D model files Not decided Out of scope