# Deployment Guide

## 1. Components

| Process | Command | Notes |
|---------|---------|-------|
| Web/API | PHP-FPM + Nginx (or Octane) | Stateless; scale horizontally |
| Outbox publisher | `php artisan wms:outbox:publish --loop` | **Exactly one or more** instances; events are claimed with row locks so multiple workers are safe. Drives billing, labor capture, AI triggers and webhooks |
| Queue workers | `php artisan queue:work --queue=ai,default --tries=3 --timeout=120` | `ai` queue calls the model provider; keep 1-2 workers so cost caps and rate limits behave |
| Scheduler | `* * * * * php artisan schedule:run` | Nightly maintenance 01:15, reconciliation 02:30, hourly replenishment scan 06-22, outbox safety net every minute |
| Database | PostgreSQL **15+** (16 recommended) | Requires `UNIQUE NULLS NOT DISTINCT` (PG15+). One database; sequences `docseq_*` are created on first use |
| Cache / locks | Redis recommended in production | Rate limiting, AI circuit breaker, cache. Database driver works for a single node |

Minimum sizing for one site at ~2,000 order lines/day: 2 API nodes (2 vCPU/4 GB), 1 worker node, PostgreSQL 4 vCPU/16 GB with SSD, Redis 1 GB.

## 2. Environment variables

```
APP_ENV=production
APP_KEY=...                       # php artisan key:generate
APP_URL=https://wms.westore.ai
DB_CONNECTION=pgsql  DB_HOST=... DB_PORT=5432  DB_DATABASE=wms  DB_USERNAME=wms_app  DB_PASSWORD=...
# read-only connection for the AI natural-language query use case (see §4)
DB_RO_USERNAME=wms_readonly  DB_RO_PASSWORD=...
CACHE_STORE=redis  QUEUE_CONNECTION=redis  SESSION_DRIVER=redis  REDIS_HOST=...
AI_PROVIDER=claude                # or 'fake' to run without a provider
ANTHROPIC_API_KEY=sk-ant-...
AI_MODEL=claude-sonnet-4-5
AI_TIMEOUT=60
AI_RETENTION_DAYS=90
LOG_CHANNEL=stack  LOG_LEVEL=info
SANCTUM_STATEFUL_DOMAINS=wms.westore.ai
```

Add the read-only connection to `config/database.php`:

```php
'pgsql_readonly' => array_merge(config('database.connections.pgsql'), [
    'username' => env('DB_RO_USERNAME'), 'password' => env('DB_RO_PASSWORD'),
]),
```

## 3. Database roles (do this before go-live)

```sql
-- application role: full DML, but the ledger and audit trail are append-only
CREATE ROLE wms_app LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE wms TO wms_app;
GRANT USAGE ON SCHEMA public TO wms_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wms_app;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO wms_app;
GRANT CREATE ON SCHEMA public TO wms_app;              -- document sequences are created lazily
REVOKE UPDATE, DELETE ON inventory_transactions, audit_events FROM wms_app;   -- append-only guarantee at the DB level

-- read-only role for AI natural-language queries: reporting views only
CREATE ROLE wms_readonly LOGIN PASSWORD '...';
GRANT CONNECT ON DATABASE wms TO wms_readonly;
GRANT USAGE ON SCHEMA public TO wms_readonly;
GRANT SELECT ON v_inventory_summary, v_stock_by_lot, v_expiring_stock, v_location_utilization,
      v_order_cycle_times, v_receiving_accuracy, v_pick_productivity, v_billable_summary, v_open_exceptions TO wms_readonly;
ALTER ROLE wms_readonly SET statement_timeout = '5s';
```

Run migrations as a privileged role (`php artisan migrate --force`), not as `wms_app`.

## 4. Release procedure

1. `php artisan down --secret=<token>` (optional for schema changes that lock tables).
2. Deploy code; `composer install --no-dev --optimize-autoloader`.
3. `php artisan migrate --force` (migrations are additive; views are `CREATE OR REPLACE`).
4. `php artisan config:cache route:cache event:cache`.
5. Restart PHP-FPM, queue workers (`php artisan queue:restart`) and the outbox publisher.
6. `php artisan wms:inventory:reconcile` — must report `drift=0`.
7. `php artisan up`.

Seed roles/permissions after any change to `app/Support/Permissions.php`: `php artisan db:seed --class=RolesAndPermissionsSeeder --force`.

## 5. Monitoring (what to alert on)

| Signal | Source | Threshold |
|--------|--------|-----------|
| Outbox lag | `GET /api/v1/system/outbox` → `lag_seconds` | > 120 s |
| Outbox failures | `outbox_events.status = 'failed'` | any |
| Reconciliation drift | `wms_exceptions.exception_type = 'reconciliation_drift'` | any (critical) |
| Webhook dead letters | `webhook_deliveries.status = 'dead'` | any |
| AI provider errors | `ai_invocations.status IN ('provider_error','timeout','circuit_open')` | > 5 in 15 min |
| AI cost | `ai_usage_daily` | approaching caps |
| Queue depth | Redis / Horizon | > 500 jobs for 5 min |
| Open critical exceptions | `v_open_exceptions WHERE severity='critical'` | any |

Logs are JSON with `request_id`, `actor`; every API response carries `X-Request-Id`.

## 6. Backups and retention

- PostgreSQL: continuous WAL archiving + nightly base backup; test restore monthly.
- `inventory_transactions` and `audit_events` are never deleted.
- `ai_invocations.request_body/response_body` purged after `AI_RETENTION_DAYS` by the nightly job; metrics rows stay.
- `idempotency_keys` expire after 24 h (purge with a nightly `DELETE WHERE expires_at < now()`; add to scheduler if volume is high).

## 7. Security checklist

- TLS everywhere; Sanctum tokens only over HTTPS; rotate integration tokens yearly (`POST /auth/tokens` with `expires_in_days`).
- Webhook secrets are encrypted at rest; verify `X-WMS-Signature` = `sha256=HMAC_SHA256(secret, "{timestamp}.{body}")` and reject timestamps older than 5 minutes.
- Rate limits: 120 req/min per user (API), 240 req/min (RF), 20/min login, 30/min AI query; tune in `config/wms.php`.
- The AI NL-query use case only ever runs on the read-only role with `statement_timeout`.
- Never run `DemoSeeder` in production (it refuses).
