feat(backend): S0 foundation (schema + stdlib sqlite3 db factory + health/meta + JSON logs)

- backend/db/schema.sql: all 12 tables from DATA-MODEL.md (taxonomy empty)
- backend/db: stdlib sqlite3 factory, WAL/FK/busy_timeout/synchronous pragmas,
  single locked connection, explicit SLAB_DB_PATH/SLAB_IMAGES_DIR
- backend/app.py: /api/health (non-200 on storage not writable), /api/v1/meta,
  JSON logging with request-id threading + secret redaction, startup guards
  (SETTINGS-10 WOO_BASE_URL https, SETTINGS-25 SLAB_AES_KEY)
- tests: schema-loads, health happy/failure, startup guards (TDD)
- docs/contracts: add requirement-ID citations to 12 sections so
  check_requirements.py passes (pre-existing gate debt)

Gates: check_requirements.py + check_coverage.py exit 0; pytest 5 passed
This commit is contained in:
2026-09-19 15:34:15 -07:00
parent 9b181a937b
commit 0eb2b0f697
12 changed files with 592 additions and 6 deletions
+25
View File
@@ -0,0 +1,25 @@
# S0 minimal backend Dockerfile (OPS-25: pinned, not latest)
FROM python:3.14.3-slim-bookworm
WORKDIR /app
# system deps minimal
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /app/requirements.txt
COPY backend/ /app/backend/
COPY .dockerignore /app/.dockerignore 2>/dev/null || true
ENV PYTHONPATH=/app \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# non-root? later; for S0 root ok
EXPOSE 8000
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
+2
View File
@@ -0,0 +1,2 @@
"""SlabUploader backend package (S0 foundation)."""
__all__ = ["app", "db"]
+163
View File
@@ -0,0 +1,163 @@
"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25).
Base /api/v1; health at /api/health (non-200 on storage not writable).
Stdlib JSON logging with request_id from X-Request-Id (generate if absent).
Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY.
No secrets in logs. Minimal, no S1+.
"""
from __future__ import annotations
import json
import logging
import os
import sys
import uuid
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable
__version__ = "0.5.0-m3-s0"
# --- Structured JSON logging (stdlib only) ---
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
class _JsonFormatter(logging.Formatter):
"""JSON lines; redacts any secret-looking content (APP-65)."""
def format(self, record: logging.LogRecord) -> str:
rid = getattr(record, "request_id", None) or request_id_var.get("")
msg = record.getMessage()
# Redact secrets: never log creds, keys, aes material
lower = msg.lower()
if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")):
msg = "[REDACTED secret material]"
data: dict[str, Any] = {
"ts": self.formatTime(record, self.datefmt),
"level": record.levelname,
"logger": record.name,
"request_id": rid,
"msg": msg,
}
if record.exc_info:
data["exc"] = self.formatException(record.exc_info)
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _configure_logging() -> None:
level = os.getenv("LOG_LEVEL", "INFO").upper()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(_JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(getattr(logging, level, logging.INFO))
# uvicorn access logs also json? for S0 keep simple; they inherit
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
_configure_logging()
logger = logging.getLogger("slab.backend")
# --- Startup guards (refuse with clear message) ---
def _enforce_startup_guards() -> None:
woo_url = os.getenv("WOO_BASE_URL")
if not woo_url:
msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)"
logger.error(msg)
raise RuntimeError(msg)
if not woo_url.startswith("https://"):
msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")"
logger.error(msg)
raise RuntimeError(msg)
if not os.getenv("SLAB_AES_KEY"):
msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)"
logger.error(msg)
raise RuntimeError(msg)
# db/images envs are required by db.py on use; guards here only per spec
_enforce_startup_guards()
# --- Request ID middleware (threads X-Request-Id or generates) ---
class RequestIdMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
token = request_id_var.set(rid)
try:
response = await call_next(request)
response.headers["X-Request-Id"] = rid
return response
finally:
request_id_var.reset(token)
# --- App ---
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("startup: applying schema (idempotent)")
try:
init_db()
logger.info("startup: schema ready; db+images envs present")
except Exception:
logger.exception("startup failed")
raise
yield
# shutdown: reset conn for test isolation if needed
try:
from .db import _reset_for_tests
_reset_for_tests()
except Exception:
pass
app = FastAPI(
title="SlabUploader",
version=__version__,
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestIdMiddleware)
@app.get("/api/health")
async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
db_ok = probe_writable()
img_ok = probe_images_writable()
if not (db_ok and img_ok):
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
raise HTTPException(status_code=503, detail="storage not writable")
return {
"status": "ok",
"version": __version__,
"woo_reachable": False, # S0 stub; S4 will implement real check
}
@app.get("/api/v1/meta")
async def meta() -> dict[str, Any]:
"""Per API.md §3."""
return {"version": __version__}
# expose for tests
__all__ = ["app", "__version__"]
+98
View File
@@ -0,0 +1,98 @@
"""S0 db factory package: stdlib sqlite3 ONLY. ... (same as before)"""
from __future__ import annotations
import os
import sqlite3
import threading
from pathlib import Path
from typing import Any
DB_PATH_ENV = "SLAB_DB_PATH"
IMAGES_DIR_ENV = "SLAB_IMAGES_DIR"
SCHEMA_FILE = "schema.sql"
_lock = threading.Lock()
_conn: sqlite3.Connection | None = None
_current_path: str | None = None
def _get_schema_path() -> Path:
# backend/db/__init__.py -> backend/db/schema.sql
return Path(__file__).parent / SCHEMA_FILE
def get_connection(db_path: str | None = None) -> sqlite3.Connection:
global _conn, _current_path
if db_path is None:
db_path = os.environ.get(DB_PATH_ENV)
if not db_path:
raise RuntimeError(f"{DB_PATH_ENV} env var is required")
db_path = str(Path(db_path).resolve())
with _lock:
if _conn is None or _current_path != db_path:
if _conn is not None:
_conn.close()
_conn = sqlite3.connect(db_path, check_same_thread=False)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("PRAGMA foreign_keys=ON")
_conn.execute("PRAGMA busy_timeout=5000")
_conn.execute("PRAGMA synchronous=NORMAL")
_current_path = db_path
return _conn
def init_db(db_path: str | None = None) -> sqlite3.Connection:
conn = get_connection(db_path)
schema_path = _get_schema_path()
if not schema_path.exists():
raise FileNotFoundError(f"Schema not found: {schema_path}")
with open(schema_path, "r", encoding="utf-8") as f:
schema = f.read()
with _lock:
conn.executescript(schema)
conn.commit()
return conn
def get_images_dir() -> Path:
img = os.environ.get(IMAGES_DIR_ENV)
if not img:
raise RuntimeError(f"{IMAGES_DIR_ENV} env var is required")
return Path(img).resolve()
def probe_writable(conn: sqlite3.Connection | None = None) -> bool:
try:
c = conn or get_connection()
c.execute("SELECT 1").fetchone()
return True
except Exception:
return False
def probe_images_writable() -> bool:
try:
img_dir = get_images_dir()
if not img_dir.exists():
img_dir.mkdir(parents=True, exist_ok=True)
probe = img_dir / ".health-probe"
probe.write_text("ok", encoding="utf-8")
probe.unlink(missing_ok=True)
return True
except Exception:
return False
def _reset_for_tests() -> None:
"""Close global conn so temp dirs can be cleaned on Windows (test only)."""
global _conn, _current_path
with _lock:
if _conn is not None:
try:
_conn.close()
except Exception:
pass
_conn = None
_current_path = None
+157
View File
@@ -0,0 +1,157 @@
-- SlabUploader SQLite schema (S0)
-- Every table/column from docs/contracts/DATA-MODEL.md using CREATE TABLE IF NOT EXISTS
-- Taxonomy tables exist but remain EMPTY (S4 populates). Stdlib sqlite3 only.
-- Pragmas applied by db factory: journal_mode=WAL, foreign_keys=ON, busy_timeout=5000, synchronous=NORMAL
PRAGMA foreign_keys = ON;
-- 2. slabs
CREATE TABLE IF NOT EXISTS slabs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','calibrated','ready','publishing','published','failed')),
sku TEXT UNIQUE CHECK(length(sku) BETWEEN 3 AND 24),
length_in REAL,
thickness_in REAL,
sqft REAL,
bdft REAL,
width_min_in REAL,
width_max_in REAL,
width_avg_in REAL,
species_id INTEGER,
wood_category_ids TEXT, -- JSON array of leaf ids
edge_type_term_id INTEGER,
figure_term_ids TEXT, -- JSON array
grade_term_id INTEGER,
thickness_term_id INTEGER,
moisture_term_id INTEGER,
fig_tag_ids TEXT, -- JSON array optional
feat_tag_ids TEXT, -- JSON array optional
price REAL,
price_source TEXT CHECK(price_source IN ('recommendation','override') OR price_source IS NULL),
title TEXT,
short_description TEXT,
description TEXT,
content_source TEXT CHECK(content_source IN ('manual','llm','template') OR content_source IS NULL),
species_confidence REAL,
figure_confidence REAL,
grade_confidence REAL,
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
calibration_photo_id TEXT,
client_rev INTEGER NOT NULL DEFAULT 1,
server_rev INTEGER NOT NULL DEFAULT 1,
woo_product_id INTEGER,
published_at TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (species_id) REFERENCES woo_categories(id),
FOREIGN KEY (calibration_photo_id) REFERENCES slab_photos(id)
);
-- 3. slab_photos
CREATE TABLE IF NOT EXISTS slab_photos (
id TEXT PRIMARY KEY,
slab_id TEXT NOT NULL,
seq INTEGER NOT NULL,
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
original_blob BLOB,
processed_blob BLOB,
width_px INTEGER,
height_px INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY (slab_id) REFERENCES slabs(id) ON DELETE CASCADE
);
-- 4. pricing_rules
CREATE TABLE IF NOT EXISTS pricing_rules (
id INTEGER PRIMARY KEY,
species_id INTEGER NOT NULL UNIQUE,
price_per_bdft REAL NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (species_id) REFERENCES woo_categories(id)
);
-- 5. Taxonomy cache (empty at S0; columns chosen to support FKs + S4 sync; ambiguity resolved in learnings)
CREATE TABLE IF NOT EXISTS woo_categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
parent_id INTEGER,
woo_id INTEGER
);
CREATE TABLE IF NOT EXISTS woo_attributes (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
woo_id INTEGER
);
CREATE TABLE IF NOT EXISTS woo_attribute_terms (
id INTEGER PRIMARY KEY,
attribute_id INTEGER NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
woo_id INTEGER,
FOREIGN KEY (attribute_id) REFERENCES woo_attributes(id)
);
CREATE TABLE IF NOT EXISTS woo_tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
woo_id INTEGER
);
-- 6. settings (key/value/value_enc per §6; secrets in value_enc = nonce||ciphertext)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
value_enc TEXT
);
-- 7. prompts + prompt_revisions (append-only revisions)
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL CHECK(name IN ('call1','call2')),
active_revision_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (active_revision_id) REFERENCES prompt_revisions(id)
);
CREATE TABLE IF NOT EXISTS prompt_revisions (
id INTEGER PRIMARY KEY,
prompt_id INTEGER NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
created_by TEXT,
FOREIGN KEY (prompt_id) REFERENCES prompts(id)
);
-- 8. sync_log (append-only)
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY,
slab_id TEXT,
woo_product_id INTEGER,
status TEXT NOT NULL CHECK(status IN ('success','failed')),
payload_hash TEXT,
message TEXT,
created_at TEXT NOT NULL
);
-- 9. inference_log (append-only audit)
CREATE TABLE IF NOT EXISTS inference_log (
id INTEGER PRIMARY KEY,
slab_id TEXT,
call INTEGER NOT NULL CHECK(call IN (1,2)),
prompt_revision_id INTEGER,
model TEXT,
request_payload TEXT,
response_payload TEXT,
latency_ms INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY (prompt_revision_id) REFERENCES prompt_revisions(id)
);
+6
View File
@@ -0,0 +1,6 @@
# S0 backend requirements (pinned, no ORM)
fastapi==0.115.2
uvicorn[standard]==0.30.6
pytest==8.3.3
httpx==0.27.2
# stdlib only for sqlite3, logging, json, contextvars, threading
+73
View File
@@ -0,0 +1,73 @@
"""TDD red tests for S0 health endpoint and startup guards (APP-30, APP-45, SETTINGS-10, SETTINGS-25)."""
import importlib
import os
import tempfile
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _reload_app():
import backend.app as app_mod
return importlib.reload(app_mod)
def test_health_happy_path_returns_200_with_three_fields(monkeypatch):
"""Given: writable db + image dir; When: GET /api/health; Then: 200 + {status, version, woo_reachable}."""
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
monkeypatch.setenv("LOG_LEVEL", "INFO")
from backend.app import app
client = TestClient(app)
resp = client.get("/api/health")
assert resp.status_code == 200
body = resp.json()
assert set(body.keys()) == {"status", "version", "woo_reachable"}
assert body["status"] == "ok"
db_mod._reset_for_tests()
def test_health_fails_non_200_when_image_dir_not_writable(monkeypatch):
"""Given: image dir not writable; When: GET /api/health; Then: non-200 (probes health file per DATA-MODEL §1)."""
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
# force probe fail without relying on chmod (win32 owner can still write)
with patch("backend.app.probe_images_writable", return_value=False):
from backend.app import app
client = TestClient(app)
resp = client.get("/api/health")
assert resp.status_code != 200
db_mod._reset_for_tests()
def test_startup_guard_refuses_on_missing_or_non_https_woo_base_url(monkeypatch):
"""Given: WOO_BASE_URL missing or not https; When: import/create app; Then: raises with clear message (SETTINGS-10)."""
monkeypatch.delenv("WOO_BASE_URL", raising=False)
with pytest.raises(RuntimeError, match="WOO_BASE_URL"):
_reload_app()
monkeypatch.setenv("WOO_BASE_URL", "http://insecure.example.com")
with pytest.raises(RuntimeError, match="https"):
_reload_app()
def test_startup_guard_refuses_on_missing_slab_aes_key(monkeypatch):
"""Given: SLAB_AES_KEY missing; When: import/create app; Then: raises with clear message (SETTINGS-25)."""
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
_reload_app()
+46
View File
@@ -0,0 +1,46 @@
"""TDD red test for S0 schema load (DATA-MODEL.md contract)."""
import os
import sqlite3
import tempfile
import pytest
SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "..", "db", "schema.sql")
def test_schema_loads_cleanly_and_all_tables_exist():
"""Given: schema.sql from DATA-MODEL; When: loaded via executescript into isolated sqlite; Then: all 12 tables present with exact columns."""
expected_tables = {
"slabs",
"slab_photos",
"pricing_rules",
"woo_categories",
"woo_attributes",
"woo_attribute_terms",
"woo_tags",
"settings",
"prompts",
"prompt_revisions",
"sync_log",
"inference_log",
}
with tempfile.TemporaryDirectory() as tmp:
db_path = os.path.join(tmp, "isolated.db")
assert os.path.exists(SCHEMA_PATH), "schema.sql must exist for test"
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
schema_sql = f.read()
conn = sqlite3.connect(db_path)
try:
conn.executescript(schema_sql)
conn.commit()
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
actual = {r[0] for r in rows}
assert expected_tables.issubset(actual), f"Missing tables: {expected_tables - actual}"
# spot check one column from slabs per DATA-MODEL
cols = [r[1] for r in conn.execute("PRAGMA table_info(slabs)").fetchall()]
assert "id" in cols and "status" in cols and "client_rev" in cols
finally:
conn.close()
+6 -4
View File
@@ -75,7 +75,7 @@ There is no `412 validation_module_stale` path.
Multipart create of a calibrated draft. Returns `201` with the slab at `status: "calibrated"`. 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 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` `inference_enabled` is true; inferred values persist only at confidence ≥ 0.7. `409 duplicate_sku`
if the SKU already exists. if the SKU already exists. Cites APP-20, CAPTURE-135, METADATA-45, PUBLISH-15.
`SlabCreate` fields: `SlabCreate` fields:
@@ -116,7 +116,7 @@ Partial update of editable fields plus `client_rev`. Stores client-sent derived
not recompute them. Checks run in order: `409 revision_conflict`, then `409 duplicate_sku` (SKU 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). 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 `status` becomes `ready` only when the full required set is present; it is server-derived and a
client never sets it. client never sets it. Cites APP-20, REVIEW-40, PUBLISH-15, PUBLISH-20.
### `DELETE /api/v1/slabs/{id}` ### `DELETE /api/v1/slabs/{id}`
Soft delete of unpublished drafts only (sets `deleted_at`); `204`. Deleted rows are excluded from Soft delete of unpublished drafts only (sets `deleted_at`); `204`. Deleted rows are excluded from
@@ -141,7 +141,7 @@ Runs Call 2 on user action only. Fresh request carrying the processed photos, th
metadata, the primary and secondary wood categories, the parent-category path, the dimension fact metadata, the primary and secondary wood categories, the parent-category path, the dimension fact
block, and brand/geo context; no provider thread id and no raw Call 1 result. Requires block, and brand/geo context; no provider thread id and no raw Call 1 result. Requires
`test-inference` `ok`. Returns `202`; the client polls. The model writes the long description only `test-inference` `ok`. Returns `202`; the client polls. The model writes the long description only
and never touches dimensions. and never touches dimensions. Cites METADATA-55, METADATA-65.
Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`, 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`, `edge_type_term_id`, `figure_term_ids`, `figure_confidence`, `grade_term_id`, `grade_confidence`,
@@ -158,7 +158,7 @@ Preconditions are the full ready set, else `422 unpublishable` with field keys.
the ready set; look up the SKU and stop if present; refresh taxonomy when the last sync is over 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; 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. `POST /products` per `docs/contracts/WOO-API.md`; finalize. `SLAB-UAT-*` always creates a draft.
Returns `202`; the client polls. Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-25.
- Success: `woo_product_id` set and returned on the terminal poll, photos purged, the local working - 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. slab row discarded, device draft cleared. Nothing is deleted from WooCommerce.
@@ -187,6 +187,8 @@ One flat `price_per_bdft` per species leaf, no tiers.
## 10. Settings ## 10. Settings
Cites SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-80, SETTINGS-85.
- `GET /api/v1/settings` → `SettingsView` with secrets masked. - `GET /api/v1/settings` → `SettingsView` with secrets masked.
- `PUT /api/v1/settings` — partial update. `woo_base_url` is not writable; the server rejects the - `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 write. Woo credentials are write-only and stored AES-GCM. Inference base URL and key are
+6
View File
@@ -104,6 +104,8 @@ successful publish.
## 5. Taxonomy cache ## 5. Taxonomy cache
Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55.
- `woo_categories` — species and wood-category leaves only; parents group and are never assigned. - `woo_categories` — species and wood-category leaves only; parents group and are never assigned.
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure, - `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
Grade, Thickness, Moisture, with their terms. Grade, Thickness, Moisture, with their terms.
@@ -148,6 +150,8 @@ revision it used (`METADATA-66`).
## 8. `sync_log` ## 8. `sync_log`
Cites PUBLISH-25, PUBLISH-30.
Append-only; never deleted on publish. Columns: `id`, `slab_id`, `woo_product_id`, `status` Append-only; never deleted on publish. Columns: `id`, `slab_id`, `woo_product_id`, `status`
(`success` | `failed`), `payload_hash` (sha256 of the outbound payload), `message`, `created_at`. (`success` | `failed`), `payload_hash` (sha256 of the outbound payload), `message`, `created_at`.
@@ -158,5 +162,7 @@ Append-only audit. Columns: `id`, `slab_id`, `call` (1 | 2), `prompt_revision_id
## 10. Status transitions ## 10. Status transitions
Cites APP-5.
`draft` → `calibrated` → `ready` → `publishing` → `published`, with `failed` reachable from any `draft` → `calibrated` → `ready` → `publishing` → `published`, with `failed` reachable from any
step and retryable. `calibrated` is set when the draft uploads; `ready` requires the full set. step and retryable. `calibrated` is set when the draft uploads; `ready` requires the full set.
+2
View File
@@ -39,6 +39,8 @@ The interface is reachable on the trusted network only until it is authenticated
## 3. Call 1 — vision classification ## 3. Call 1 — vision classification
Cites METADATA-35, METADATA-40, METADATA-45, METADATA-50.
**When.** Runs automatically after a calibrated draft uploads only when `inference_enabled` is **When.** Runs automatically after a calibrated draft uploads only when `inference_enabled` is
true; also on demand or retry. With inference off it is skipped and the fields are filled by hand. true; also on demand or retry. With inference off it is skipped and the fields are filled by hand.
+8 -2
View File
@@ -11,10 +11,10 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
| CAPTURE-20, CAPTURE-25, CAPTURE-30 | photo intake and calibration choice | | CAPTURE-20, CAPTURE-25, CAPTURE-30 | photo intake and calibration choice |
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake | | CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers | | CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
| CAPTURE-105, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, upload, preview/controls, failure | | CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality | | METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability | | METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
| PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish outcomes and messaging | | PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits | | REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
| SETTINGS-65 | create-status default | | SETTINGS-65 | create-status default |
@@ -26,6 +26,8 @@ flow runs on a PC browser.
## 2. Capture ## 2. Capture
Cites CAPTURE-20, CAPTURE-25, CAPTURE-40, CAPTURE-130.
1. **Photos.** Take one or more photos; the first is top-down with the lens parallel to the broad 1. **Photos.** Take one or more photos; the first is top-down with the lens parallel to the broad
face. The owner chooses which photo is the calibration photo. face. The owner chooses which photo is the calibration photo.
2. **Mask overlay and controls.** The app draws the mask on the source photo. The controls are 2. **Mask overlay and controls.** The app draws the mask on the source photo. The controls are
@@ -47,6 +49,8 @@ undersized source never guess; the resolution is retake.
## 3. Review ## 3. Review
Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
- **Vision pre-fill.** At or above the threshold (default 0.7) a field is pre-filled and shown - **Vision pre-fill.** At or above the threshold (default 0.7) a field is pre-filled and shown
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
on edit. on edit.
@@ -63,6 +67,8 @@ undersized source never guess; the resolution is retake.
## 4. Publish ## 4. Publish
Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-25.
- A normal SKU uses `woo_create_status` (default draft); a `SLAB-UAT-*` SKU always creates a draft. - A normal SKU uses `woo_create_status` (default draft); a `SLAB-UAT-*` SKU always creates a draft.
"Published" means the store create succeeded, not that it is live. "Published" means the store create succeeded, not that it is live.
- **Success.** The local draft and photos are gone; the store product reference is shown. - **Success.** The local draft and photos are gone; the store product reference is shown.