# SLICE-1 Walking Skeleton — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Stand up the ecomm walking skeleton — a clean greenfield repo that, from a
fresh checkout, boots a FastAPI backend against an empty self-migrating PostgreSQL
database and serves `/healthz`, with the layer contract, dev container lifecycle,
and CI gate all in place (SD-0001 §7.2 SLICE-1; completes the localhost half of PUC-10).

**Architecture:** Four-layer Python monolith `app.main > app.domains > app.platform`
(import-linter–enforced; GraphQL `api` layer deferred per §2). Persistence is
PostgreSQL everywhere (D-7): a single pinned Docker container in dev/CI, accessed via
psycopg 3 + a small connection pool, with forward-only numbered `.sql` migrations
applied at startup under a Postgres advisory lock (INV-7). A Vite/React SPA shell is
scaffolded but carries no screens yet (those land in SLICE-2/3). `scripts/dev.sh`
owns the container lifecycle; `scripts/check.sh` is the single gate run identically
locally and in Gitea CI.

**Tech Stack:** Python 3.13 · FastAPI · psycopg[binary] 3 + psycopg-pool · pytest ·
import-linter · PostgreSQL 16 (Docker) · React 18 + Vite 5 + TypeScript · Gitea Actions.

**Reference (read-only prior art — patterns to port, not copy):**
`/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype`
(`backend/app/platform/db.py`, `backend/.importlinter`, `scripts/check.sh`,
`.gitea/workflows/ci.yml`, `frontend/vite.config.ts`). The prototype is SQLite; this
plan ports the *structure* (migration runner shape, layer contract, gate) to psycopg.

**Spec:** `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/specs/SD-0001-mvp-sign-up-and-single-storefront.md`
(v0.3.0). Key sections: §6.2 architecture, §6.3 data model, §6.4 `/healthz` contract,
§6.7 D-7 datastore, §6.8 testing, §7.2 SLICE-1.

**Working tree:** this is an isolated worktree on branch `claude/agitated-heyrovsky-1a1f47`;
the repo root already contains `README.md`, `CLAUDE.md`, `app.json`, `.gitignore`. All
paths below are relative to the repo root.

---

## File Structure

```
wiggleverse-ecomm/
├── compose.yaml                      # dev datastore: single pinned Postgres service
├── .gitignore                        # (modify: add backend/frontend build artifacts)
├── scripts/
│   ├── check.sh                      # the gate: lint-imports + pytest + frontend build
│   └── dev.sh                        # owns container lifecycle + backend + Vite
├── .gitea/workflows/ci.yml           # CI calls scripts/check.sh against a PG service
├── docs/
│   └── BOOTSTRAP.md                  # localhost bring-up runbook (Docker prereq)
├── backend/
│   ├── requirements.txt
│   ├── .importlinter                 # layer contract: main > domains > platform
│   ├── pytest.ini
│   ├── app/
│   │   ├── __init__.py
│   │   ├── main.py                   # FastAPI factory + /healthz (the BFF lives here)
│   │   ├── domains/__init__.py       # empty package (filled in SLICE-2/3)
│   │   └── platform/
│   │       ├── __init__.py
│   │       ├── config.py             # env-driven config surface (INV-8)
│   │       ├── db.py                 # psycopg connect/pool + migration runner (INV-7)
│   │       └── deps.py               # per-request DB connection dependency
│   ├── migrations/
│   │   └── 0001_init.sql             # §6.3 schema: account, auth_code, storefront, membership
│   └── tests/
│       ├── conftest.py               # fresh-per-test Postgres database fixtures
│       ├── test_migrations.py        # migrate-from-empty + idempotent re-migrate (INV-1 partial)
│       └── test_healthz.py           # /healthz green on a migrated empty DB
└── frontend/
    ├── package.json
    ├── package-lock.json             # committed; CI uses npm ci
    ├── tsconfig.json
    ├── tsconfig.node.json
    ├── vite.config.ts                # proxies /api -> :8000
    ├── index.html
    └── src/
        ├── main.tsx
        └── App.tsx                   # static shell ("ecomm"); screens land in SLICE-2/3
```

**Responsibilities (one job each):**
- `platform/config.py` — the single place env/deployment config is read (INV-8); no other module reads `os.environ` for config.
- `platform/db.py` — connection pool + the forward-only migration runner; owns the schema lifecycle, no business rules.
- `platform/deps.py` — FastAPI injectable yielding a pooled connection per request.
- `app/main.py` — the app factory: builds the pool, migrates once at startup, mounts `/healthz`. The REST BFF will grow here in later slices.
- `migrations/0001_init.sql` — the entire §6.3 schema, forward-only and immutable once merged.

---

## Task 1: Backend project skeleton, dependencies & layer contract

**Files:**
- Create: `backend/requirements.txt`
- Create: `backend/app/__init__.py`
- Create: `backend/app/domains/__init__.py`
- Create: `backend/app/platform/__init__.py`
- Create: `backend/.importlinter`
- Create: `backend/pytest.ini`
- Modify: `.gitignore`

- [ ] **Step 1: Create the backend dependency list**

Create `backend/requirements.txt`:

```
fastapi>=0.110
uvicorn[standard]>=0.29
httpx>=0.27
psycopg[binary]>=3.1
psycopg-pool>=3.2
pytest>=8.0
import-linter>=2.0
```

- [ ] **Step 2: Create the package skeleton**

Create `backend/app/__init__.py` (empty file).

