# Architecture Status: SPEC · hybrid topology locked at Gate A freeze (2026-08-31) · SlabUploader Normative for hybrid split, swimlane, what-runs-where, secrets **why**, and status transitions. Ops depth lives in DEPLOYMENT. Schema enums live in DATA-MODEL. Agent-facing hard rules live in AGENTS.md (verbatim locks stay there). Companion: `docs/TECH-SPEC-PIPELINE.md`. Historical PRD: `docs/archive/PRD-2026-08.txt` (frozen; not a source for new work). Agent locks: `AGENTS.md`. ## 1. Topology (hybrid) Phone does the happy-path pipeline (mask knobs, length axis, sqft/bdft, 3:4 PNG). FastAPI stores drafts, proxies inference, talks to Woo. reverse proxy terminates HTTPS. Server U2Net (“Try harder”) is **deferred from POC**, not deleted. ``` phone (SvelteKit) capture, four knobs inline, live mask overlay on the source photo, length axis, sqft/bdft, 3:4 PNG (POC: retake if mask still bad — no U2Net control) server reverse proxy → frontend (static) → fastapi : store, settings, taxonomy cache, Woo, inference proxy, prompt files → U2Net endpoint reserved post-POC ``` - Happy path never leaves the phone. - Mask knobs live on capture; changing a knob re-runs client BG removal immediately. - Woo + LLM keys stay on the server. Browser does not call Woo or the vision endpoint directly. ## 2. Swimlane ```mermaid flowchart TB subgraph Phone["Phone — SvelteKit"] P1[New Wood: 1–5 photos] P2[BG overlay + sheet / sensitivity / edge / feather] P3{Edge OK?} P4[Length axis overlay — rotate / confirm] P5[User length + thickness + SKU] P6[sqft, bdft, 6in widths, 3:4 PNGs] P7[Review: Call 1 pre-fill or manual taxonomy] P8[Tap Generate text] P9[Review title / desc — publish] end subgraph API["server — FastAPI"] A1[U2Net reserved post-POC] A2[Store draft: originals + PNGs + numbers] A3[Call 1 proxy — vision] A4[Call 2 proxy — text] A5[Woo: taxonomy, SKU check, create product] end subgraph LLM["Your OpenAI-compatible endpoint"] L1[Wood expert — all photos at 1024] L2[Title / desc / short desc] end subgraph Woo["WooCommerce store"] W1[Categories / attributes / fig-* tags] W2[New product live or draft per setting] end P1 --> P2 --> P3 P3 -->|no — retune knobs or retake| P2 P3 -->|no — Retake| P1 P3 -->|yes| P4 --> P5 --> P6 P6 --> A2 A2 --> A3 --> L1 --> A3 --> P7 P7 --> P8 --> A4 --> L2 --> A4 --> P9 P9 --> A5 A5 --> W1 A5 --> W2 ``` ## 3. Clarifications 1. **Happy path** stays on the phone through PNG + numbers. FastAPI is idle until the user has a confirmed mask. 2. **Mask knobs** live on capture; each change re-runs client BG removal. Server U2Net “Try harder” is deferred from POC (keep the one-photo contract; do not ship the control). 3. **Draft upload** sends **originals + processed PNGs + length/thickness/SKU/sqft/bdft/widths**. Originals are needed if Call 1 or a later retry must not depend on the tab still being open. After successful Woo publish, server deletes both. 4. **Call 1** is server-side so the vision key never sits in the browser. Auto-run on slab create **only if** `inference_enabled` is true. If disabled, Call 1 is skipped; user can manually fill taxonomy and still trigger Call 2. Body: all originals downscaled to 1024 + **Woo-synced taxonomy only** + SKU/length/thickness. Timeout 45s → error + retry on the phone. ≥0.7 confidence **pre-populates** species, wood categories, edge/figure/grade, feat-* (mutable). Below 0.7 those fields stay **empty**. Manual entry stays authoritative. Presentation: `docs/UX.md`. 5. **Call 2** does not auto-fire. User taps Generate text. Server must send **portable Call 1 context** (default: full conversation resend). Stateful `previous_response_id` only after `test-inference` proves the provider supports it. Call 2 inputs distinguish confirmed user values from inferred ones. Per-slab thread; delete on submit or abandon. 6. **Woo** is only FastAPI. Browser never sees the WordPress application password. Duplicate SKU → 409 mapped on the SKU field (edit SKU or open existing). Stale value → 422 on that field. Draft stays on the phone. 7. **Settings** (species $/bdft, prompts, inference endpoint, Woo credentials, publish status) live on the server. Store URL is env, displayed read-only. ## 4. What runs where | Job | Where | |---|---| | Camera, 1-5 photos | Client | | Sheet detect, chroma-key / black threshold, four knobs, live re-run, flood-fill | Client | | U2Net “Try harder” | Server, **deferred from POC** | | Length axis overlay, 6" widths, sqft, bdft | Client | | 3:4 PNG, 80% fill (configurable aspect) | Client | | Draft + originals + processed PNGs | Server (after user continues) | | Species $/bdft, Woo creds, prompts, brand/GEO | Server | | Call 1 (vision, all photos @1024) | Server proxy | | Call 2 (title/desc) | Server proxy, after Call 1 curation | | Woo taxonomy sync + publish | Server | ## 5. Secrets and settings **Server-side only (never in browser JS):** 1. WordPress username + **application password** for a dedicated low-privilege WP user. Server uses HTTP Basic Auth (`username:application_password`) against `/wp-json/wc/v3/`. Stored encrypted at rest in SQLite (AES-GCM). Not Woo consumer keys. Not the account login password. 2. Inference endpoint base URL + API key. Stored encrypted. Server proxies all inference calls. 3. AES master key. Env var on server (`SLAB_AES_KEY`), never in the DB. 4. `WOO_BASE_URL` env (compose). HTTPS only. Not user-editable; Settings displays it read-only. **Browser-side (no secrets):** 1. User preferences: sheet mode, sensitivity, edge offset, feather. Persisted in localStorage, reset-to-default available. 2. Species list with $/bdft. Read from server settings sync. 3. Taxonomy snapshot (categories, attributes, tags). Read from server. No auth needed. **Flow:** 1. Deploy sets `WOO_BASE_URL` (`https://`) in compose. Operator creates a low-privilege WP user on that store (HTTPS required for Application Passwords UI). Generates an application password under Users → Profile. 2. User enters that username + application password in Settings UI (store URL shown read-only from env). 3. Browser POSTs them to `/api/v1/settings` on FastAPI. Server encrypts with AES-GCM and stores ciphertext only. 4. Server syncs taxonomy from Woo, caches in SQLite. 5. Client reads taxonomy from `/api/v1/admin/taxonomy` — no auth needed. 6. All Woo calls go server-to-Woo. Browser never calls Woo REST directly. Browser-to-Woo REST would need CORS on the store, which this design does not use. ## 6. Status machine ``` draft → calibrated → ready → publishing → published │ │ ↑ └→ failed ────┴────────────────┘ (woo_product_id set) any → failed (with error) ``` 1. **draft** — user took photos, no mask confirmed yet. 2. **calibrated** — user confirmed the BG edge, confirmed length axis, entered length/thickness/SKU. Client computed sqft/bdft/widths. Draft uploaded to server. 3. **ready** — SKU, length, thickness, client sqft/bdft/widths, ≥1 inventory PNG, exactly one species, ≥1 wood category, 1 edge type, ≥1 figure term, ≥1 fig-* tag, ≥1 grade, thickness store band (round-up), 1 moisture (default kiln-dried), price. feat-* 0+. Title/description may be typed, generated, or templated at publish. Values can come from Call 1 (≥ 0.7 pre-fill the user kept), manual entry, or a mix. **Review gate:** below-threshold Call 1 leaves species, wood category, figure, edge, grade, and feat-* empty. That subset is not the submit list. Inline nudges on every still-required control, not submit-only. Presentation: `docs/UX.md`. Inference is optional; with inference OFF the user fills everything by hand. 4. **publishing** — Woo create in flight. Poll for result. 5. **published** — Woo product created, woo_product_id stored. Images purged. 6. **failed** — pipeline, inference, or publish error. Error detail stored. Retry available. Call 1 and Call 2 are assist-only. They do not gate publish. Low-confidence Call 1 results do not auto-fill. If the user skips inference, the path is: calibrated → ready → publishing → published. ## 7. Taxonomy cache (manufactured last-modified) Woo REST exposes **no usable last-modified** on categories, attributes, or tags (only products carry `date_modified`). The client never talks to Woo. FastAPI is the sole freshness source. It persists two UTC stamps: `taxonomy_last_sync_at` (every successful Woo pull) and `taxonomy_anchor` (bumps only when the stored-subset hash changes, including deletions). - Anchor bumps **only** when a row-level change is detected in the **stored subset** of cache fields (whatever columns the cache tables actually keep: species/wood-category leaves, five attributes + terms, `fig-*`/`feat-*` tags). Scope is schema-driven: adding a cache column later automatically includes it. - Spelling/name edits, additions, and deletions all bump. Diff must catch missing rows (deletions), not only changed rows. - A no-op sync (stored subset unchanged) **does not** bump, so the client does not churn. - Client reads the anchor. If it advanced past the client's last value, re-pull the **full** taxonomy cache. One timestamp governs categories, attributes, and tags together. - Do not invent a Woo-side taxonomy timestamp or a per-table anchor. **Triggers** 1. `POST /api/v1/settings/test-woo` **always** resyncs taxonomy after a successful connection test (unlocks the species list). 2. Publish / product submit: opportunistic resync if the last successful sync is older than **1 hour**; bump anchor only if the stored subset changed. 3. Optional: app launch check (sync only if stale) and a Settings “Refresh taxonomy” control. Not required for POC. 4. Do **not** sync on every screen navigation. Species pickers and Call 1 option lists are **exactly** this cached set. No hardcoded, invented, or fallback species list. A new Woo species is selectable only after the next sync that lands it in the cache. ## 8. Shared validation module One FastAPI module is source of truth (`GET /api/v1/validation/module` → `{ hash, rules[] }`). Two consumers: review UI and Call 2 prompt builder. Client caches the module. - SKU charset: `[A-Z0-9-]{3,24}` (printable ASCII subset). Title / short_title / description: UTF-8 within Woo `maxLength`. Not “ASCII-only” and not “extended ASCII”. - Field-exit: local check against the cache. Empty optional is valid. - Length: `maxLength` is a hard input cap; server still enforces. - Submit carries `validation_hash`. Match → validate. Mismatch → HTTP **412** + fresh module; UI “Updating rules”; re-validate; retry. Never show 412. 409 is duplicate SKU only. Woo-shaped 422 is store rejection (#18). ## 9. Call 1 → Call 2 context Providers differ. Default portable path: **resend the full Call 1 turn** (prompt + images metadata + assistant JSON) with Call 2. Opt into `previous_response_id` only after probe. `POST /api/v1/settings/test-inference` two-turn probe records `stateful | stateless | fail`. **Fail blocks Call 2 enablement only** (Gate C inference-OFF still works). Never require `previous_response_id`. Do not ship a Call 2 that silently drops Call 1 context. Per-slab thread on the **server** (client does not hold provider IDs). Best-effort delete on publish / abandon / fail; log and continue. Presentation: `docs/UX.md`.