# 1. Architecture

## 1.1 Goals that shaped the design

| Goal | Consequence in the code |
|------|-------------------------|
| Trustworthy stock figures (the one thing a WMS must never get wrong) | Append-only movement ledger; balances are a materialised view of the ledger updated in the same transaction under row locks; database check constraints forbid negative on-hand or allocated > on-hand. |
| One codebase for on-prem and SaaS | Every business table carries `tenant_id`; isolation is enforced in PostgreSQL (RLS) and not only in application code; the platform console, plans, feature flags and metering are built in from day one. |
| Operations keep running when the AI is down | All AI features are additive; the deterministic analytics work without a model; AI output never writes operational data without a human decision. |
| Integrations that survive partner outages | Transactional outbox for events, retried webhooks with signatures, idempotent public API, message log with retry for inbound ERP documents. |
| A dev team can own it | Modular monolith with explicit module boundaries, shared zod contracts between API and web, an end-to-end test suite that drives the real API against a real database. |

## 1.2 System context

```
                 +------------------+        +-------------------+
   Browser  ---> |  Web (React)     | -----> |  API (NestJS)     | ---> PostgreSQL 16 (RLS)
   RF device --> |  back office, RF |  /api  |  modules below    | ---> Object storage (local / S3)
                 +------------------+        |  jobs (worker)    | ---> SMTP
   ERP / storer systems ------------------>  |  public API v1    | ---> LLM provider (optional)
   (API key, IDoc XML, Excel)                +-------------------+ <--- webhooks out (signed)
```

One API image runs in two roles: `JOBS_ENABLED=false` for stateless API replicas and `JOBS_ENABLED=true` for the single worker that runs the outbox dispatcher, senders (webhooks, email), storage snapshots, replenishment, expiry sweep and AI proposal expiry. Jobs use `SELECT ... FOR UPDATE SKIP LOCKED`, so running several workers is safe; one is enough.

## 1.3 Modules (apps/api/src/modules)

| Module | Responsibility | Depends on |
|--------|----------------|------------|
| `platform` | tenants, plans, subscriptions, feature flags, usage metering, provisioning, tenant export | common |
| `auth`, `users` | login, refresh token rotation, platform login, users, roles, API keys, audit endpoint | platform |
| `master-data` | warehouses, zones, locations (+ generator), storers, suppliers, customers, carriers, SKUs/packs/barcodes, slotting, putaway and allocation rules, reason codes | common |
| `inventory` | ledger, balances, LPNs, moves, holds, adjustments (four-eyes), counts, putaway engine | master-data, tasks |
| `tasks` | unified work queue (RECEIVE, PUTAWAY, PICK, REPLENISH, MOVE, COUNT, PACK, LOAD, QC), assignment, handler registry | common |
| `inbound` | ASNs, dock appointments, receiving, discrepancies, GRN/RCR | master-data, inventory, tasks |
| `outbound` | orders, ATP, allocation, waves, picking, packing, shipping/loads, returns, replenishment | master-data, inventory, tasks |
| `billing` | contracts, tariffs, charge capture (event handlers), storage snapshots and methods, invoices, PDF | master-data |
| `documents` | PDF rendering (pdfmake), ZPL labels, document store | storage |
| `integrations` | public API v1, API-key scopes, webhooks, connectors, ERP adapters (SAP IDoc), imports | master-data, inbound, outbound |
| `reports` | control tower, aging, expiry, movement register, productivity, occupancy, CSV | common |
| `ai` | LLM provider abstraction, proposals, document intake, analytics, copilot | platform, inbound, outbound, master-data, reports, billing |
| `jobs` | outbox dispatcher, senders, scheduled jobs | billing, outbound, inventory, platform |

Cross-cutting (`apps/api/src/common`): request context (AsyncLocalStorage), `DbService` (tenant-scoped transactions), auth guard, audit, numbering (document sequences), event bus (outbox), encryption (AES-256-GCM for connector secrets), storage, mail, validation (zod pipes), error mapping.

## 1.4 Request lifecycle

1. `RequestContextMiddleware` creates a context (request id, ip, user agent) in AsyncLocalStorage.
2. `AuthGuard` authenticates: Bearer JWT (user, platform admin) or `X-API-Key` (integration). It fills the context with tenant, actor, permissions, warehouse access, storer scope and enabled features, then checks `@RequirePermissions`, `@RequireFeature`, `@PlatformOnly`, `@ApiKeyScopes`. API-key calls are metered and quota-checked.
3. Controllers validate bodies and queries with zod schemas from `@wms/shared` (the same schemas the web app uses).
4. Services call `DbService.run(fn)`, which opens a transaction and sets `app.tenant_id` (and `app.bypass_rls` for platform/system work). Every query inside is filtered by RLS policies. Services accept an optional outer transaction so flows compose atomically.
5. Domain changes write audit rows and publish events to the outbox inside the same transaction. The dispatcher later delivers them to in-process handlers (billing capture, metering) and to webhooks.
6. Errors are mapped by `HttpExceptionFilter` into `{ statusCode, code, message, details, requestId }`; Postgres errors (unique, FK, check, RLS, the cross-tenant guard) get stable codes.