Create `backend/app/domains/__init__.py`:

```python
"""domains layer — bounded contexts (accounts, storefronts).

Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3.
The package exists now so the layer contract (.importlinter) has a target and later
slices add to a stable seam. Domains import only from app.platform, never upward.
"""
```

Create `backend/app/platform/__init__.py`:

```python
"""platform layer — cross-cutting primitives (config, db, deps).

The bottom layer: imports nothing from app.domains or app.main. Owns the schema
lifecycle and request wiring, never business rules (SD-0001 §6.2).
"""
```

- [ ] **Step 3: Create the import-linter layer contract**

Create `backend/.importlinter`:

```ini
# Enforces SD-0001 §6.2: the four-layer contract main > domains > platform, with
# imports flowing only downward. The GraphQL `api` layer is deferred (§2), so the
# MVP contract has three layers; `app.api` is added back between main and domains
# when GraphQL arrives. Run from backend/:  .venv/bin/lint-imports
[importlinter]
root_package = app

[importlinter:contract:layers]
name = App layering (main > domains > platform)
type = layers
layers =
    app.main
    app.domains
    app.platform
```

- [ ] **Step 4: Configure pytest**

Create `backend/pytest.ini`:

```ini
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -q
# Put backend/ (this rootdir) on sys.path so tests import the `app` package under
# pytest's default prepend import mode (tests/ has no __init__.py).
pythonpath = .
```

- [ ] **Step 5: Extend .gitignore for build artifacts**

Modify `.gitignore` — append these lines (the existing file already has `.venv/`,
`__pycache__/`, `node_modules/`, `dist/`, `.env`):

```
.pytest_cache/
frontend/dist/
backend/var/
```

- [ ] **Step 6: Create the venv and install deps (one-time, to verify the list resolves)**

Run from repo root:

```bash
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r backend/requirements.txt
```

Expected: all packages install without error; `.venv/bin/lint-imports` and
`.venv/bin/pytest` exist.

- [ ] **Step 7: Verify the layer contract passes on the empty skeleton**

Run from repo root:

```bash
cd backend && ../.venv/bin/lint-imports
```

Expected: `Contracts: 1 kept, 0 broken.` (an empty `app` with three layer packages
satisfies the contract trivially).

- [ ] **Step 8: Commit**

```bash
git add backend/requirements.txt backend/app backend/.importlinter backend/pytest.ini .gitignore
git commit -m "feat(slice-1): backend skeleton, deps, and import-linter layer contract

Four-layer package (main > domains > platform) per SD-0001 §6.2; GraphQL api
layer deferred. lint-imports green on the empty skeleton."
```

---

## Task 2: Config surface (env-driven, INV-8)

**Files:**
- Create: `backend/app/platform/config.py`
- Test: `backend/tests/test_config.py`

The config surface is the single place env is read (INV-8 — no deployment shape baked
into other modules). It exposes the database DSN with a localhost-dev default that
matches the compose service (Task 5).

- [ ] **Step 1: Write the failing test**

Create `backend/tests/test_config.py`:

```python
import os

from app.platform import config


def test_database_url_defaults_to_local_compose(monkeypatch):
    monkeypatch.delenv("ECOMM_DATABASE_URL", raising=False)
    assert config.database_url() == "postgresql://ecomm:ecomm@localhost:5432/ecomm"


def test_database_url_honors_env(monkeypatch):
    monkeypatch.setenv("ECOMM_DATABASE_URL", "postgresql://x:y@db:5432/z")
    assert config.database_url() == "postgresql://x:y@db:5432/z"
```

- [ ] **Step 2: Run the test to verify it fails**

Run from `backend/`: `../.venv/bin/pytest tests/test_config.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.platform.config'`.

(Tests import the `app` package because `pythonpath = .` in `pytest.ini` puts the
rootdir, `backend/`, on `sys.path`. If you instead see a collection error about not
finding `app` at all, confirm that `pythonpath` line is present.)

- [ ] **Step 3: Write the implementation**

Create `backend/app/platform/config.py`:

```python
"""Configuration surface — the single place deployment/environment config is read.

INV-8: no deployment shape is baked into framework code. Every URL, credential, or
relay coordinate arrives through here from the environment (resolved from Secret
Manager in deployed environments). The localhost defaults match compose.yaml so a
clean `scripts/dev.sh` checkout needs no env setup.
"""
from __future__ import annotations

import os

# Dev default points at the single Postgres container compose.yaml brings up. In
# PPE/Prod the deployment supplies ECOMM_DATABASE_URL from Secret Manager (INV-8).
_DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm"


def database_url() -> str:
    """The psycopg DSN for the application database."""
    return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL
```

- [ ] **Step 4: Run the test to verify it passes**

Run from `backend/`: `../.venv/bin/pytest tests/test_config.py -q`
Expected: PASS (2 passed).

- [ ] **Step 5: Commit**

```bash
git add backend/app/platform/config.py backend/tests/test_config.py
git commit -m "feat(slice-1): platform/config — single env-driven config surface (INV-8)"
```

---

## Task 3: Dev Postgres compose file

**Files:**
- Create: `compose.yaml`

A single pinned Postgres service with a named volume and healthcheck (D-7). The app
stays native; the container is the datastore only. This task has no automated test —
it is verified by Task 4's tests (which need the container up) and Task 9's dev.sh run.

- [ ] **Step 1: Create the compose file**

Create `compose.yaml`:

