> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alforse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture

> One journey from a button click, through the gateway, into Postgres, out to background work, and back to the screen.

Alforse uses two applications over one manufacturing data model. The **ERP** manages quoting, purchasing,
planning, inventory, accounting, and shipping. The **MES** records and controls shop-floor work, typically
from tablets at work centers. Both are served from `app.alforse.com` and `mes.app.alforse.com`; one backend serves both.

This page traces a write from the UI through validation, authorization, persistence, background work, and
UI refresh. Read [Part 1](#part-1-the-map) and [Part 2](#part-2-follow-one-click-all-the-way-down) in order.
The remaining sections are independent references.

Numbered references such as Postgres [\[12\]](#g12) link to definitions in the [Glossary](#glossary).

NOTE

**A snapshot, not a contract**

File paths and counts reflect the repository at the time of writing and may change as it evolves.

***

## Part 1: The map

Alforse is a monorepo [\[8\]](#g8) managed with pnpm workspaces and Turborepo [\[17\]](#g17). It has two layers.

**The screens.** `apps/erp` and `apps/mes` are React Router applications with server-rendered React. Their
shared web packages live in `packages/web/*` (`auth`, `database`, `api`, `jobs`, `planning`, `react`,
`utils`, `workflows`, and others).

**The backend.** `apps/backend` is the single application host. It serves the API and composes the native layers:

| Folder | What it holds |
| - | - |
| `kernel/` | Shared contracts: authorization, identity, approvals, events, records, workflows, the transaction ports. |
| `modules/` | Master data used by several products: units, products, parties and addresses, document numbering, people. |
| `products/` | Seventeen product areas such as `inventory-and-logistics`, `quality-management`, `source-to-pay`. |
| `packages/database/` | Postgres stores, the migration runner, and the SQL migrations. |

One Postgres [\[12\]](#g12) database is the system of record. It enforces row-level permissions per company
and holds the transactional outbox that background work reads. [Part 3](#part-3-layer-by-layer) describes these responsibilities.

Key properties:

1. **The ERP and the MES do not talk to the database directly.** Their server code calls a gateway under
   `/api/web` in the backend. The gateway speaks the same REST, RPC, auth, and storage protocol the screens were
   built for and routes every call to a native owner in `kernel/`, `modules/`, `products/`, or `packages/database/`.
2. **The database enforces tenant access.** Row-level security prevents access to another company's rows.
   See [Part 3](#part-3-layer-by-layer).
3. **Writes that must succeed together share one transaction.** A native owner joins the caller's unit of work, and
   events are written to the outbox in that same transaction.
4. **Background work runs inside the backend.** A runner inside the backend process executes scheduled and queued
   tasks, and a delivery worker sends notifications and webhooks. See [Part 4](#part-4-what-happens-after-the-write).
5. **CAD conversion is part of the backend.** Geometry conversion and assembly planning run in the backend
   behind `/api/web/assembler`; there is no separate service to operate.

***

## Part 2: Follow one click all the way down

This section follows a buyer creating a purchase order through the form, validation, permission check,
database write, document numbering, and UI refresh.

### The short version

```text theme={null}
Browser            ERP server (React Router)             Backend gateway            Postgres
  |  POST form          |                                      |                        |
  |-------------------->| validate with zod schema             |                        |
  |                     | requirePermissions(create: purchasing)                        |
  |                     |------------------------------------->| decide permission       |
  |                     | rpc get_next_sequence                |                        |
  |                     |------------------------------------->| allocate "PO000123"     |
  |                     | insert purchaseOrder + children      |                        |
  |                     |------------------------------------->| write rows, outbox ---->|
  |  302 redirect       |                                      |                        |
  |<--------------------|                                      |                        |
  |  GET new order page: the loader reads through the gateway  |                        |
```

The request completes after the rows are written. Notifications, webhooks, and other side effects run later,
from the outbox.

### The same story, with real files

**1. The button.** It is a link, not a submit button.

`apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:535`

```tsx theme={null}
<New label={t`Purchase Order`} to={path.to.newPurchaseOrder} />
```

`permissions.can(...)` hides actions the user cannot perform, but the server performs the authoritative
permission check. `path.to.*` references the route registry in `apps/erp/app/utils/path.ts`; do not hardcode application URLs.

**2. The form.** `apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrderForm.tsx:116`

`ValidatedForm` (from `@alforse/web-form`) wraps an HTML form with schema-based client and server error display.
It receives the validator and the default values as props.

**3. The schema.** `apps/erp/app/modules/purchasing/purchasing.models.ts:146`

`purchaseOrderValidator` is a zod [\[19\]](#g19) object. zod provides runtime validation and inferred TypeScript
types, and `zod-form-data` (`zfd`) normalizes HTML form values; for example, `zfd.text()` converts an empty field
from `""` to `undefined`. The browser and server validate against the same schema.

**4. The route.** `apps/erp/app/routes/x+/purchase-order+/new.tsx:30`

```ts theme={null}
export async function action({ request }: ActionFunctionArgs) {
  assertIsPost(request);
  const { client, companyId, companyGroupId, userId } =
    await requirePermissions(request, {
      create: "purchasing",
      bypassRls: true
    });

  const formData = await request.formData();
  const validation = await validator(newPurchaseOrderValidator).validate(
    formData
  );
  if (validation.error) return validationError(validation.error);
  /* ... check that the supplier and location belong to the company ... */
  const result = await insertPurchaseOrder(client, {
    ...validation.data,
    companyId,
    companyGroupId,
    createdBy: userId,
    customFields: setCustomFields(formData)
  });
  /* ... flash an error and redirect, or redirect to the new order ... */
}
```

Alforse writes follow this sequence:

> **assert the method → check permissions → validate → call a service → redirect.**

A file under `apps/erp/app/routes/` can export up to three things that matter here:

| Export | Runs where | Job |
| - | - | - |
| `loader` [\[6\]](#g6) | server only | fetch the data a page needs |
| `action` [\[1\]](#g1) | server only | handle a form submission |
| `export default` | server first, then browser | the React component |

`loader` and `action` are excluded from browser JavaScript, so they can call server-only code.

**5. The service function.** `apps/erp/app/modules/purchasing/purchasing.service.ts:1663` (`insertPurchaseOrder`)

Routes call service functions instead of accessing the database directly. A service function accepts a client as
its first argument and returns the `{ data, error }` shape of `supabase-js`. That client is created by
`getAlforseServiceRole()` in `packages/web/auth/src/lib/supabase/client.server.ts:13`. Despite its historical name, it
points at the native gateway (`/api/web`) and acts as the signed-in user for the current request. It cannot be
used from another request.

```ts theme={null}
const seq = await client.rpc("get_next_sequence", {
  sequence_name: "purchaseOrder",
  company_id: input.companyId
});

const order = await client
  .from("purchaseOrder")
  .insert({
    purchaseOrderId,
    supplierId: input.supplierId,
    /* ... */
  });
```

The service then writes the delivery and payment rows for the order. Each call to the client is a separate request
to the gateway. For writes that must be atomic, the gateway routes to a native owner that runs the whole
operation in one database transaction (see [Part 3.3](#33-the-service-layer-ways-to-write)).

**6. Database-side behavior.** `client.rpc("get_next_sequence", ...)` is an RPC [\[15\]](#g15) handled by the
`master-document-numbering` module (`modules/master-document-numbering/`). It allocates the next number inside the
submitting transaction and formats it with the company's prefix, zero-padding, and optional date tokens. See
[Sequences](/docs/reference/sequences.md).

**7. The screen updates.** The action ends with `throw redirect(...)`. React Router follows the redirect,
runs the destination `loader`, reads current state through the gateway, and renders the page. After any non-GET
submission, React Router automatically revalidates the current page's loaders.

***

## Part 3: Layer by layer

### 3.1 The browser and the route file

The applications use **React Router v7** in framework mode with server-rendered React. A route file can
define both data loading and UI. Alforse does not use Remix or Next.js for ERP and MES screens. Vite [\[18\]](#g18)
builds the applications, and Tailwind v4 provides styling. (`apps/backend` itself is a Next.js application host.)

**Routes are files on disk.** `apps/erp/app/routes/` has well over 1,600 files, and the
folder structure *is* the URL structure, through `remix-flat-routes` (`apps/erp/app/routes.ts`):

| In a filename | Means |
| - | - |
| `x+/` | a URL segment and a folder: `x+/purchase-order+/new.tsx` → `/x/purchase-order/new` |
| `_public+/` | a folder for grouping that adds **no** URL segment |
| `_layout.tsx` | a wrapper shared by every route in the folder |
| `$orderId` | a URL parameter: `$orderId.tsx` → `/:orderId` |
| `.` in the name | a `/` in the URL: `dimensions.new.tsx` → `dimensions/new` |
| `_index.tsx` | the page for the folder's own URL |
| `*.server.ts` | ignored by the router; a helper file that never reaches the browser |

At the top level, `x+` contains authenticated application routes, `api+` contains JSON endpoints,
`_public+` contains authentication routes, and `share+` contains token-authenticated public links.
`select-company+` and `onboarding+` handle company selection and first-run setup.

**Getting data in.** A server-side `loader` calls service functions and returns JSON. The component reads it with
`useLoaderData<typeof loader>()`. `apps/erp/app/routes/x+/_layout.tsx` loads shared user, company, permission,
and feature-flag data. Hooks such as `useUser()`, `usePermissions()`, and `useCompanySettings()` (in
`apps/erp/app/hooks/`) read this loader data.

**Getting data out.** Forms post to an `action`, as shown in [Part 2](#part-2-follow-one-click-all-the-way-down). Use `useFetcher()` for writes that should not navigate,
such as reordering, inline edits, or deletion. Fetcher submissions still trigger automatic revalidation.

**Shared UI.** Check these locations before adding a component:

* `packages/web/react/`: the design system (`@alforse/web-react`), with buttons, modals, comboboxes,
  menus, and layout primitives.
* `apps/erp/app/components/`: app-level pieces. The big one is `components/Table/Table.tsx`, the generic table
  used by list screens.

**Client state** is limited. Shared reference lists live in stores under `apps/erp/app/stores/` (items,
customers, suppliers, people) and are refreshed by `RealtimeDataProvider`. Ephemeral UI state uses zustand [\[20\]](#g20).
User and permission data comes from route loaders.

**Translations** use Lingui [\[5\]](#g5). Mark strings with `` t`...` `` or `<Trans>`; catalogs are compiled with `pnpm lingui:compile`.

**MES uses the same stack with a flatter structure.** It has no `modules/` folder: services are in
`apps/mes/app/services/`, routes are one flat level (about 100 files), and the UI is built for gloved hands on a tablet.

### 3.2 The gate: who is allowed to do what

Server-side entry points use `requirePermissions` for authentication and authorization.

```ts theme={null}
const { client, companyId, userId } = await requirePermissions(request, {
  create: "purchasing",
});
```

Defined at `packages/web/auth/src/services/auth.server.ts:152`, it performs these operations:

1. **Identifies the caller** from the native session cookie or, for the public API, the `alforse-key` header.
   API keys are checked by the native key owner with the route's required permissions, including rate limits.
   See [API keys](/docs/building/api-keys.md).
2. **Asks the native authorization owner for a decision.** The required permissions (`view`, `create`, `update`,
   `delete`, optionally `role`) are sent to the backend, which answers from the user's grants in the selected
   company. Grants accumulate from the employee type, user groups, and direct assignment.
3. **Rejects unauthorized requests.** An expired or missing session is destroyed and redirected to login;
   a missing permission redirects with an **Access Denied** message. A decision that does not match the
   session's user or company is refused as an inconsistent identity.
4. **Returns a request-scoped client**, `companyId`, `companyGroupId`, and `userId`.

Alforse uses **multi-tenancy** [\[10\]](#g10): one database serves multiple companies. The active company comes from
the session, not the URL.

**Row-level security (RLS)** [\[16\]](#g16) is enabled and forced on tenant tables in `packages/database/migrations/0001_baseline.sql`
(`ALTER TABLE ... FORCE ROW LEVEL SECURITY`). Each transaction sets the current company and actor as
transaction-local settings, and the policies compare rows against them. Queries for another company's records return no rows.

**Platform versus workspace.** The platform control plane (workspace lifecycle, plans, entitlements) is a separate
security domain from a customer's workspace. Platform authority never grants implicit access to a customer's
business records.

Portal users (customers and suppliers) have narrower permissions, and routes can require `{ role: "employee" }`.
`share+/` routes use an unguessable URL token instead of login authentication.

### 3.3 The service layer: ways to write

The ERP and MES screens still use the data-access shapes they were built with. The backend maps each of them to a
native owner instead of exposing a database connection.

**1. The gateway client.** `from(table).select/insert/update/delete` and `rpc(name, args)` calls go to
`/api/web/rest/v1/[relation]` and `/api/web/rest/v1/rpc/[name]`. Reads resolve to native read relations; writes
resolve to the owning product or module, which validates the command and writes through its store in
`packages/database/src/`.

**2. Named native operations.** Some screens use a Kysely [\[7\]](#g7) query builder
(`getPostgresClient` in `packages/web/database/src/client.ts`). It does not send SQL. The builder records the
structure of the query and resolves it to exactly one named operation registered in
`packages/web/database/native-operations/` (for example `purchasing.ts`, `inventory.ts`, `production-*`, `quality.ts`).
Anything it cannot map is refused by name. This is how multi-row writes, such as drag-to-reorder (`apps/erp/app/modules/shared/sort-order.ts`),
stay all-or-nothing.

**3. Native owners and the unit of work.** The heavy writes (posting a shipment or an invoice, issuing material,
completing a production quantity) are owned by classes under `products/*` and `packages/database/src/`. They run in a
**unit of work**: one Postgres connection per business transaction, with the actor and company set for the
transaction. If the body fails, everything rolls back. Hooks that must follow a successful write run only after commit.

**4. RPCs** are a narrower path: named functions called with `client.rpc("name", args)`. `get_next_sequence` from
[Part 2](#part-2-follow-one-click-all-the-way-down) is one example. Each RPC is mapped to a native owner or explicitly listed as unmapped.

MRP [\[9\]](#g9) and production scheduling logic live in `packages/web/planning` and the planning owners.

### 3.4 The database

Alforse uses PostgreSQL. There is no PostgREST layer, no Supabase project, and no edge-function runtime: the
backend is the only thing that talks to the database, as dedicated database roles (a runtime role subject to RLS,
and separate roles for the scheduler and the delivery worker).

Each **migration** [\[11\]](#g11) is a numbered SQL file in `packages/database/migrations/`: `0001_baseline.sql` is the
immutable baseline (the complete schema of a new database plus its reference rows), and every later change is a
file numbered `0002` and up, named `<ordinal>_<description>.sql`. They run in numeric order inside a
transaction owned by the runner, and an applied file is never edited, renamed, reordered, or removed (the runner
records a checksum for each). See `packages/database/migrations/README.md` and `packages/database/src/migrations.ts`.

The baseline creates about 740 tables in two schemas, `platform` (control plane and native owners) and `web`
(the contract schema the screens read), with views [\[21\]](#g21) for reads.

**Identifiers** are generated by the application, not the database: `{prefix}_{base58(uuidv7)}`, with a
globally unique prefix per record type and `text` primary keys. This keeps IDs sortable by creation time and readable in logs and URLs.

**Reads and writes.** Alforse **writes through owners** and **reads from relations** that derive display values
(totals, names, thumbnails). The write path inserts source data; the read side derives what the screen shows.

**Files** live in object storage behind the `/api/web/storage` route. The backend supports Cloudflare R2, S3-compatible
storage, and a customer storage gateway as providers (`apps/backend/src/production/blob-storage-provider.ts` and neighbours),
with per-company key namespaces, quotas, and an optional backup copy profile.

***

## Part 4: What happens after the write

Event processing is independent of the code that performs a write. Owners publish a domain event to the
**transactional outbox** (`platform."outbox"`) in the same transaction as the business change, so a rolled-back write
never emits an event and a committed write never loses one.

```text theme={null}
business write ──(same transaction)──► outbox row
                                          │ after commit
                                          ▼
             delivery worker ──► notification inbox / email, webhooks, ...
             native runner   ──► queued tasks, scheduled tasks
```

Delivery is at-least-once, so handlers are idempotent. A retry policy (maximum attempts, exponential delay with a
ceiling) decides when to retry a transient failure and when to stop on a permanent one.
`kernel/events/` defines the event bus, the retry policy, and the webhook delivery.

**The native runner** lives inside the backend process (`apps/backend/src/runner/`). A task is accepted into
`platform."backgroundTaskRun"` with a company, a name, and an idempotency key; the runner claims it with a lease,
runs the registered handler inside a transaction bound to that company, and records the outcome (`pending`,
`running`, `retrying`, `succeeded`, or `deadLettered`). A failed attempt is retried up to a maximum. The idempotency key stays the same across retries and lease
take-overs, so a handler can tell it has already done the work. Task names such as `alforse/company-export` and the
`packages/web/jobs` package keep the job names the screens use; no external job service is involved.

**Scheduled work** is defined in `apps/backend/src/runner/native-runner-schedules.ts`. Cron expressions are
evaluated in UTC, and each tick fans out to every active company. Examples: MRP every 15 minutes, a nightly
replan at 01:00, exchange-rate updates at 00:00, audit-log archiving at 02:00, notification digests every 15
minutes, rental billing and contract-time billing hourly, workflow-run retention at 04:00. A separate set of
delivery schedules covers the notification relay (every minute), maintenance due-dispatch (every two minutes),
blob cleanup, and the backup copy of new files.

**Notifications** are stored in a per-user inbox. The ERP receives changes through change feeds the backend publishes
(for example the private topic `user:<userId>:notification`, which carries which rows changed, never their data) and
reloads the rows it needs. Email goes through the configured transactional email transport.

***

## Part 5: The other engines

### Workflows: the customer's own automations

Customers configure workflows on a drag-and-drop canvas. These workflows are separate from the runner's built-in tasks:

| | Built-in tasks | Workflows |
| - | - | - |
| Written by | us, in TypeScript | customers, on a canvas |
| Stored as | code | workflow definitions and versions |
| Changed by | a deploy | the customer, at any moment |

`packages/web/workflows` and `kernel/workflows` contain the definition and validation logic. The workflow
engine records every run and step for inspection, and the runner schedules the workflow backstop (every ten minutes)
and run-history retention. See [Workflows](/docs/reference/workflows.md).

### CAD and assembly planning

CAD files (STEP) are converted into web-renderable 3D, and a collision-aware disassembly sequence is planned
for assembly instructions. This runs inside the backend (`apps/backend/src/cad/`): geometry is read with
`occt-import-js` (OpenCASCADE compiled to WebAssembly), and the motion planner lives next to it. The ERP and MES
call it through `/api/web/assembler`, which requires a bearer key, and `packages/web/viewer` renders the output in the
browser. Long conversions run as runner tasks, so a page can wait for the result or poll for it.

***

## Glossary

Numbered references used throughout this page, in alphabetical order.

**1. Action:** the function a route file exports to handle a form submission.
Runs on the server only, never shipped to the browser.

**5. Lingui:** the translation library. You mark strings in code; it extracts
them into per-language files for translators.

**6. Loader:** the function a route file exports to fetch the data its page
needs. Runs on the server only.

**7. Kysely:** a TypeScript SQL query builder. Here its builder is used only to describe an operation, which the
backend maps to a named native operation.

**8. Monorepo:** one Git repository holding several applications and shared
libraries that are versioned and released together.

**9. MRP:** Material Requirements Planning. Works out what to buy and make, and
when, given demand and current stock.

**10. Multi-tenancy:** one database and one running application serving many
separate customer companies, with each company's data invisible to the others.

**11. Migration:** one numbered SQL file describing a schema change. The baseline plus the
ordered set of later files *is* the schema.

**12. Postgres:** the relational database everything in Alforse is built on.

**15. RPC:** here, calling a named function from application code, via
`client.rpc("name", args)`. The backend maps it to a native owner.

**16. RLS (Row Level Security):** a Postgres feature where a per-table rule is
automatically added to every query. It is what keeps one company's data
invisible to another.

**17. Turborepo:** the build orchestrator that knows which packages depend on
which, so `pnpm build` only rebuilds what changed.

**18. Vite:** the build tool and dev server behind the ERP and MES.

**19. zod:** a schema library. Describe data once, get validation and a
TypeScript type from the same definition.

**20. zustand:** a small React state library. We use it only for throwaway UI
state, like whether a modal is open.

**21. View:** a saved query that behaves like a read-only table.

***

## Related

**API keys**

Authenticate programmatic access.

[Open](/docs/building/api-keys.md)

**Webhooks**

Receive events as they happen.

[Open](/docs/building/webhooks.md)

**Backups**

Snapshot and restore company data and uploaded files.

[Open](/docs/platform/backups.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.