# Milestone 3 Create ## Purpose Milestone 3 takes a Photos-ready device draft and makes the slab **submission-ready for WooCommerce**. Submission-ready means the mill owner has filled the full store listing package against **live synced Woo attributes**, with price set, and can create the product in the store (practice SKUs as Woo drafts). Photos and measures from Milestones 1 and 2 are inputs. They are not enough by themselves. **Exit criteria.** On the M3 UAT deploy, a Photos-ready slab reaches submission-ready and a `SLAB-UAT-*` product exists in WooCommerce as a **draft** whose categories, attributes, tags, price, and inventory images match the reviewed ready set. Create still works with inference off. ## How to read this file This file is the Milestone 3 source of truth on branch `m3-ds`. - **What is in M3** comes from Ty conversations on this branch (not from older milestone splits on `main`). - **How Woo sync, metadata, pricing, and inference behave** is taken from the technical requirements on `main` (`CONTENT-WOO`, `DATA-MODEL`, `UX`, `ARCHITECTURE`, `OPENAPI`, `AGENTS`, `UAT-PRACTICE`, `DEPLOYMENT`) and written here so coding does not need to open `main`. Related shipped specs: `docs/CAPTURE.md`, `docs/PHOTOS.md`. Product overview: `docs/PRODUCT.md`. Folder map: `docs/README.md`. ## Preconditions (already shipped) On this branch before M3 work: - Milestone 1 Capture works in the browser (`docs/CAPTURE.md`). - Milestone 2 Photos works in the browser (`docs/PHOTOS.md`). UAT evidence on `:10881`. - Device draft holds Photos-ready inventory PNGs, calibration measures, and related state. - `frontend/` holds that client path. `backend/` does not yet implement the Woo client or inference proxy. M3 adds the server path, Settings, taxonomy sync, review/ready fields, pricing, inference dependency, and Woo create. ## In scope 1. WooCommerce auth with WordPress application password (server-side). 2. Sync of **Woo attributes** from the store and correct use of those lists for the listing. 3. Review UI and rules to reach submission-ready (full ready set). 4. Pricing proposal from species `$/bdft` rules and user override. 5. OpenAI-compatible inference endpoint as a Settings dependency, with Call 1 / Call 2 behavior when enabled. 6. Create product in WooCommerce (force draft for `SLAB-UAT-*`), then clear the device draft on success. ## Out of scope unless Ty expands M3 in chat - Creating new Woo categories, attributes, terms, or tags from the app. - Updating an existing store SKU in place. - Bulk create. - Touching deploy port `:10880`. - Dumping work onto `m2-ds` or doc-simplification branches. --- ## Woo attributes Locked name for the synced taxonomy package. WooCommerce is the source of truth. The app caches ids. It does not invent the lists. ### Categories (product categories, leaf only) | Field | Cardinality | Rule | |---|---|---| | Species | exactly 1 | Synced species leaves only. No hardcoded or fallback species set. A new store species appears only after the next successful sync. | | Wood category | 1 or more | Leaf ids. Parents are grouping only. | ### Attributes (exactly five locked Woo attributes) | Attribute | Cardinality | Rule | |---|---|---| | Edge Type | 1 | Term id from sync. | | Figure | 1 or more | Term ids from sync. | | Grade | 1 or more | Term ids from sync. | | Thickness | 1 | Measured thickness rounded **up** into the store band (example: 1" lands in the store band labeled like `1"-1 1/2"` when that is the store label). | | Moisture | 1 | Defaults to kiln-dried. User sets or keeps it. Never inferred. | v1 does not create missing attributes or terms. If a term is missing in Woo, skip it. Do not invent it. Species, character, length, and free-text thickness are **not** Woo product attributes. Only the five locked attributes go on the create payload. ### Tags | Prefix | Cardinality | Rule | |---|---|---| | `fig-*` | 1 or more | Required. Other Woo tags are ignored at sync. | | `feat-*` | 0 or more | Optional. Character, inclusions, voids, and checks map onto existing `feat-*` tags only. | --- ## Auth and store URL From `main` deploy and agents rules, adopted here: - FastAPI is the only Woo caller. The browser never holds store credentials. - Auth is a WordPress **application password** for a dedicated low-privilege WP user (shop manager or custom role with product create/edit and media upload). Not WooCommerce consumer keys (`ck_` / `cs_`). Not the store admin login password. - Basic auth shape: `woo_wp_username` + `woo_app_password`. - Store URL is **`WOO_BASE_URL`** from `deploy/.env` / Docker Compose (`https://` only). Injected into the FastAPI container. Settings shows it read-only. Not a SQLite settings row. Change store means edit `.env` and recreate the FastAPI container. - Username and application password are entered in Settings UI, stored AES-GCM encrypted in SQLite (`SLAB_AES_KEY` env). - REST surface: `{WOO_BASE_URL}/wp-json/wc/v3/…` - Client: `httpx` with connect timeout 10s and read timeout 60s for image-heavy create. - Rotate: revoke the application password in WP Users → Profile, enter a new one in Settings, then run `test-woo`. --- ## Taxonomy sync Endpoint and behavior from `main` OPENAPI / CONTENT-WOO / ARCHITECTURE: `POST /api/v1/admin/taxonomy/sync` pulls, in order: 1. `GET /products/categories?per_page=100` (paginate) → species and wood-category leaves after app filter. 2. `GET /products/tags?per_page=100` → keep only `fig-*` and `feat-*`. 3. `GET /products/attributes` plus terms → the five locked attributes. Rebuild cache tables in one transaction. On failure return `502 woo_error` and **keep** the previous cache. Never delete-then-fail. **Stamps.** Woo REST has no usable last-modified for categories, attributes, or tags. FastAPI stores: - `taxonomy_last_sync_at` on every successful pull - `taxonomy_anchor` bumped only when the stored-subset hash changes (including deletions) Client re-pulls the full taxonomy only when the anchor advances. **Triggers.** 1. Successful `POST /api/v1/settings/test-woo` always resyncs. 2. On publish, if `now - taxonomy_last_sync_at > 1 hour`, reconcile (bump anchor only on real change). 3. Optional app launch or Settings refresh. If a picked id disappeared after refresh, show that field as no longer valid. Do not silently swap another value. `GET /api/v1/admin/taxonomy` returns the cached groups plus `taxonomy_anchor` and `taxonomy_last_sync_at`. --- ## Submission-ready (ready set) A slab is submission-ready only when **all** of the following are present. Species plus one figure is not enough. Required: - At least one inventory PNG (from Milestone 2) - SKU matching `[A-Z0-9-]{3,24}` (practice runs use `SLAB-UAT-*`) - Length and thickness (from capture, still editable on review) - Client-computed widths, sqft, and bdft (user typed number wins if overridden) - Exactly one species leaf - At least one wood-category leaf - One edge type term - At least one figure term - At least one grade term - Thickness band term (round-up from measured thickness) - One moisture term (default kiln-dried) - At least one `fig-*` tag - Price (see Pricing) Optional: - `feat-*` tags (zero or more) - Title, short title, description (may be typed, filled by templates at create, or written by Generate text when inference is on). Not required to reach ready. Inline per-field nudges show empty required fields as the owner works. The slab cannot reach ready until the full set is filled. Server status name for this state: `ready` (see data model below). Publish/create is blocked with `422 unpublishable` until then. --- ## Pricing From `main` DATA-MODEL / UX / AGENTS: - Settings (or admin API) holds `pricing_rules`: one flat `$/bdft` per species name that matches a synced species leaf. No tiers in v1. - Seed rule rows from synced species. The mill owner sets the rate per species. - Proposal: `price = bdft × price_per_bdft` for the selected species. - User may replace the proposal. Override sticks (`price_source` = `override` vs `recommendation`). - If no rule exists for the species, price starts empty and the owner types it. - Publish/create is allowed with a manual price. - `bdft = sqft × thickness_in` (deterministic). Admin API surface from `main` OPENAPI: - `GET /api/v1/admin/pricing` - `POST /api/v1/admin/pricing` - `PUT /api/v1/admin/pricing/{id}` - `DELETE /api/v1/admin/pricing/{id}` --- ## Inference M3 includes the OpenAI-compatible endpoint as a real server dependency and the Call 1 / Call 2 rules below. Create must pass with inference **off**. ### Settings (not compose) Stored encrypted in SQLite Settings (not `deploy/.env`): - `inference_base_url` - `inference_api_key` - `inference_model` - `inference_enabled` - `content_llm_enabled` FastAPI proxies all inference calls. The browser never holds the key. `POST /api/v1/settings/test-inference` returns `{ ok, mode: stateful|stateless|fail, vision_capable, detail }`. Mode `fail` blocks Call 2 enablement only. Inference-off create still works. ### Call 1 (vision / taxonomy assist) - Runs only when `inference_enabled` is true (auto after calibrated draft upload when on, or via `POST /api/v1/slabs/{id}/infer-taxon`). - Input: inventory photos downscaled to max 1024px, user metadata, synced Woo taxonomy lists. - Output: species, wood categories, edge, figure, grade, and feature-tag suggestions constrained to synced lists, each with confidence. - Default confidence threshold 0.7 (configurable). At or above threshold: pre-fill on review, still editable. Below: leave empty. Do not show low-confidence guesses as defaults. - Moisture is never inferred. - Typed values always win over model values. - Vision never blocks create and never auto-publishes. ### Call 2 (Generate text) - User-triggered only (`POST /api/v1/slabs/{id}/infer-content`). Never auto. - Requires successful `test-inference` (`ok: true`). - Model writes prose only (character, color, grain, uses). It must not invent dimensions. - Server assembles title, short title, and description from templates with prose injected. - If the endpoint is down, disable the button. Templates still work. Create is never blocked by Generate text failure. ### Templates (always available) Defaults from `main` CONTENT-WOO: **title** ``` {species} {length_ft}ft {length_in_remainder}in × {width_avg_in}in Wood Slab - {character} ``` **short_title** ``` {species} Slab - {character} ``` **description** (multi-paragraph) ``` A one-of-a-one {character} {species} slab, {length_ft}ft {length_in_remainder}in long with an average width of {width_avg_in}in and {thickness_in}in thickness ({bdft} bdft). Each slab is naturally shaped. Width varies along the length at {width_min_in}in to {width_max_in}in. Sold by the slab; dimensions as measured. {geo_sentence} ``` All template math fields come from the deterministic pipeline, never from the LLM. --- ## Review and create flow Mill-owner path after Photos-ready: 1. Upload calibrated / Photos-ready draft to the server (`POST /api/v1/slabs` multipart) when the M3 client reaches that step. Status becomes `calibrated`. If inference is on, Call 1 may run. 2. **Review.** Owner fills or overrides every ready-set field. Pickers show only synced Woo attributes. 3. When the ready set is complete, status becomes `ready` (submission-ready). 4. Owner taps create/publish → `POST /api/v1/slabs/{id}/publish`. 5. Server runs the create sequence below. Client polls `GET /api/v1/slabs/{id}`. ### Create sequence (server) 1. Check the listing against the ready-set rules. 2. Look up SKU in the store (`GET /products?sku={sku}`, any status). Stop if found. 3. Refresh taxonomy if last successful pull is older than 1 hour. 4. Resolve categories, tags, and attributes from the synced lists. 5. Upload each inventory PNG (`kind=inventory` only) via `POST /media`, keep alpha. Reference `source_url` values on the product. Cap at 5 inventory photos. Calibration-only artifacts are not store images. 6. `POST /products` with status from Settings `woo_create_status` (`draft` or `publish`, default `draft`). **`SLAB-UAT-*` always forces draft.** 7. On success: log, set `woo_product_id`, mark published locally, delete original and processed photos, clear device draft. 8. On failure: log, mark failed, keep photos, put a field-level error on the phone. No raw status codes for the mill owner. ### Create payload (shape) `POST /products` sends only non-null fields. Conceptual body: ```jsonc { "name": "{title}", "type": "simple", "status": "{woo_create_status}", "sku": "{sku}", "regular_price": "{price}", "description": "{description}", "short_description": "{short_title}", "categories": [ { "id": } ], "tags": [ { "id": } ], "images": [ { "src": "", "alt": "{species} {character} slab" } ], "attributes": [ /* Edge, Figure, Grade, Thickness, Moisture term ids when present */ ], "virtual": false, "downloadable": false, "catalog_visibility": "visible" } ``` App "published" means the store create succeeded. That is not the same as the Woo product being live when status is draft. ### Duplicate SKU Before create, if the SKU exists in any store status: block. Show Edit SKU and Open existing listing. This version does not update the other product. API: `409 duplicate_sku`. ### Stale taxonomy on submit If a category, attribute, or price is no longer valid after refresh: under that field, tell the owner to pick from the current list and supply current options (`stale_field`). --- ## Settings keys (M3) | Key | Where | Notes | |---|---|---| | `WOO_BASE_URL` | compose / `.env` | HTTPS store URL. Read-only in UI. | | `SLAB_AES_KEY` | compose / `.env` | AES master key. Never in DB or repo. | | `woo_wp_username` | Settings (encrypted) | Dedicated WP user. | | `woo_app_password` | Settings (encrypted) | Application password. | | `woo_create_status` | Settings | `draft` or `publish`. Default `draft`. | | `inference_base_url` | Settings (encrypted) | OpenAI-compatible base URL. | | `inference_api_key` | Settings (encrypted) | | | `inference_model` | Settings | | | `inference_enabled` | Settings | | | `content_llm_enabled` | Settings | Gates Generate text. | | `taxonomy_last_sync_at` | Settings / derived | | | `taxonomy_anchor` | Settings / derived | | | `brand_voice`, `geo_context` | Settings | Copy helpers. | Relevant Settings API: `GET/PUT /api/v1/settings`, `POST /api/v1/settings/test-woo`, `POST /api/v1/settings/test-inference`. --- ## Data shape (submission-ready fields) Core slab fields M3 must persist (from `main` DATA-MODEL, adopted here): | Field | Rule | |---|---| | `sku` | Required. Unique. | | `status` | `draft` → `calibrated` → `ready` → `publishing` → `published`, or `failed` | | `length_in`, `thickness_in` | Required. 1/8" step. | | `sqft`, `bdft`, widths | From client math. | | `species_id` | 1 species leaf. | | `wood_category_ids` | 1 or more. | | `edge_type_term_id` | 1. | | `figure_term_ids` | 1 or more. | | `grade_term_ids` | 1 or more. | | `thickness_term_id` | Round-up band. | | `moisture_term_id` | 1. Default kiln-dried. | | `fig_tag_ids` | 1 or more. | | `feat_tag_ids` | 0 or more. | | `price`, `price_per_bdft`, `price_source` | See Pricing. | | `title`, `short_title`, `description` | Optional for ready. | Cache tables: `woo_categories`, `woo_attributes`, `woo_attribute_terms`, `woo_tags`, `pricing_rules`. Device draft (IndexedDB on the phone) is not a WooCommerce draft. Clear the device draft after successful create. --- ## Practice / UAT rules - Every practice listing uses SKU prefix `SLAB-UAT-*`. - `SLAB-UAT-*` always creates a WooCommerce draft, even if Settings say publish. - Customers must not see practice listings on the storefront. - Inference is **off** for the claimable exit walk. Optional ON path only after inference Settings pass `test-inference`. - UAT deploy for this branch: ubudual5060 `http://100.64.0.9:10881`. Do not use `:10880`. --- ## API surface M3 must implement (summary) Base path `/api/v1` (health may also be `/api/health`). | Area | Methods | |---|---| | Health | `GET /api/health` | | Slabs | `POST/GET/PUT/DELETE /api/v1/slabs`, `GET /api/v1/slabs/{id}` | | Photos | original/processed GET while retained | | Inference | `POST .../infer-taxon`, `POST .../infer-content` | | Publish | `POST .../publish` → 202, poll slab | | Taxonomy | `GET/POST /api/v1/admin/taxonomy`, `.../sync` | | Pricing | CRUD `/api/v1/admin/pricing` | | Settings | `GET/PUT /api/v1/settings`, `test-woo`, `test-inference` | Full request and response field lists on historical `main` `docs/OPENAPI.md` are the detail reference if a field is missing here. Prefer extending this file over coding from `main` directly. --- ## Done when (exit criteria) **Product state induced.** Overnight UAT app on ubudual5060 `http://100.64.0.9:10881` (or the current M3 image of this branch), with `WOO_BASE_URL` and Woo application password pointed at the intended store. **Evidence from that state.** 1. `test-woo` succeeds and taxonomy sync fills Woo attributes into the app cache. Pickers offer only synced ids. 2. A Photos-ready slab can be filled to the **full ready set** (submission-ready) using those lists and pricing rules, with **inference off**. 3. Create with `SLAB-UAT-*` yields a WooCommerce **draft** whose categories, attributes, tags, price, and inventory images match the reviewed ready set. 4. Duplicate SKU is blocked. Device draft clears after success. 5. Inference Settings accept an OpenAI-compatible base URL, key, and model. `test-inference` runs. Create still succeeds with inference disabled. ## Dishonest proxies (do not count as pass) - Hardcoded or fixture taxonomy lists treated as synced. - Mock Woo client that never hits the real store for the exit walk. - Unit tests alone without a real `SLAB-UAT-*` draft in the store. - Consumer-key auth instead of application password. - Inference "wired" as dead config with no server proxy, or create that requires inference on. - Claiming submission-ready when required ready-set fields are still empty. - Touching `:10880` or parking work on `m2-ds` / doc-simplification. ## Open decisions (must lock before task breakdown) These came from comparing `CREATE.md` to the technical requirements on `main`. Do not invent Woo API shapes to fill them. 1. **`categories` on `POST /products`.** Ready set requires species plus wood-category leaf ids. The `main` CONTENT-WOO example only shows a species id in `categories[]`. Confirm: send **all** ready-set category ids (species + every wood category) as `[{"id": n}, ...]` per Woo REST product `categories`. 2. **`attributes` element shape on `POST /products`.** `main` leaves `"attributes": [ … if present … ]` without fields. Need the exact Woo REST object fields for the five locked attributes (typically attribute `id`, `options` as term names, `visible`, `variation`). Prefer a live product sample from Whidbey Wood Store or the official Woo REST product-attribute docs Ty points us at. Do not guess. 3. **Dimension-overlay PNG.** Photos-ready lineup includes the overlay copy. `main` says only `kind=inventory` images go to Woo (≤5). Confirm: **omit** the overlay from Woo upload, **include** it as an inventory image, or something else. 4. **Template `{character}`.** Default title/short/description templates need a `{character}` string. Confirm the source when `feat-*` is empty, one tag, or many (for example first feat tag name, figure term, or a dedicated field). 5. **Server persistence.** `main` OpenAPI assumes a full server slab row and status machine. This branch still centers the device draft. Confirm M3 server persistence: full slab store as on `main`, or a thinner path (Settings + taxonomy + pricing + create from an uploaded ready package) with device draft as the client source of truth until create. Non-blocking notes (shipped M1/M2 win on this branch): fill target 90% and 800px floor (not main's 80%/1600); Vite + Svelte SPA (not SvelteKit docs); overlay draw spacing may differ from measure spacing by design. If store-specific term labels differ from examples in this file, the synced store lists win.