```yaml
# Dev datastore for ecomm — a single pinned PostgreSQL service (SD-0001 D-7).
# The app runs natively (fast dev loop); only the database is containerized.
# `scripts/dev.sh` owns this container's lifecycle. Reset to empty (to rehearse the
# bootstrap, BUC-5) with:  docker compose down -v
name: ecomm-dev

services:
  db:
    # Pinned to the Cloud SQL major version ecomm targets in PPE/Prod (D-7).
    image: postgres:16
    environment:
      POSTGRES_USER: ecomm
      POSTGRES_PASSWORD: ecomm
      POSTGRES_DB: ecomm
    ports:
      - "5432:5432"
    volumes:
      - ecomm-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ecomm -d ecomm"]
      interval: 2s
      timeout: 3s
      retries: 30

volumes:
  ecomm-pgdata:
```

- [ ] **Step 2: Bring the container up and verify it is healthy**

Run from repo root:

```bash
docker compose up -d --wait db
docker compose ps
```

Expected: the `db` service is `running` and `healthy`.

- [ ] **Step 3: Verify connectivity from psycopg**

Run from repo root:

```bash
.venv/bin/python -c "import psycopg; psycopg.connect('postgresql://ecomm:ecomm@localhost:5432/ecomm').close(); print('connected')"
```

Expected: `connected`.

- [ ] **Step 4: Commit**

```bash
git add compose.yaml
git commit -m "feat(slice-1): dev Postgres compose — single pinned service, named volume (D-7)"
```

---

## Task 4: platform/db — migration runner + migration 0001 (INV-7)

**Files:**
- Create: `backend/app/platform/db.py`
- Create: `backend/migrations/0001_init.sql`
- Create: `backend/tests/conftest.py`
- Test: `backend/tests/test_migrations.py`

This is the heart of SLICE-1. The migration runner is forward-only, fail-stop, and
guarded by a Postgres advisory lock so two booting processes cannot double-apply
(INV-7). Migration `0001` is the entire §6.3 schema. Tests prove migrate-from-empty
applies `0001` and creates the four tables, and that a second `migrate()` is a no-op
(INV-1 partial; the full-flow bootstrap test lands in SLICE-3).

- [ ] **Step 1: Create the per-test database fixtures**

Each test runs on its own freshly-created, empty database on the compose Postgres, so
every test exercises the real engine (§6.8) and starts from a clean slate.

Create `backend/tests/conftest.py`:

```python
"""Test fixtures — a fresh, empty PostgreSQL database per test.

Each test gets its own database created on the dev/CI Postgres (every test runs the
real engine, §6.8). The admin DSN points at the `postgres` maintenance database;
ECOMM_TEST_ADMIN_URL overrides it (CI sets it to the service container).
"""
from __future__ import annotations

import os
import uuid

import psycopg
import pytest

ADMIN_URL = os.environ.get(
    "ECOMM_TEST_ADMIN_URL", "postgresql://ecomm:ecomm@localhost:5432/postgres"
)


def _swap_dbname(dsn: str, dbname: str) -> str:
    # ADMIN_URL ends in `/postgres`; point the same credentials at `dbname`.
    base, _, _old = dsn.rpartition("/")
    return f"{base}/{dbname}"


@pytest.fixture()
def fresh_db_url():
    """Create a uniquely-named empty database; drop it after the test."""
    dbname = f"ecomm_test_{uuid.uuid4().hex}"
    admin = psycopg.connect(ADMIN_URL, autocommit=True)
    try:
        admin.execute(f'CREATE DATABASE "{dbname}"')
    finally:
        admin.close()
    try:
        yield _swap_dbname(ADMIN_URL, dbname)
    finally:
        admin = psycopg.connect(ADMIN_URL, autocommit=True)
        try:
            admin.execute(
                "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
                "WHERE datname = %s AND pid <> pg_backend_pid()",
                (dbname,),
            )
            admin.execute(f'DROP DATABASE IF EXISTS "{dbname}"')
        finally:
            admin.close()
```

- [ ] **Step 2: Write the failing migration-runner tests**

Create `backend/tests/test_migrations.py`:

```python
import psycopg

from app.platform import db

_TABLES = {"account", "auth_code", "storefront", "storefront_membership"}


def _table_names(conn) -> set[str]:
    rows = conn.execute(
        "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
    ).fetchall()
    return {r[0] for r in rows}


def test_migrate_from_empty_applies_0001(fresh_db_url):
    with psycopg.connect(fresh_db_url) as conn:
        applied = db.migrate(conn)
    assert applied == ["0001_init.sql"]
    with psycopg.connect(fresh_db_url) as conn:
        assert _TABLES.issubset(_table_names(conn))


def test_migrate_is_idempotent(fresh_db_url):
    with psycopg.connect(fresh_db_url) as conn:
        db.migrate(conn)
    with psycopg.connect(fresh_db_url) as conn:
        applied_again = db.migrate(conn)
    assert applied_again == []


def test_membership_has_no_unique_account_constraint(fresh_db_url):
    # INV-4: the schema must keep many-storefronts-per-account open — no UNIQUE on
    # storefront_membership.account_id anywhere.
    with psycopg.connect(fresh_db_url) as conn:
        db.migrate(conn)
        rows = conn.execute(
            """
            SELECT indexdef FROM pg_indexes
            WHERE schemaname = 'public' AND tablename = 'storefront_membership'
            """
        ).fetchall()
    defs = " ".join(r[0] for r in rows).lower()
    assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
```

