# App API contract — client ↔ FastAPI Status: SPEC · 2026-09-18. Base path `/api/v1`. Health at `/api/health`. All JSON unless noted. Timestamps UTC ISO-8601. The outbound WooCommerce payload is owned by `docs/contracts/WOO-API.md`. ## Requirements impact | Requirement | Impact on this design | |---|---| | APP-5 | slab status values and transitions | | APP-20 | client ids, UTC timestamps, `client_rev`/`server_rev` | | APP-25 | FastAPI is the only Woo and inference caller | | APP-30 | `/api/v1` base path, `/api/health` | | APP-35 | error envelope, full lists, JSON | | APP-40 | server stores client numbers, never recomputes | | APP-45 | health body and non-200 condition | | APP-50 | upstream Woo 4xx mapped into the envelope | | CAPTURE-15, CAPTURE-165 | offline and U2Net are out of scope | | CAPTURE-25, CAPTURE-30, CAPTURE-115, CAPTURE-130 | calibration choice and batch photos | | CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | length/thickness units and derived numbers | | CAPTURE-95, CAPTURE-105, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | PNG output, upload, preview/controls | | METADATA-5, METADATA-10, METADATA-15, METADATA-30 | taxonomy cardinality and scoping | | METADATA-35, METADATA-40, METADATA-45, METADATA-50, METADATA-55 | Call 1 / Call 2 | | METADATA-60, METADATA-65, METADATA-70, METADATA-75, METADATA-100, METADATA-105, METADATA-110, METADATA-115 | context, constraints, fallback, prompts, failure | | OPS-55 | no client auth | | PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20 | create sequence, draft rule, duplicate, stale | | PUBLISH-25, PUBLISH-30, PUBLISH-31, PUBLISH-35, PUBLISH-40, PUBLISH-45, PUBLISH-50, PUBLISH-55, PUBLISH-60 | create outcomes, images, payload | | REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, fields, limits | | SETTINGS-5, SETTINGS-10, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30 | env URL, credentials, crypto | | SETTINGS-35, SETTINGS-40, SETTINGS-45, SETTINGS-50, SETTINGS-51, SETTINGS-55, SETTINGS-60, SETTINGS-65, SETTINGS-75, SETTINGS-80, SETTINGS-85, SETTINGS-90 | rates, tests, taxonomy, flags | ## 1. Conventions - **Auth:** no client auth; the app relies on the trusted LAN/Tailscale network plus HTTPS. - **Envelope:** every error is `{ "error": { "code": string, "message": string, "detail": object|null } }`. - **Lists:** returned in full, no pagination. - **Revisions:** create/update carry `client_rev`; the server echoes `server_rev`; a mismatch is `409 revision_conflict`. A background inference write bumps `server_rev`; the client adopts the polled `server_rev` before its next write. - **Async work:** inference and publish run through one in-process job queue with a single consumer; a status transition is compare-and-set inside one write transaction, so a poll never observes a half-applied transition. Tests run the queue inline so contract tests are deterministic. - **IDs and time:** slab ids are client-generated UUIDv4; timestamps are UTC ISO-8601. - **Photos:** `multipart/form-data` with `data` (JSON), `files[]` (original images), and `meta[]` (`{ "seq": n }`). One photo is the calibration photo, chosen by the owner. - **Secrets:** never returned; only a configured boolean. - **Error display:** raw status codes, stack traces, and internal codes never reach the owner. ## 2. Error codes | Status | Code | Meaning | |---|---|---| | 400 | `validation` | payload failed validation | | 404 | `not_found` | unknown slab, photo, or setting | | 409 | `revision_conflict` | `client_rev`/`server_rev` mismatch; `detail.current_server_rev` | | 409 | `duplicate_sku` | SKU exists in Woo under any status | | 422 | `unpublishable` | slab not publishable or missing a required field | | 422 | `stale_field` | a picked taxonomy id vanished; returns current options | | 502 | `woo_error` | WooCommerce rejected the request; `detail` carries the Woo body | | 500 | `internal` | unexpected | There is no `412 validation_module_stale` path. ## 3. Health and meta - `GET /api/health` → `{ "status": "ok", "version": string, "woo_reachable": bool }`. Non-200 when the database or image volume is not writable. - `GET /api/v1/meta` → `{ "version": string }`. ## 4. Slabs ### `POST /api/v1/slabs` Multipart create of a calibrated draft. Returns `201` with the slab at `status: "calibrated"`. Stores the client-sent derived numbers and never recomputes them. Auto-runs Call 1 only when `inference_enabled` is true; inferred values persist only at confidence ≥ 0.7. `409 duplicate_sku` if the SKU already exists. `SlabCreate` fields: | Field | Type | Notes | |---|---|---| | `id` | uuid | client-generated | | `sku` | string | 3–24, alphanumeric and hyphen | | `length_in`, `thickness_in` | number | 1/8 inch steps | | `sqft`, `bdft`, `width_min_in`, `width_max_in`, `width_avg_in` | number | client-computed | | `species_id` | int | one species leaf | | `wood_category_ids` | int[] | one or more | | `edge_type_term_id` | int | one | | `figure_term_ids` | int[] | one or more | | `grade_term_id` | int | exactly one | | `thickness_term_id` | int | round-up band | | `moisture_term_id` | int | default kiln-dried | | `fig_tag_ids` | int[] | optional | | `feat_tag_ids` | int[] | optional | | `price` | number | 2 decimals | | `price_source` | string | `recommendation` \| `override` | | `title`, `short_description`, `description` | string\|null | prose | | `content_source` | string | `manual` \| `llm` \| `template` | | `client_rev` | int | optimistic concurrency | Grade, edge, thickness, and moisture are singular; figure and the tag lists are arrays. ### `GET /api/v1/slabs` Returns `[Slab]`, most recent first. ### `GET /api/v1/slabs/{id}` Returns the `Slab`. Also the poll endpoint for inference and publish. The client backs off (1s, 2s, 4s, cap 10s). On publish success the terminal response carries `woo_product_id`; the working row is then discarded, so a later GET may return `404`, which the client treats as completion. ### `PUT /api/v1/slabs/{id}` Partial update of editable fields plus `client_rev`. Stores client-sent derived fields and does not recompute them. Checks run in order: `409 revision_conflict`, then `409 duplicate_sku` (SKU field actions `edit_sku` / `open_existing`), then `422 stale_field` (current options included). `status` becomes `ready` only when the full required set is present; it is server-derived and a client never sets it. ### `DELETE /api/v1/slabs/{id}` Soft delete of unpublished drafts only (sets `deleted_at`); `204`. Deleted rows are excluded from all GET responses. Published slabs cannot be deleted from the app. ## 5. Photos - `GET /api/v1/slabs/{id}/photos/{pid}/original` → `image/*` while retained. - `GET /api/v1/slabs/{id}/photos/{pid}/processed` → `image/*` (transparent PNG) while retained. - After publish both return `410 Gone`. - `POST /api/v1/slabs/{id}/u2net/{pid}` is reserved post-POC and is not exposed. ## 6. Inference ### `POST /api/v1/slabs/{id}/infer-taxon` Runs or retries Call 1. Sends the clean processed photos at 1024 px long edge or less, the synced taxonomy, and the user metadata. Returns `202`; the client polls. A failure or timeout follows the runtime failure rule (recoverable offers Retry, unrecoverable explains). ### `POST /api/v1/slabs/{id}/infer-content` Runs Call 2 on user action only. Fresh request carrying the full Call 1 context; no provider thread id. Requires `test-inference` `ok`. Returns `202`; the client polls. The model writes prose only and never touches dimensions. Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`, `edge_type_term_id`, `figure_term_ids`, `figure_confidence`, `grade_term_id`, `grade_confidence`, `character`, `inclusions`, `voids`, `checks`. Free-text observations map to synced `feat-*` tags only. Call 2 result fields: `title`, `short_description`, `description`. ## 7. Publish ### `POST /api/v1/slabs/{id}/publish` Preconditions are the full ready set, else `422 unpublishable` with field keys. Sequence: validate the ready set; look up the SKU and stop if present; refresh taxonomy when the last sync is over one hour old; resolve categories, tags, and attributes; upload the processed PNGs to Woo media; `POST /products` per `docs/contracts/WOO-API.md`; finalize. `SLAB-UAT-*` always creates a draft. Returns `202`; the client polls. - Success: `woo_product_id` set and returned on the terminal poll, photos purged, the local working slab row discarded, device draft cleared. Nothing is deleted from WooCommerce. - Failure: attempt marked failed, photos retained, `sync_log` written, plain message on the affected field or action, retry from the same screen. - Partial failure (media uploaded, product POST failed): orphaned media left, logged, manual cleanup note surfaced. ## 8. Taxonomy - `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus `taxonomy_anchor` and `taxonomy_last_sync_at`. Only fig-* and feat-* tag families are kept. These lists are the only legal picker options. - `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets `taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and on failure returns `502` while keeping the previous cache. ## 9. Pricing - `GET /api/v1/admin/pricing` → `[PriceRule]` - `POST /api/v1/admin/pricing` → `201` - `PUT /api/v1/admin/pricing/{id}` → `200` - `DELETE /api/v1/admin/pricing/{id}` → `204` One flat `price_per_bdft` per species leaf, no tiers. ## 10. Settings - `GET /api/v1/settings` → `SettingsView` with secrets masked. - `PUT /api/v1/settings` — partial update. `woo_base_url` is not writable; the server rejects the write. Woo credentials are write-only and stored AES-GCM. Inference base URL and key are encrypted; flags, brand voice, and geo context are settings. Secrets are never echoed. - `POST /api/v1/settings/test-woo` → `{ "ok": bool, "detail": string, "taxonomy_anchor": string, "taxonomy_last_sync_at": string }`. Fails closed on missing credentials or non-HTTPS; an auth rejection is distinct from an unreachable store; on success always resyncs. - `POST /api/v1/settings/test-inference` → `{ "ok": bool, "mode": "stateful"|"stateless"|"fail", "vision_capable": bool, "detail": string }`. `fail` blocks Call 2 only. ## 11. Removed from v1 - The shared validation module and its `412` path. - Provider thread ids and `previous_response_id` (Call 2 is a fresh full-context call). - The U2Net control; the endpoint is reserved only. - Offline/IndexedDB sync.