docs: lock revised capture pipeline and hybrid architecture

Rewrite PRD, tech spec, data model, and OpenAPI around the 2026-08-27
decisions: client-side mask/measure/crop, U2Net on demand, Woo as product
source of truth, draft-only local state. Architecture records topology C
through the status machine. Drop the old CONTENT-WOO spec until the
Call 2 prompt review finishes.
This commit is contained in:
Ty
2026-08-27 19:26:45 -07:00
parent bee3bcc278
commit 4e97770373
6 changed files with 962 additions and 1022 deletions
+212 -158
View File
@@ -1,166 +1,220 @@
# Data Model (SQLite, v1)
# Data Model
Status: SPEC · 2026-08-24 · SlabUploader
Storage: single SQLite file (volume `slab_db`). Schema kept portable to Postgres
(DATETIME→TIMESTAMPTZ, TEXT blobs→bytea/objects). All math values stored full
precision (float64); display rounding is a presentation concern (TECH-SPEC §2).
Status: SPEC · 2026-08-27 · SlabUploader
Storage: single SQLite file. Schema portable to Postgres.
## Conventions
- IDs: `TEXT` UUIDv4 (client-generated for slabs so the PWA can reference a slab
before it exists server-side).
- Timestamps: UTC ISO-8601 `TEXT` (`YYYY-MM-DDTHH:MM:SSZ`).
- Soft state via `status` column; no hard deletes for slabs (audit). Images purged
on publish (Decision 12) but the slab row + metadata retained.
- Money stored as float64 USD, rounded half-up to cents at write (TECH-SPEC §2).
- `client_rev` / `server_rev`: monotonic ints for optimistic concurrency (ARCH §3.2).
Conventions:
- IDs: client-generated UUIDv4 for slabs so the PWA can reference a slab before it exists server-side.
- Timestamps: UTC ISO-8601 TEXT.
- Soft state via status column; no hard deletes for slabs (audit).
- Money: float64 USD, rounded half-up to cents at write.
- All rounded values are what gets stored. No full-precision internal storage.
- Inches stored as 1/8" increments. Sqft/bdft to 2 decimals.
- Multi-select fields stored as JSON arrays of Woo IDs.
---
## slabs
Draft-only. Deleted on successful publish. WooCommerce is the source of truth for product data.
| Column | Type | Notes |
|---|---|---|
| `id` | TEXT PK | client-generated UUID |
| `sku` | TEXT UNIQUE NOT NULL | 3-24 [A-Z0-9-] |
| `status` | TEXT NOT NULL | draft \| calibrated \| ready \| publishing \| published \| failed |
| `length_in` | REAL NOT NULL | 1/8" step |
| `thickness_in` | REAL NOT NULL | 1/8" step |
| `sqft` | REAL | 2 decimals |
| `bdft` | REAL | 2 decimals |
| `width_min_in` | REAL | min width at 6" stations, 2 decimals |
| `width_max_in` | REAL | max width at 6" stations, 2 decimals |
| `width_avg_in` | REAL | avg width at 6" stations, 2 decimals |
| `species_id` | INTEGER | → woo_categories (leaf only, 1 required) |
| `wood_category_ids` | INTEGER[] JSON | → woo_categories (leaf, 1+) |
| `edge_type_term_id` | INTEGER | → woo_attribute_terms (1 required) |
| `figure_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
| `grade_term_ids` | INTEGER[] JSON | → woo_attribute_terms (1+) |
| `thickness_term_id` | INTEGER | → woo_attribute_terms (round-up) |
| `moisture_term_id` | INTEGER | → woo_attribute_terms (1 required, defaults to kiln-dried term; user overrides; not inferred) |
| `fig_tag_ids` | INTEGER[] JSON | → woo_tags (fig-*, 1+) |
| `price_per_bdft` | REAL | from pricing_rules for species |
| `price` | REAL NOT NULL | 2 decimals |
| `price_source` | TEXT | 'recommendation' \| 'override' |
| `title` | TEXT | |
| `short_title` | TEXT | |
| `description` | TEXT | |
| `content_source` | TEXT | 'manual' \| 'llm' |
| `client_rev` | INTEGER NOT NULL DEFAULT 1 | |
| `server_rev` | INTEGER NOT NULL DEFAULT 1 | |
| `created_at` | TEXT NOT NULL | |
| `updated_at` | TEXT NOT NULL | |
Indexes: `sku` (unique), `status`, `updated_at`.
### Status state machine
## ERD (text)
```
slabs 1─N slab_photos
slabs 1─0..1 sync_log (latest) [sync_log is an append-only history; 1 per publish attempt]
pricing_rules N─1 (species, character) [unique pair]
woo_taxonomy (cache) categories / tags / attributes
settings 1 row (key→encrypted value)
```
## Tables
### slabs
| col | type | notes |
|---|---|---|
| id | TEXT PK | client-generated UUID |
| sku | TEXT UNIQUE NOT NULL | from OCR or manual; dedupe key (FR24a) |
| status | TEXT NOT NULL | `draft` `processing` `ready` `review` `publishing` `published` `failed` |
| species | TEXT | controlled value (from woo_taxonomy categories) or free until confirmed |
| species_confidence | REAL NULL | from inference, if used |
| species_source | TEXT | `inference` `manual` |
| character | TEXT | controlled value |
| character_confidence | REAL NULL | |
| character_source | TEXT | `inference` `manual` |
| length_in | REAL NOT NULL | authoritative (ruler-derived or manual) |
| length_source | TEXT | `ruler` `manual` |
| width_avg_in | REAL | average of sampled widths (TECH-SPEC §5.2) |
| width_source | TEXT | `sampled` `manual` |
| thickness_in | REAL NOT NULL | always manual (FR8) |
| sqft | REAL | (L×w_avg)/144 |
| bdft | REAL | (L×w_avg×t)/12 (Decision 18) |
| price_per_bdft | REAL NULL | copied from pricing rule at compute time (snapshot) |
| price | REAL NOT NULL | final authoritative price (override or recommendation) |
| price_source | TEXT | `recommendation` `override` |
| title | TEXT | generated/edited |
| short_title | TEXT | |
| description | TEXT | |
| content_source | TEXT | `template` `llm` `manual` |
| normalized_fill_ok | INTEGER | 0/1 — met ~80% fill target (TECH-SPEC TV-6) |
| woo_product_id | INTEGER NULL | set on successful publish (FR28) |
| published_at | TEXT NULL | |
| client_rev | INTEGER NOT NULL DEFAULT 1 | |
| server_rev | INTEGER NOT NULL DEFAULT 1 | |
| created_at | TEXT NOT NULL | |
| updated_at | TEXT NOT NULL | |
Index: `sku` (unique), `status`, `updated_at`.
### slab_photos
| col | type | notes |
|---|---|---|
| id | TEXT PK | |
| slab_id | TEXT FK→slabs.id ON DELETE CASCADE | |
| kind | TEXT NOT NULL | `calibration_sku` `calibration_ruler` `inventory` |
| role | TEXT | for inventory: `topdown` (the width-sampling photo) or `extra` |
| seq | INTEGER | display order (FR23 reorder) |
| original_path | TEXT | relative to images volume |
| normalized_path | TEXT NULL | set after normalization |
| is_customer_facing | INTEGER | calibration=0, inventory=1 (FR2: calibration never uploaded) |
| created_at | TEXT | |
Rule: only `kind=inventory` photos are uploaded to WooCommerce. Calibration photos
are internal-only (FR2).
### pricing_rules
| col | type | notes |
|---|---|---|
| id | TEXT PK | uuid (PRD pricing schema) |
| species | TEXT NOT NULL | |
| character | TEXT NOT NULL | |
| price_per_bdft | REAL NOT NULL | flat (Decision 5; no tiers) |
| created_at | TEXT | |
| updated_at | TEXT | |
`UNIQUE (species, character)`. Seeded from synced Woo categories (Decision 4);
user sets `price_per_bdft` per value in Settings (FR13).
### woo_taxonomy (cache)
| col | type | notes |
|---|---|---|
| kind | TEXT | `category` `tag` `attribute` |
| woo_id | INTEGER | Woo term id |
| name | TEXT | |
| parent_id | INTEGER NULL | categories |
| slug | TEXT | |
| synced_at | TEXT | |
`UNIQUE (kind, woo_id)`. Rebuilt by `POST /api/admin/taxonomy/sync` (FR25). This is
the controlled list for species (Decision 4 — species drawn from categories) and
the taxonomy auto-assign source (FR26).
### sync_log (append-only history)
| col | type | notes |
|---|---|---|
| id | INTEGER PK AUTOINCREMENT | |
| slab_id | TEXT FK | |
| attempt | INTEGER | 1,2,… per slab |
| action | TEXT | `create` |
| woo_product_id | INTEGER NULL | |
| status | TEXT | `success` `failed` `blocked_duplicate_sku` |
| http_status | INTEGER NULL | |
| error | TEXT NULL | machine-readable code |
| detail | TEXT NULL | human-readable message / Woo error body |
| payload_hash | TEXT | sha256 of the Woo product payload sent (audit, FR28) |
| created_at | TEXT | |
FR28: log sync results, surface errors, store Woo product ID on success.
### settings (key/value, encrypted values)
| col | type | notes |
|---|---|---|
| key | TEXT PK | |
| value_enc | BLOB | AES-GCM ciphertext (see Security) |
| updated_at | TEXT | |
Keys:
- `woo_base_url` (plaintext-OK, but encrypted for uniformity)
- `woo_consumer_key`, `woo_consumer_secret` (secret)
- `inference_base_url`, `inference_api_key` (secret), `inference_model`
- `inference_enabled` (bool), `content_llm_enabled` (bool)
- `brand_voice` (text), `geo_context` (text)
- `content_templates` (JSON: title/short_title/description templates, FR16)
- `aspect_ratio` = `1:1`, `output_px` = 1600, `output_px_min` = 1000 (Decision 20)
- `fill_target` = 0.80
The AES-GCM master key is an env var (`SLAB_AES_KEY`, 32 bytes base64) injected by
compose — never stored in the DB. Each `value_enc` = `nonce(12) || ciphertext`.
## Status state machine
```
draft → processing → ready → review → publishing → published
│ ↑ (woo_product_id set)
└→ failed (retry → publishing)
draft → calibrated → ready → publishing → published
│ │ ↑
└→ failed ────┴────────────────┘ (woo_product_id in sync_log)
any → failed (with error)
```
- `processing`: server pipeline running.
- `ready`: enriched, awaiting user review.
- `review`: user has opened/edited the review screen.
- `publishing`: Woo create in flight.
- `published`: Woo product created (draft/pending on the store — never live without
human action, per test-target decision).
- `failed`: pipeline or publish error; `sync_log.detail` has the reason.
## Concurrency
- Single owner. Optimistic locking via `server_rev`: an update must send the `server_rev`
it last read; server rejects on mismatch (409) → client re-fetches. This is the
server-side counterpart to the client `client_rev` (ARCH §3.2).
1. **draft** — photos taken, no mask confirmed.
2. **calibrated** — BG edge confirmed, length axis confirmed, length/thickness/SKU entered. Client computed sqft/bdft/widths. Draft uploaded to server.
3. **ready** — all mandatory fields populated. Values from Call 1, manual, or mix. Inference is optional. Publishable.
4. **publishing** — Woo create in flight. Poll for result.
5. **published** — Woo product created. This row + all photos deleted. sync_log retained.
6. **failed** — pipeline, inference, or publish error. Error detail stored. Retry available.
## Backup
- SQLite file + images volume are the only durable state. Backup = `sqlite3 .backup`
of the DB file (WAL-safe) + `tar` of the images volume. See DEPLOYMENT.md.
---
## slab_photos
| Column | Type | Notes |
|---|---|---|
| `id` | TEXT PK | |
| `slab_id` | TEXT FK → slabs.id ON DELETE CASCADE | |
| `kind` | TEXT NOT NULL | 'inventory' |
| `role` | TEXT | 'topdown' \| 'extra' |
| `seq` | INTEGER | display order |
| `original_path` | TEXT | relative to images volume |
| `processed_path` | TEXT | relative to images volume |
| `created_at` | TEXT | |
Rule: only inventory photos. Calibration photos do not exist in this design.
---
## pricing_rules
| Column | Type | Notes |
|---|---|---|
| `id` | TEXT PK UUID | |
| `species` | TEXT NOT NULL | matches woo_categories.name (leaf) |
| `price_per_bdft` | REAL NOT NULL | flat, no tiers |
| `created_at` | TEXT | |
| `updated_at` | TEXT | |
`UNIQUE(species)`. Seeded from synced Woo categories. User sets price per species.
---
## woo_categories
| Column | Type | Notes |
|---|---|---|
| `woo_id` | INTEGER PK | |
| `name` | TEXT | |
| `slug` | TEXT | |
| `parent_id` | INTEGER | NULL for root |
| `synced_at` | TEXT | |
Only species + wood category. Sync pulls all Woo categories; the app filters to the two we use.
`UNIQUE(woo_id)`.
---
## woo_attributes
| Column | Type | Notes |
|---|---|---|
| `woo_id` | INTEGER PK | |
| `name` | TEXT | |
| `slug` | TEXT | |
| `type` | TEXT | 'select' |
| `synced_at` | TEXT | |
Only the five first-class attributes: Edge Type, Figure, Grade, Thickness, Moisture.
`UNIQUE(woo_id)`.
---
## woo_attribute_terms
| Column | Type | Notes |
|---|---|---|
| `woo_id` | INTEGER PK | |
| `attribute_id` | INTEGER FK → woo_attributes.woo_id | |
| `name` | TEXT | |
| `slug` | TEXT | |
| `synced_at` | TEXT | |
The actual selectable values. E.g., "Cathedral" under Figure, "1\"–1 1/2\"" under Thickness.
`UNIQUE(woo_id)`.
---
## woo_tags
| Column | Type | Notes |
|---|---|---|
| `woo_id` | INTEGER PK | |
| `name` | TEXT | |
| `slug` | TEXT | |
| `synced_at` | TEXT | |
Only `fig-*` tags. Other Woo tags ignored.
`UNIQUE(woo_id)`.
---
## settings
| Column | Type | Notes |
|---|---|---|
| `key` | TEXT PK | |
| `value_enc` | BLOB | AES-GCM ciphertext |
| `updated_at` | TEXT | |
Keys:
- `woo_base_url`, `woo_consumer_key`, `woo_consumer_secret`
- `inference_base_url`, `inference_api_key`, `inference_model`
- `inference_enabled`, `content_llm_enabled`
- `brand_voice`, `geo_context`
- `prompts` (JSON: call1_prompt, call2_prompt, reset-to-default)
- `publish_status` ('published' | 'draft')
- `aspect_ratio` ('3:4'), `output_px` (1600), `output_px_min` (1600), `fill_target` (0.80)
- `user_sensitivity`, `user_edge_offset`, `user_feather`, `user_sheet_mode`
AES master key is an env var (`SLAB_AES_KEY`, 32 bytes base64). Never in the DB.
Each `value_enc` = `nonce(12) || ciphertext`.
---
## sync_log
Append-only history. Not deleted on publish.
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `slab_id` | TEXT FK | |
| `sku` | TEXT | |
| `woo_product_id` | INTEGER | |
| `status` | TEXT | 'success' \| 'failed' |
| `http_status` | INTEGER | |
| `error` | TEXT | machine-readable code |
| `detail` | TEXT | Woo error body |
| `payload_hash` | TEXT | sha256 of Woo payload sent |
| `created_at` | TEXT | |
---
## inference_log
Append-only audit.
| Column | Type | Notes |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | |
| `slab_id` | TEXT FK | |
| `call` | TEXT | '1' \| '2' |
| `request_payload` | TEXT | JSON of what was sent |
| `response_payload` | TEXT | JSON of what came back |
| `created_at` | TEXT | |