- [ ] **Step 3: Run the tests to verify they fail**

Run from `backend/`: `../.venv/bin/pytest tests/test_migrations.py -q`
Expected: FAIL — `AttributeError: module 'app.platform.db' has no attribute 'migrate'`
(or `ModuleNotFoundError` for `app.platform.db`).

- [ ] **Step 4: Write the migration runner**

Create `backend/app/platform/db.py`:

```python
"""PostgreSQL access (psycopg 3) + a forward-only migration runner.

Standard wiggleverse stack ported to Postgres (SD-0001 D-7): a connection pool and
numbered forward-only `.sql` migrations applied in filename order, recorded in
`schema_migrations`. No ORM. The runner is fail-stop (a failing migration aborts and
records nothing, so it retries next boot) and guarded by a session advisory lock so
two booting processes never double-apply (INV-7).
"""
from __future__ import annotations

from pathlib import Path

import psycopg
from psycopg_pool import ConnectionPool

from . import config

# app/platform/db.py -> parents[2] is backend/, where migrations/ lives.
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"

# Arbitrary fixed key for the migration advisory lock (INV-7 concurrency guard).
_MIGRATION_LOCK_KEY = 0x6563_6F6D_6D31  # "ecomm1"


def open_pool(dsn: str | None = None, *, min_size: int = 1, max_size: int = 5) -> ConnectionPool:
    """Open a small connection pool against the configured database."""
    pool = ConnectionPool(dsn or config.database_url(), min_size=min_size, max_size=max_size, open=False)
    pool.open()
    pool.wait()
    return pool


def _migration_files(migrations_dir: Path) -> list[Path]:
    return sorted(migrations_dir.glob("*.sql"))


def migrate(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
    """Apply unapplied migrations in filename order; return the names applied.

    Forward-only and fail-stop: each migration runs with its `schema_migrations`
    insert in one transaction, so a failure records nothing and is retried next time.
    A session advisory lock serializes concurrent migrators (INV-7).
    """
    conn.execute("SELECT pg_advisory_lock(%s)", (_MIGRATION_LOCK_KEY,))
    try:
        conn.execute(
            "CREATE TABLE IF NOT EXISTS schema_migrations ("
            " filename TEXT PRIMARY KEY,"
            " applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
        )
        conn.commit()
        applied = {
            r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
        }
        ran: list[str] = []
        for path in _migration_files(migrations_dir):
            if path.name in applied:
                continue
            try:
                conn.execute(path.read_text())  # whole file; no params -> multi-statement
                conn.execute(
                    "INSERT INTO schema_migrations (filename) VALUES (%s)", (path.name,)
                )
                conn.commit()
            except Exception:
                conn.rollback()
                raise
            ran.append(path.name)
        return ran
    finally:
        conn.execute("SELECT pg_advisory_unlock(%s)", (_MIGRATION_LOCK_KEY,))
        conn.commit()


def pending_migrations(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
    """Migration filenames present on disk but not yet recorded as applied.

    Used by /healthz to report migration currency (§6.4). Returns all on-disk
    migrations if the tracking table does not exist yet.
    """
    exists = conn.execute("SELECT to_regclass('public.schema_migrations')").fetchone()[0]
    if exists is None:
        return [p.name for p in _migration_files(migrations_dir)]
    applied = {
        r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
    }
    return [p.name for p in _migration_files(migrations_dir) if p.name not in applied]
```

- [ ] **Step 5: Write migration 0001 (the §6.3 schema)**

Create `backend/migrations/0001_init.sql`:

```sql
-- 0001_init.sql — SD-0001 §6.3 data model. Forward-only and fail-stop (INV-7):
-- do NOT edit this file once it has merged/applied; add a new numbered migration.

-- account — a person's identity on the platform. Email is the canonical key (INV-2);
-- the address is stored already-normalized (lowercased) by the service layer, so a
-- plain unique index enforces one-account-per-email. Data minimization (OHM): email
-- and nothing else (§6.3).
CREATE TABLE account (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email       TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX account_email_key ON account (email);  -- INV-2

-- auth_code — one-time codes, handled like secrets (INV-3): only the hash is stored;
-- keyed by email (the code is issued before the account may exist — sign-up and
-- log-in converge, PUC-3).
CREATE TABLE auth_code (
    id           BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email        TEXT         NOT NULL,
    code_hash    TEXT         NOT NULL,
    expires_at   TIMESTAMPTZ  NOT NULL,
    attempts     INTEGER      NOT NULL DEFAULT 0,
    consumed_at  TIMESTAMPTZ,
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX auth_code_email_idx ON auth_code (email);

-- storefront — a merchant's business presence. Name is always populated (a blank
-- entry gets a generated default at the service layer, §6.3); "unnamed" is never NULL.
CREATE TABLE storefront (
    id          BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        TEXT         NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- storefront_membership — account <-> storefront ownership. INV-4: deliberately a
-- many-to-many shape (composite PK, no UNIQUE(account_id)) so many-storefronts-per-
-- account stays open; the one-storefront rule is a deletable service-layer guard, not
-- a schema law. INV-5: every storefront-scoped row carries its storefront_id. `role`
-- is a single-value enum today; it is the seam the staff/permissions model attaches to.
CREATE TABLE storefront_membership (
    account_id     BIGINT       NOT NULL REFERENCES account (id),
    storefront_id  BIGINT       NOT NULL REFERENCES storefront (id),
    role           TEXT         NOT NULL DEFAULT 'owner' CHECK (role IN ('owner')),
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
    PRIMARY KEY (account_id, storefront_id)
);
```

