diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..15cd020 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..1502a60 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,2 @@ +"""SlabUploader backend package (S0 foundation).""" +__all__ = ["app", "db"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..ff532fb --- /dev/null +++ b/backend/app.py @@ -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__"] diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000..a45b8a4 --- /dev/null +++ b/backend/db/__init__.py @@ -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 diff --git a/backend/db/schema.sql b/backend/db/schema.sql new file mode 100644 index 0000000..1037bf3 --- /dev/null +++ b/backend/db/schema.sql @@ -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) +); diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..20cb2c0 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_health_startup.py b/backend/tests/test_health_startup.py new file mode 100644 index 0000000..f928c35 --- /dev/null +++ b/backend/tests/test_health_startup.py @@ -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() diff --git a/backend/tests/test_schema.py b/backend/tests/test_schema.py new file mode 100644 index 0000000..dc9ce13 --- /dev/null +++ b/backend/tests/test_schema.py @@ -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() diff --git a/docs/contracts/API.md b/docs/contracts/API.md index aec9d41..857f77d 100644 --- a/docs/contracts/API.md +++ b/docs/contracts/API.md @@ -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"`. 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. +if the SKU already exists. Cites APP-20, CAPTURE-135, METADATA-45, PUBLISH-15. `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 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. +client never sets it. Cites APP-20, REVIEW-40, PUBLISH-15, PUBLISH-20. ### `DELETE /api/v1/slabs/{id}` 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 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 -and never touches dimensions. +and never touches dimensions. Cites METADATA-55, METADATA-65. 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`, @@ -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 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. +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 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 +Cites SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-80, SETTINGS-85. + - `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 diff --git a/docs/contracts/DATA-MODEL.md b/docs/contracts/DATA-MODEL.md index cbbe405..45277b3 100644 --- a/docs/contracts/DATA-MODEL.md +++ b/docs/contracts/DATA-MODEL.md @@ -104,6 +104,8 @@ successful publish. ## 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_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure, Grade, Thickness, Moisture, with their terms. @@ -148,6 +150,8 @@ revision it used (`METADATA-66`). ## 8. `sync_log` +Cites PUBLISH-25, PUBLISH-30. + 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`. @@ -158,5 +162,7 @@ Append-only audit. Columns: `id`, `slab_id`, `call` (1 | 2), `prompt_revision_id ## 10. Status transitions +Cites APP-5. + `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. diff --git a/docs/contracts/PROMPTS.md b/docs/contracts/PROMPTS.md index daa16b9..6c4e570 100644 --- a/docs/contracts/PROMPTS.md +++ b/docs/contracts/PROMPTS.md @@ -39,6 +39,8 @@ The interface is reachable on the trusted network only until it is authenticated ## 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 true; also on demand or retry. With inference off it is skipped and the fields are filled by hand. diff --git a/docs/contracts/UX.md b/docs/contracts/UX.md index 7f344ef..8881014 100644 --- a/docs/contracts/UX.md +++ b/docs/contracts/UX.md @@ -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-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-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-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 | | SETTINGS-65 | create-status default | @@ -26,6 +26,8 @@ flow runs on a PC browser. ## 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 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 @@ -47,6 +49,8 @@ undersized source never guess; the resolution is retake. ## 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 with an "AI" badge; below it the field starts empty. The badge is presentation only and clears on edit. @@ -63,6 +67,8 @@ undersized source never guess; the resolution is retake. ## 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. "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.