diff --git a/specs/SD-0001-mvp-sign-up-and-single-storefront.md b/specs/SD-0001-mvp-sign-up-and-single-storefront.md
index 2c59643..00aaa7f 100644
--- a/specs/SD-0001-mvp-sign-up-and-single-storefront.md
+++ b/specs/SD-0001-mvp-sign-up-and-single-storefront.md
@@ -531,19 +531,155 @@ text-first, honest — no countdowns, no badges, no fake density.
### 6.1 Invariants
-
+- **INV-1 — Empty is a working state.** The application boots against empty
+ persistence, applies its own schema migrations, and serves the full
+ sign-up → storefront → admin flow. No code path assumes a pre-existing row;
+ there is no seed step. *Enforced mechanically:* the bootstrap test (§6.8)
+ runs the whole flow from a fresh database; it is the first test of the
+ suite.
+- **INV-2 — Email is the canonical account key.** One account per email
+ (case-insensitively normalized); any future auth method resolves to the
+ same account by email (corpus 14.01.0010). *Enforced:* unique index on the
+ normalized email column.
+- **INV-3 — One-time codes are handled like secrets.** Stored only as hashes;
+ expire after 10 minutes; single-use; at most 5 verification attempts per
+ code; resend no more than once per 60 seconds per email. Codes never appear
+ in production logs or API responses. *Enforced:* schema (consumed/attempt
+ columns) + service-layer checks + tests.
+- **INV-4 — One storefront per account is the MVP rule, not the schema.**
+ Ownership lives in a membership relation that admits many storefronts per
+ account and many members per storefront; the *service layer* refuses a
+ second storefront for the same account today (and the UX never offers it,
+ PUC-7). Lifting the rule later is deleting one check, not a migration.
+ *Enforced:* service-layer guard + test; no `UNIQUE(account_id)` anywhere in
+ the storefront path.
+- **INV-5 — Every storefront-scoped row carries its storefront.** All future
+ tenant data references `storefront_id`; nothing tenant-scoped is global.
+ (Trivially satisfied by this MVP's two tenant tables; stated now because it
+ binds every later Feature.) *Enforced:* schema review + FK constraints.
+- **INV-6 — One source of truth for rules.** Authentication, authorization,
+ and the one-storefront guard live in the domain layer exactly once; every
+ API surface (today REST; later GraphQL) delegates to them. *Enforced:*
+ layering contract (import-linter) + the API layer owning no business logic.
+- **INV-7 — Migrations are forward-only and fail-stop.** Numbered `.sql`
+ files applied in order at startup, recorded in a migrations table; a failed
+ migration aborts startup (the deploy fails; the prior version keeps
+ serving). No down-migrations.
+- **INV-8 — No deployment shape in framework code.** URLs, mail relay
+ coordinates, secrets: all arrive via environment/deployment configuration
+ (resolved from Secret Manager in deployed environments); none are baked
+ into the app (handbook §5.3). *Enforced:* config surface table (§6.6) +
+ review.
+- **INV-9 — Honest failure.** When the platform cannot do what was asked
+ (code not sent, storefront not created), the user is told so — no fake
+ success, no silent drop (OHM: Transparency & Traceability).
### 6.2 High-level architecture
-
+```mermaid
+flowchart LR
+ subgraph Browser
+ SPA[Admin SPA
React + Vite]
+ end
+ subgraph "ecomm backend (FastAPI, one process)"
+ BFF[REST BFF
main.py]
+ subgraph domains
+ ACC[accounts
identity · OTC · sessions]
+ SF[storefronts
storefront · membership]
+ end
+ subgraph platform
+ DB[db
SQLite WAL + migrations]
+ MAIL[mailer
port: log / SMTP]
+ DEPS[deps
request wiring]
+ end
+ end
+ STORE[(SQLite file)]
+ RELAY[SMTP relay
deployed envs only]
+
+ SPA -->|JSON /api/*| BFF
+ BFF --> ACC
+ BFF --> SF
+ ACC --> DB
+ SF --> DB
+ ACC --> MAIL
+ DB --> STORE
+ MAIL -.-> RELAY
+```
+
+Layering is the prototype's four-layer contract, mechanically enforced with
+import-linter: `main` (entrypoint + REST BFF) → `domains` → `platform`;
+imports flow only downward; domains are imported via their package surface
+(`__all__`), never by reaching into modules.
+
+- **Admin SPA** — the four surfaces of §5; owns no rules. Renders what the
+ BFF returns; must never enforce a business rule the backend doesn't (the
+ UX-level one-storefront affordance is presentation; the rule is INV-4's
+ service guard).
+- **REST BFF (`main.py`)** — screen-shaped JSON endpoints, one round-trip per
+ screen; translates HTTP ↔ domain calls; owns no business logic (INV-6).
+- **`accounts` domain** — owns identity: account records, one-time-code
+ issue/verify (INV-2, INV-3), session establishment, the signed-in-user
+ gate. Must never know what a storefront is.
+- **`storefronts` domain** — owns the storefront entity and the
+ account↔storefront membership (INV-4, INV-5), including the entry-routing
+ answer ("which storefront, if any, does this account have?"). Must never
+ mint identity.
+- **`platform/db`** — SQLite WAL connection + forward-only migration runner
+ (INV-7); owns the schema lifecycle, no business rules.
+- **`platform/mailer`** — a one-method port (`send(to, subject, body)`) with
+ two adapters: `LogMailer` (localhost/dev and tests — the message lands in
+ the app log / a test inbox) and `SmtpMailer` (PPE/Prod — relay coordinates
+ from deployment config, INV-8). Chosen by configuration at startup.
+- **`platform/deps`** — FastAPI dependency wiring: per-request DB connection,
+ current-session resolution, configured mailer.
+
+Sessions are signed HTTP-only cookies (server secret from configuration; no
+session table). MVP-honest: signing out clears the cookie; a leaked signing
+secret is rotated by replacing the configured secret, which invalidates all
+sessions at once. Revisit (server-side sessions) when staff/multi-device
+management arrives.
### 6.3 Data model & ownership
-
+| Entity | Owned by | Key fields | System of record |
+| --- | --- | --- | --- |
+| `account` | accounts | `id`, `email` (unique, normalized — INV-2), `created_at` | SQLite |
+| `auth_code` | accounts | `id`, `email`, `code_hash`, `expires_at`, `attempts`, `consumed_at`, `created_at` (INV-3) | SQLite |
+| `storefront` | storefronts | `id`, `name`, `created_at` | SQLite |
+| `storefront_membership` | storefronts | `account_id` (FK), `storefront_id` (FK), `role` (MVP: always `'owner'`), `created_at`; PK `(account_id, storefront_id)` | SQLite |
+
+Notes:
+
+- **Data minimization (OHM):** the account stores the email and nothing else —
+ no name, no phone, no profile (corpus sign-up asks for nothing more;
+ prototype precedent). A display name arrives only when a Feature needs it.
+- `auth_code` rows are keyed by *email*, not account, because the code is
+ issued before the account may exist (sign-up and log-in converge, PUC-3).
+ Consumed/expired rows are purged opportunistically on issue.
+- `storefront_membership.role` is a single-value enum today; it is the seam
+ where the corpus's staff & permissions model (13.15.\*) attaches later
+ without reshaping ownership (INV-4 rationale).
+- `storefront.name`: when the merchant leaves it blank, a generated default
+ (e.g. "⟨email local-part⟩'s storefront") is stored — the column is always
+ populated; "unnamed" is a naming choice, not a NULL (corpus 14.01.0026).
### 6.4 Interfaces & contracts
-
+All JSON over `/api/*`; session via signed cookie. Errors share one shape:
+`{"error": {"code": "", "message": ""}}`.
+
+| Endpoint | In | Out | Errors |
+| --- | --- | --- | --- |
+| `POST /api/auth/request-code` | `{email}` | `204` — code issued & dispatched. Response is identical whether the email is new or known (no account enumeration). | `400 invalid_email` · `429 resend_cooldown` (with `retry_after_s`) · `502 delivery_failed` (INV-9 — the relay refused; dev `LogMailer` cannot fail) |
+| `POST /api/auth/verify` | `{email, code}` | `200 {account: {email}, storefront: {id, name} \| null, created: bool}` — session cookie set; `created` drives the honest welcome copy (PUC-3); `storefront` drives entry routing (§6.5). | `400 code_mismatch` (with `attempts_remaining`) · `400 code_expired` · `400 code_exhausted` |
+| `GET /api/auth/me` | — | `200 {account: {email}, storefront: {id, name} \| null}` | `401 unauthenticated` |
+| `POST /api/auth/logout` | — | `204` — cookie cleared | — (idempotent) |
+| `POST /api/storefronts` | `{name?: string}` | `201 {id, name}` — membership row created with `role='owner'` | `401 unauthenticated` · `409 already_owns_storefront` (INV-4, PUC-7) |
+| `GET /healthz` | — | `200 {status: "ok"}` — process up, DB reachable, migrations current | `503` otherwise (deploy gate, §6.9) |
+
+Contract stance: these are the stable seams the SPA and the tests bind to;
+adding fields is free, renaming/removing is a spec change (this document,
+§3.1-handbook).
### 6.5 Per–Product-Use-Case design