- [ ] **Step 6: Run the tests to verify they pass**

Ensure the compose Postgres is up (`docker compose up -d --wait db`), then run from
`backend/`: `../.venv/bin/pytest tests/test_migrations.py -q`
Expected: PASS (3 passed).

- [ ] **Step 7: Run the layer contract (db must import only downward)**

Run from `backend/`: `../.venv/bin/lint-imports`
Expected: `Contracts: 1 kept, 0 broken.` (`db.py` imports only `app.platform.config`
and third-party packages — no upward imports).

- [ ] **Step 8: Commit**

```bash
git add backend/app/platform/db.py backend/migrations/0001_init.sql backend/tests/conftest.py backend/tests/test_migrations.py
git commit -m "feat(slice-1): platform/db migration runner + migration 0001 (INV-7, §6.3 schema)

Forward-only, fail-stop, advisory-lock-guarded psycopg runner; migration 0001 lays
the account/auth_code/storefront/membership schema. Tests prove migrate-from-empty
and idempotent re-migrate (INV-1 partial) and the INV-4 many-capable shape."
```

---

## Task 5: FastAPI app factory + /healthz

**Files:**
- Create: `backend/app/platform/deps.py`
- Create: `backend/app/main.py`
- Test: `backend/tests/test_healthz.py`

`create_app()` builds the pool, migrates once at startup, and mounts `/healthz`.
`/healthz` returns `200 {"status":"ok"}` when the process is up, the DB is reachable,
and migrations are current; `503` otherwise (§6.4 — the deploy gate).

- [ ] **Step 1: Write the per-request connection dependency**

Create `backend/app/platform/deps.py`:

```python
"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps).

Yields a pooled connection per request. The pool lives on app.state, built once in
create_app(). Later slices add the current-session and mailer dependencies here.
"""
from __future__ import annotations

from collections.abc import Iterator

import psycopg
from fastapi import Request


def get_conn(request: Request) -> Iterator[psycopg.Connection]:
    """A pooled connection for the duration of one request."""
    pool = request.app.state.pool
    with pool.connection() as conn:
        yield conn
```

- [ ] **Step 2: Write the failing /healthz test**

Create `backend/tests/test_healthz.py`:

```python
from fastapi.testclient import TestClient

from app.main import create_app


def test_healthz_ok_on_migrated_empty_db(fresh_db_url):
    app = create_app(database_url=fresh_db_url)
    with TestClient(app) as client:
        resp = client.get("/healthz")
    assert resp.status_code == 200
    assert resp.json() == {"status": "ok"}


def test_startup_migrates_from_empty(fresh_db_url):
    # create_app on an empty DB must self-migrate so /healthz reports current.
    app = create_app(database_url=fresh_db_url)
    with TestClient(app) as client:
        assert client.get("/healthz").status_code == 200
    # the schema_migrations row exists -> migrations ran at startup
    import psycopg

    with psycopg.connect(fresh_db_url) as conn:
        n = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0]
    assert n >= 1
```

- [ ] **Step 3: Run the test to verify it fails**

Run from `backend/`: `../.venv/bin/pytest tests/test_healthz.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.main'`.

- [ ] **Step 4: Write the app factory + /healthz**

Create `backend/app/main.py`:

```python
"""ecomm backend — FastAPI app factory + the REST BFF.

SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in
later slices. create_app() opens the connection pool and self-migrates the database
at startup (INV-1, INV-7): the app boots against empty persistence and applies its
own schema — there is no seed step.
"""
from __future__ import annotations

from contextlib import asynccontextmanager

import psycopg
from fastapi import Depends, FastAPI, Response

from app.platform import config, db
from app.platform.deps import get_conn


def create_app(database_url: str | None = None) -> FastAPI:
    dsn = database_url or config.database_url()

    @asynccontextmanager
    async def lifespan(app: FastAPI):
        app.state.pool = db.open_pool(dsn)
        with app.state.pool.connection() as conn:
            db.migrate(conn)  # self-migrate at startup (INV-1, INV-7)
        try:
            yield
        finally:
            app.state.pool.close()

    app = FastAPI(title="ecomm", version="0.1", lifespan=lifespan)

    @app.get("/healthz")
    def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
        """Liveness + readiness: process up, DB reachable, migrations current (§6.4)."""
        try:
            conn.execute("SELECT 1")
            pending = db.pending_migrations(conn)
        except Exception:
            response.status_code = 503
            return {"status": "unavailable", "reason": "database_unreachable"}
        if pending:
            response.status_code = 503
            return {"status": "unavailable", "reason": "migrations_pending"}
        return {"status": "ok"}

    return app


app = create_app()
```

- [ ] **Step 5: Run the test to verify it passes**

Ensure compose Postgres is up, then run from `backend/`:
`../.venv/bin/pytest tests/test_healthz.py -q`
Expected: PASS (2 passed).

- [ ] **Step 6: Run the full backend suite + layer contract**

Run from `backend/`:

```bash
../.venv/bin/lint-imports
../.venv/bin/pytest -q
```

Expected: `Contracts: 1 kept, 0 broken.` and all tests pass (config + migrations +
healthz).

- [ ] **Step 7: Manually verify the app boots and serves /healthz**

Run from `backend/` (compose Postgres up):