## 1.5 Inventory ledger (the trust layer)

- `inventory_movements` is append-only. Each row has a type (RECEIPT, PUTAWAY, MOVE, PICK, SHIP, ADJUST_IN/OUT, STATUS_CHANGE, COUNT_ADJUST ...), from/to location, from/to LPN, lot, expiry, status, quantity, reference, actor, device, idempotency key.
- `inventory_balances` holds one row per (warehouse, storer, SKU, location, LPN, lot, expiry, status) identified by a deterministic `balance_key`. `LedgerService.post()` locks the affected balance rows (`FOR UPDATE`), applies the delta, and the check constraints `qty_on_hand >= 0` and `qty_allocated <= qty_on_hand` make an inconsistent state impossible to commit.
- Allocation reserves quantity on specific balances (`allocations`), so ATP = on hand - allocated per balance, never a global guess.
- Serial-controlled SKUs maintain `serial_units` with location/LPN and lifecycle (IN_STOCK, ALLOCATED, PICKED, SHIPPED ...).
- Idempotency: receiving, public API documents and charge capture use idempotency keys with unique indexes, so retries never double post.

## 1.6 Tenancy and security

See `docs/06-security-and-tenancy.md`. In short: RLS on every tenant table (`FORCE ROW LEVEL SECURITY`, app role cannot bypass), a trigger that refuses cross-tenant foreign keys, JWTs carrying permissions/warehouses/features with short expiry and rotating refresh tokens, argon2 password hashes, API keys stored as SHA-256 with scopes, secrets encrypted at rest, full audit log.

## 1.7 Events and integration surface

- Domain events (`packages/shared/src/events.ts`) are written to `outbox_events` in the business transaction. The dispatcher marks them PROCESSING with `SKIP LOCKED`, runs handlers, retries with backoff and parks poison messages as DEAD.
- Webhook deliveries are rows (`webhook_deliveries`) with attempts, response codes and HMAC-SHA256 signatures (`X-WMS-Signature`), redeliverable from the UI.
- Inbound ERP documents land in `integration_messages` (RECEIVED, PROCESSING, PROCESSED, FAILED, DEAD) and can be retried; adapters implement `ErpAdapter` (parse, map, produce outbound documents).
- The public API speaks business codes (SKU code, warehouse code, references), never internal ids, and is versioned under `/api/v1`.

## 1.8 AI layer

See `docs/05-ai-layer.md`. Provider-agnostic (`LlmProvider`), every LLM call goes through `LlmService` (feature gate, monthly token budget, metering, structured-output validation with one corrective retry). Deterministic analytics compute findings in SQL; the model only writes narratives, extracts documents, and answers questions by calling whitelisted read-only tools. Outputs are `ai_proposals`; `ProposalsService.decide()` is the only bridge into operational data and it reuses the normal domain services.

## 1.9 Front end

`apps/web` is one Vite application with two shells: the back office (sidebar navigation, data tables, forms) and the RF handheld (`/rf`, large touch targets, scan-first screens). Both use the same API client (`src/lib/api.ts`) with automatic token refresh, the same zod contracts, and permission/feature-aware navigation. State is server state (TanStack Query) plus a small auth store; there is no client-side business logic that the API does not enforce again.

## 1.10 Key design decisions (ADR summary)

| Decision | Alternatives considered | Why |
|----------|-------------------------|-----|
| NestJS + TypeScript, shared zod contracts | Laravel (team background), Spring | One language end to end, contracts shared with the web app, strong typing of decimals and enums; the earlier Laravel prototype is kept under `legacy/` for reference. |
| Drizzle ORM | Prisma, TypeORM | Explicit SQL, transactions with `SET LOCAL` for RLS, no engine binary, decimals as strings mapped to `decimal.js`. |
| Postgres RLS + reference guard | application-only filtering | Defence in depth: a missing `where tenant_id` in code cannot leak data. |
| Transactional outbox | direct HTTP calls from services | Events are never lost on crash; partner outages do not block operations. |
| Materialised balances + append-only ledger | computing balances from movements on read | Fast reads and row-level locking for allocation; the ledger remains the source of truth for audit and rebuild. |
| Proposals for all AI outputs | autonomous AI actions | Accountability, auditability, and safe behaviour when the model is wrong. |
| Modular monolith | microservices | One deployable, transactional consistency across inventory, tasks and billing; modules keep boundaries so extraction later is possible. |