```bash
../.venv/bin/python -m uvicorn app.main:app --port 8000 &
sleep 2
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/healthz
curl -s http://localhost:8000/healthz
kill %1
```

Expected: `200` then `{"status":"ok"}`.

- [ ] **Step 8: Commit**

```bash
git add backend/app/platform/deps.py backend/app/main.py backend/tests/test_healthz.py
git commit -m "feat(slice-1): FastAPI factory + /healthz; self-migrate at startup (INV-1/§6.4)"
```

---

## Task 6: scripts/check.sh — the single gate

**Files:**
- Create: `scripts/check.sh`

The one pre-merge gate: import-linter, then pytest, then the frontend typecheck+build
(§6.8). CI calls this exact script so local and server gates never drift. (The
frontend build line is added now but only succeeds after Task 7 creates the frontend;
order the tasks so check.sh is finalized with the frontend present — this task creates
it through the backend gates, Task 7 adds the frontend, Task 8's step re-runs it whole.)

- [ ] **Step 1: Create the gate script**

Create `scripts/check.sh`:

```bash
#!/usr/bin/env bash
# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order:
#   1. the import-linter layering contract  (main > domains > platform)
#   2. the backend test suite                (pytest, against Postgres)
#   3. the frontend typecheck + production build
# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates
# cannot drift. Exits non-zero on the first failure.
#
# Requires a reachable Postgres for the backend tests: locally the compose service
# (scripts/dev.sh brings it up); in CI a Postgres service container. Override its
# admin DSN with ECOMM_TEST_ADMIN_URL.
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Pick an interpreter: explicit $PYTHON (CI), else the repo venv, else PATH.
if [ -n "${PYTHON:-}" ]; then
  PY="$PYTHON"
elif [ -x "$repo_root/.venv/bin/python" ]; then
  PY="$repo_root/.venv/bin/python"
else
  PY="$(command -v python3 || command -v python)"
fi

lint="$(dirname "$PY")/lint-imports"
[ -x "$lint" ] || lint="lint-imports"

echo "==> import boundaries (lint-imports)"
( cd "$repo_root/backend" && "$lint" )

echo "==> backend tests (pytest)"
( cd "$repo_root/backend" && "$PY" -m pytest -q )

echo "==> frontend typecheck + build"
( cd "$repo_root/frontend" && npm run build )

echo "==> all gates green"
```

- [ ] **Step 2: Make it executable**

```bash
chmod +x scripts/check.sh
```

- [ ] **Step 3: Verify the backend portion runs**

The frontend does not exist yet, so the full script will fail at the frontend step —
that is expected and resolved in Task 8. Verify the backend gates pass by running them
directly (compose Postgres up), from repo root:

```bash
( cd backend && ../.venv/bin/lint-imports && ../.venv/bin/python -m pytest -q )
```

Expected: contract kept + all backend tests pass.

- [ ] **Step 4: Commit**

```bash
git add scripts/check.sh
git commit -m "feat(slice-1): scripts/check.sh — the single gate (lint-imports, pytest, frontend build)"
```

---

## Task 7: scripts/dev.sh — owns the container lifecycle

**Files:**
- Create: `scripts/dev.sh`

From a clean checkout, `dev.sh` brings up the dev Postgres, waits for healthy, ensures
the backend venv and frontend deps, then runs uvicorn (:8000) and Vite (:5173)
together. It owns the container lifecycle (§6.5, R-6 mitigation).

- [ ] **Step 1: Create the dev script**

Create `scripts/dev.sh`:

```bash
#!/usr/bin/env bash
# Local bring-up for ecomm (SD-0001 PUC-10). From a clean checkout this:
#   1. starts the dev Postgres container and waits for it to be healthy (owns its
#      lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md);
#   2. ensures the backend venv + deps and the frontend node_modules;
#   3. runs the FastAPI backend (:8000, self-migrating an empty DB) and the Vite dev
#      server (:5173, proxying /api -> :8000) together.
# Ctrl-C stops both processes; the container keeps running (stop it with
# `docker compose down`, or `docker compose down -v` to reset to empty).
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"

export ECOMM_DATABASE_URL="${ECOMM_DATABASE_URL:-postgresql://ecomm:ecomm@localhost:5432/ecomm}"

echo "==> dev Postgres (docker compose up --wait)"
docker compose up -d --wait db

if [ ! -x "$repo_root/.venv/bin/python" ]; then
  echo "==> creating backend venv"
  python3 -m venv "$repo_root/.venv"
  "$repo_root/.venv/bin/python" -m pip install --upgrade pip
  "$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt"
fi

if [ ! -d "$repo_root/frontend/node_modules" ]; then
  echo "==> installing frontend deps"
  ( cd "$repo_root/frontend" && npm install )
fi

echo "==> starting backend (:8000) and Vite (:5173) — Ctrl-C to stop"
"$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload &
backend_pid=$!
( cd "$repo_root/frontend" && npm run dev ) &
frontend_pid=$!

cleanup() {
  echo
  echo "==> stopping backend and Vite"
  kill "$backend_pid" "$frontend_pid" 2>/dev/null || true
  wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
}
trap cleanup INT TERM

wait -n "$backend_pid" "$frontend_pid"
cleanup
```

- [ ] **Step 2: Make it executable**

```bash
chmod +x scripts/dev.sh
```

- [ ] **Step 3: Commit** (verified end-to-end in Task 10)

```bash
git add scripts/dev.sh
git commit -m "feat(slice-1): scripts/dev.sh — owns dev Postgres lifecycle + backend + Vite"
```

---

## Task 8: Frontend Vite/React shell + finalize the gate

**Files:**
- Create: `frontend/package.json`
- Create: `frontend/tsconfig.json`
- Create: `frontend/tsconfig.node.json`
- Create: `frontend/vite.config.ts`
- Create: `frontend/index.html`
- Create: `frontend/src/main.tsx`
- Create: `frontend/src/App.tsx`

A minimal React/Vite shell. No screens yet (those land in SLICE-2/3) — just a static
page that proves the build and dev server work. The `/api` proxy is wired now so later
slices need no config change.

- [ ] **Step 1: Create package.json**

Create `frontend/package.json`:

```json
{
  "name": "wiggleverse-ecomm-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.4",
    "typescript": "^5.6.3",
    "vite": "^5.4.11"
  }
}
```

- [ ] **Step 2: Create the TypeScript configs**

Create `frontend/tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}
```

Create `frontend/tsconfig.node.json`:

```json
{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "noEmit": true
  },
  "include": ["vite.config.ts"]
}
```

- [ ] **Step 3: Create the Vite config with the /api proxy**

Create `frontend/vite.config.ts`:

```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the
// browser makes no cross-origin calls in dev. Override the target with
// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in
// SLICE-2/3 (SD-0001 §6.4).
const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      "/api": apiTarget,
    },
  },
});
```

- [ ] **Step 4: Create the HTML entry and React shell**

Create `frontend/index.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ecomm</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

Create `frontend/src/main.tsx`:

```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);
```

Create `frontend/src/App.tsx`:

```tsx
// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens
// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work.
export default function App() {
  return (
    <main>
      <h1>ecomm</h1>
      <p>Honest commerce. Your storefront is yours.</p>
    </main>
  );
}
```

- [ ] **Step 5: Install deps (generates the committed lockfile) and verify the build**

Run from repo root:

```bash
( cd frontend && npm install && npm run build )
```

Expected: `npm install` creates `frontend/package-lock.json`; `npm run build` runs
`tsc` (typecheck, no errors) then `vite build` (produces `frontend/dist/`).

- [ ] **Step 6: Run the whole gate end-to-end**

Ensure compose Postgres is up, then run from repo root:

```bash
scripts/check.sh
```

Expected: import boundaries kept → backend tests pass → frontend build succeeds →
`all gates green`.

- [ ] **Step 7: Commit**

```bash
git add frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/tsconfig.node.json frontend/vite.config.ts frontend/index.html frontend/src
git commit -m "feat(slice-1): Vite/React shell + /api proxy; scripts/check.sh now fully green"
```

---

## Task 9: CI workflow (Gitea Actions)

**Files:**
- Create: `.gitea/workflows/ci.yml`

CI runs the same `scripts/check.sh` against a Postgres service container. Per repo
memory, CI is inert until a Gitea runner is registered — so this is written correctly
but will not execute yet; it is the DoD's "check gate green in CI" target.

- [ ] **Step 1: Create the workflow**

Create `.gitea/workflows/ci.yml`:

```yaml
# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so
# the local and server gates can never drift: import-linter (main > domains >
# platform), then backend pytest against a Postgres service, then the frontend
# typecheck + build. Requires a registered Gitea Actions runner advertising the
# `ubuntu-latest` label.
name: ci

on:
  push:
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: ecomm
          POSTGRES_PASSWORD: ecomm
          POSTGRES_DB: ecomm
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U ecomm"
          --health-interval 2s
          --health-timeout 3s
          --health-retries 30
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install backend deps
        run: |
          python -m venv .venv
          .venv/bin/python -m pip install --upgrade pip
          .venv/bin/python -m pip install -r backend/requirements.txt

      - name: Install frontend deps
        run: |
          cd frontend && npm ci

      - name: Run the pre-merge gate
        env:
          PYTHON: ${{ github.workspace }}/.venv/bin/python
          ECOMM_TEST_ADMIN_URL: postgresql://ecomm:ecomm@localhost:5432/postgres
        run: scripts/check.sh
```

- [ ] **Step 2: Commit**

```bash
git add .gitea/workflows/ci.yml
git commit -m "ci(slice-1): Gitea workflow runs scripts/check.sh against a Postgres service"
```

---

## Task 10: docs/BOOTSTRAP.md (localhost section)

**Files:**
- Create: `docs/BOOTSTRAP.md`

The documented bring-up gesture BUC-5 demands — localhost section for SLICE-1; the
PPE/Prod sections land in SLICE-4.

- [ ] **Step 1: Write the localhost runbook**

Create `docs/BOOTSTRAP.md`:

```markdown
# Bootstrapping ecomm

Bringing an environment from **empty persistence** to "first merchant, first
storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state
(INV-1): the app applies its own schema migrations at startup; there is no seed step.

This document grows per environment. SLICE-1 ships the **localhost** section; the
pre-production and production sections land with SLICE-4.

## Localhost

### Prerequisites

- **Docker** (Desktop or Engine) with Compose v2 — the one external prerequisite. The
  dev datastore is a single PostgreSQL container; the app itself runs natively for a
  fast dev loop (D-7).
- **Python 3.13** and **Node 20+** on your PATH. `scripts/dev.sh` creates the backend
  venv and installs frontend deps on first run.

### Bring-up

From a clean checkout:

\`\`\`bash
scripts/dev.sh
\`\`\`

This:

1. starts the dev Postgres container and waits for it to report healthy (the script
   owns the container's lifecycle);
2. creates the backend virtualenv and installs dependencies (first run only);
3. installs the frontend's npm dependencies (first run only);
4. runs the FastAPI backend on **:8000** — which connects to the empty database and
   applies all pending migrations itself (INV-7) — and the Vite dev server on
   **:5173**, proxying `/api` to the backend.

Press **Ctrl-C** to stop the backend and Vite. The database container keeps running.

### Verify

\`\`\`bash
curl http://localhost:8000/healthz
\`\`\`

Expected: \`{"status":"ok"}\` (HTTP 200) — the process is up, the database is reachable,
and migrations are current. Open <http://localhost:5173> to see the app shell.

### Reset to empty (rehearse the bootstrap)

\`\`\`bash
docker compose down -v
\`\`\`

This removes the container and its named volume, returning persistence to empty. The
next \`scripts/dev.sh\` re-migrates from scratch — exactly the day-one state every
environment starts from (BUC-5a).

### Configuration

\`scripts/dev.sh\` sets \`ECOMM_DATABASE_URL\` to the local compose DSN
(\`postgresql://ecomm:ecomm@localhost:5432/ecomm\`). No deployment shape is baked into
the app (INV-8); deployed environments supply this and other config from Secret
Manager (SLICE-4).
```

- [ ] **Step 2: Commit**

```bash
git add docs/BOOTSTRAP.md
git commit -m "docs(slice-1): BOOTSTRAP.md localhost bring-up runbook (BUC-5)"
```

---

## Task 11: End-to-end DoD verification

**Files:** none (verification only).

Prove the SLICE-1 Definition of Done: clean checkout → `dev.sh` → app up on an empty,
self-migrated Postgres; `/healthz` green; gate green.

- [ ] **Step 1: Reset persistence to empty**

Run from repo root:

```bash
docker compose down -v
```

Expected: container and `ecomm-pgdata` volume removed.

- [ ] **Step 2: Simulate a clean checkout's first run**

To prove dev.sh provisions from nothing, temporarily move the venv and node_modules
aside, then run dev.sh in the background long enough to boot:

```bash
mv .venv .venv.bak 2>/dev/null || true
mv frontend/node_modules frontend/node_modules.bak 2>/dev/null || true
scripts/dev.sh &
dev_pid=$!
sleep 60   # first run installs venv + npm deps + boots; adjust if slower
```

- [ ] **Step 3: Verify /healthz on the freshly bootstrapped, empty DB**

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/healthz
curl -s http://localhost:8000/healthz
```

Expected: `200` then `{"status":"ok"}`.

- [ ] **Step 4: Verify the schema self-applied and the DB is otherwise empty**

```bash
.venv/bin/python -c "
import psycopg
c = psycopg.connect('postgresql://ecomm:ecomm@localhost:5432/ecomm')
tables = {r[0] for r in c.execute(\"SELECT tablename FROM pg_tables WHERE schemaname='public'\").fetchall()}
print('tables:', sorted(tables))
print('accounts:', c.execute('SELECT count(*) FROM account').fetchone()[0])
print('migrations applied:', c.execute('SELECT count(*) FROM schema_migrations').fetchone()[0])
c.close()
"
```

Expected: tables include `account`, `auth_code`, `storefront`, `storefront_membership`,
`schema_migrations`; `accounts: 0` (empty is a working state, INV-1); `migrations
applied: 1`.

- [ ] **Step 5: Stop dev.sh**

```bash
kill "$dev_pid" 2>/dev/null || true
```

- [ ] **Step 6: Run the full gate green**

```bash
scripts/check.sh
```

Expected: `all gates green`.

- [ ] **Step 7: Confirm the DoD checklist**

- [ ] clean checkout → `scripts/dev.sh` → app up on an empty, self-migrated DB ✓
- [ ] `/healthz` green ✓
- [ ] `scripts/check.sh` gate green (the CI workflow runs this exact script) ✓
- [ ] bootstrap test skeleton: migrate-from-empty + idempotent re-migrate green
      (`test_migrations.py`) — INV-1 partial ✓

- [ ] **Step 8: Update the top-level README status**

Modify `README.md` — replace the `## Status` section's body:

```markdown
## Status

SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that
self-migrates an empty PostgreSQL database at startup and serves `/healthz`, a
Vite/React shell, the `scripts/check.sh` gate, and the dev container lifecycle
(`scripts/dev.sh`). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally.
Identity (SLICE-2) and the storefront (SLICE-3) are next.
```

- [ ] **Step 9: Commit**

```bash
git add README.md
git commit -m "docs(slice-1): README status — walking skeleton in place"
```

---

## Done criteria (whole plan)

- `scripts/check.sh` is green: import-linter contract kept, backend pytest green
  against real Postgres, frontend typecheck + build succeed.
- `scripts/dev.sh` from a clean checkout brings up Postgres, boots the self-migrating
  backend, and serves `200 {"status":"ok"}` at `/healthz` on an empty database.
- The §6.3 schema is laid by `migrations/0001_init.sql` and the INV-4 many-capable
  shape is asserted by test.
- `docs/BOOTSTRAP.md` documents the localhost gesture; `.gitea/workflows/ci.yml` runs
  the gate (inert until a runner is registered).
- All work committed on `claude/agitated-heyrovsky-1a1f47`, ready for PR → `main`.
```
