Architecture

One journey from a button click, through the server, into Postgres, out to background jobs, and back to the screen.

Carbon 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.

This page traces a write from the UI through validation, authorization, persistence, background work, and UI refresh. Read Part 1 and Part 2 in order. The remaining sections are independent references.

Numbered references such as Postgres [17] link to definitions in the Glossary.

NOTE

A snapshot, not a contract

File paths, line numbers, and counts are accurate as of August 2026 and may change as the repository evolves.


Part 1: The map

Carbon is a monorepo [10] containing several applications and approximately 25 shared libraries, managed with pnpm workspaces and Turborepo [25]. ERP and MES are the primary user-facing applications.

One Postgres [17] database is the system of record. It also enforces row-level permissions, computes derived values, and emits change events. Part 3 describes these responsibilities.

Everything Carbon runs. The loop on the right is the surprise: Inngest is a timer and a queue, but the job itself executes back inside the ERP.

In each diagram, blue represents Carbon applications, pink represents stored state, and amber represents background work.

Key properties:

  1. The ERP app is the hub. It serves pages and executes background jobs. Inngest [5] schedules work and sends requests to /api/inngest; the ERP container executes the code. See Part 4.
  2. Most database access is not raw SQL. It goes through PostgREST [18], a service that translates client.from("purchaseOrder").select(...) into SQL. Application code accesses it through supabase-js.
  3. The database enforces tenant access. Row-level security prevents access to another company's rows, except when code explicitly uses the service-role client. See Part 3.
  4. Selected writes emit events automatically. Approximately 90 tables use database triggers to initiate subscribed search indexing, webhooks, audit logs, and customer workflows.
  5. The Rust service is optional. It converts CAD files to browser-renderable 3D models and plans disassembly sequences for assembly instructions. Core Carbon functions without it.

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, Postgres logic, background side effect, and UI refresh.

The short version

One click, all the way down. Every hop crosses a layer, and everything above the dashed line happens while the user waits.

The request completes after the row is written. Search indexing runs asynchronously.

The same story, with real files

The following paths point to the implementation as of August 2026.

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

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

tsx
primaryAction={
  permissions.can("create", "purchasing") && (
    <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

tsx
<ValidatedForm
  method="post"
  validator={purchaseOrderValidator}
  defaultValues={initialValues}
>

ValidatedForm wraps an HTML form with schema-based client and server error display. Fields such as <Input name="..."> and <Supplier name="..."> come from ~/components/Form, which exports generic inputs and Carbon-specific selectors.

3. The schema. apps/erp/app/modules/purchasing/purchasing.models.ts:127

ts
export const purchaseOrderValidator = z.object({
  id: zfd.text(z.string().optional()),
  purchaseOrderId: zfd.text(z.string().optional()),
  purchaseOrderType: z.enum(purchaseOrderTypeType, {
    /* ... */
  }),
  supplierId: z.string().min(1, { message: "Supplier is required" }),
  locationId: zfd.text(z.string().optional()),
});

zod [28] provides runtime validation and inferred TypeScript types. 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, and the service argument type is derived from it.

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

ts
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);

  const result = await insertPurchaseOrder(client, {
    ...validation.data,
    companyId,
    companyGroupId,
    createdBy: userId,
    customFields: setCustomFields(formData),
  });

  if (result.error || !result.data) {
    throw redirect(
      path.to.purchaseOrders,
      await flash(
        request,
        error(result.error, "Failed to insert purchase order")
      )
    );
  }
  throw redirect(path.to.purchaseOrder(result.data.id));
}

Carbon 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:

ExportRuns whereJob
loader [8]server onlyfetch the data a page needs
action [1]server onlyhandle a form submission
export defaultserver first, then browserthe React component

loader and action are excluded from browser JavaScript, so they can use secrets and server-only database clients.

5. The service function. apps/erp/app/modules/purchasing/purchasing.service.ts:1393

Routes call service functions instead of accessing the database directly. A service function accepts a database client as its first argument and returns Supabase's { data, error } result.

ts
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,
    status: "Draft",
    companyId: input.companyId,
    createdBy: input.createdBy /* ... */,
  })
  .select("id, purchaseOrderId")
  .single();

const [delivery, payment] = await Promise.all([
  client.from("purchaseOrderDelivery").insert({
    /* ... */
  }),
  client.from("purchaseOrderPayment").insert({
    /* ... */
  }),
]);

if (delivery.error || payment.error) {
  await deletePurchaseOrder(client, orderId); // manual undo
  return { data: null, error: delivery.error ?? payment.error };
}

Each supabase-js .insert() call is a separate HTTP request, not part of a transaction. If a later write fails, earlier writes remain committed unless explicitly reversed. Use Kysely [6] when writes require a transaction. See Part 3.3.

6. Database-side behavior. Postgres performs two additional operations.

The order number. client.rpc("get_next_sequence", ...) calls an RPC [21], a function that lives inside the database (packages/database/supabase/migrations/20241115101526_rpc-get-next-sequence.sql). It atomically increments a counter and formats the result with PO, zero-padding, and optional date tokens.

The event. Every purchaseOrder insert invokes a Postgres trigger. If the company has an active subscription for that table and operation, the trigger queues a JSON message. The route does not initiate this behavior. Part 4 traces the event.

7. The screen updates. The action ends with throw redirect(...). React Router follows the redirect, runs the destination loader, reads current database state, 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. Carbon does not use Remix or Next.js for ERP and MES. Vite [27] builds the applications, and Tailwind v4 provides styling.

Routes are files on disk. apps/erp/app/routes/ has about 1,400 of them, and the folder structure is the URL structure, with a few conventions:

In a filenameMeans
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.tsxa wrapper shared by every route in the folder
$orderIda URL parameter: $orderId.tsx → /:orderId
. in the namea / in the URL: dimensions.new.tsx → dimensions/new
_index.tsxthe page for the folder's own URL
*.server.tsignored 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.

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() read this loader data.

Getting data out. Forms post to an action, as shown in Part 2. 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/react/: the design system (@carbon/react). Buttons, modals, comboboxes, menus, and layout primitives built on Radix UI [19] and Tailwind.
  • apps/erp/app/components/: app-level pieces. The big one is components/Table/Table.tsx, a generic TanStack Table implementation used by list screens. It handles sorting, filtering, pagination, saved views, CSV import and export, column visibility, and inline editing. The server performs pagination from URL search parameters.

Client state is limited. Shared reference lists use nanostores [13] and are refreshed by RealtimeDataProvider. Ephemeral UI state uses zustand [29]. User and permission data comes from route loaders.

Translations use Lingui [7]. Mark strings with t`...` or <Trans>, then run pnpm lingui:extract.

MES uses the same stack with a flatter structure. It has no modules/ folder: services are in apps/mes/app/services/, all validators in one models.ts, routes are one flat level, 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
const { client, companyId, userId } = await requirePermissions(request, {
  create: "purchasing",
});

Defined at packages/auth/src/services/auth.server.ts:195, it performs four operations:

  1. Identifies the caller from a browser session cookie or the public API's rate-limited, scope-checked carbon-key header.
  2. Loads their permissions. Permissions are stored as <module>_<action> keys such as purchasing_create or sales_view, each mapping to the list of companies where the user holds it. They are fetched by a Postgres function called get_claims and cached in Redis for an hour. They are not in the JWT.
  3. Rejects unauthorized requests. Unauthenticated users are redirected to login; unauthorized users receive an Access Denied message.
  4. Returns a user-scoped database client, companyId, and userId.

Carbon uses multi-tenancy [12]: one database serves multiple companies. Nearly every table has a companyId and composite primary key ("id", "companyId"). The active company comes from the session, not the URL.

Row-level security (RLS) [20] adds each table's access policy to database queries. For example:

sql
CREATE POLICY "SELECT" ON "public"."reportPin"
FOR SELECT USING (
  "companyId" = ANY ((SELECT get_companies_with_employee_role())::text[])
);

Queries for another company's records return no rows. Service functions also filter by companyId for defense in depth.

The escape hatch. bypassRls: true returns a service-role [22] client for trusted employee requests on performance-sensitive paths. Because this client bypasses RLS, each loader must verify tenant ownership explicitly:

ts
if (purchaseOrder.data?.companyId !== companyId)
  throw redirect(path.to.purchaseOrders);

Company membership assigns one of three roles: employee, customer, or supplier. Portal users 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: four doors to the database

A service function accepts a database client as its first argument. Select the client based on the required security and transaction behavior.

Four ways into the database. Only the first one leaves the database's own security rules switched on.

1. The user-scoped client. What requirePermissions normally returns. Every query runs as that user with RLS applied. Use it unless you have a reason not to.

2. The service-role client (getCarbonServiceRole()) bypasses RLS. Background jobs, share+/ routes, and bypassRls: true requests use it. Every query must enforce companyId explicitly.

3. Kysely is a TypeScript SQL query builder that holds a real connection to Postgres, so it can open a real transaction. This is the answer to the "three inserts aren't atomic" problem from Part 2:

ts
// updateSortOrder (apps/erp/app/modules/shared/sort-order.ts), which every
// drag-to-reorder route uses: one UPDATE for all the rows, inside a transaction.
await db.transaction().execute(async (trx) => {
  const { rows } = await sql<{ id: string }>`
    UPDATE "quoteLine" AS t SET "sortOrder" = v."sortOrder", …
    FROM (VALUES ${values}) AS v("id", "sortOrder")
    WHERE t."id" = v."id"
      AND t."companyId" = ${companyId}
      AND t."quoteId" = ${quoteId}
    RETURNING t."id"`.execute(trx);
  // A row from another company or another quote matched nothing. Throwing
  // here rolls back the rows that did match: all or nothing.
  if (rows.length !== updates.length) throw new Error("…");
});

Kysely throws on failure and applies no RLS. Wrap calls in try/catch, authorize before execution, and scope every statement by companyId and, when row IDs come from a request, the parent document ID. Update sets in one statement instead of querying in a loop. A Node process shares one pool of 16 connections (getProcessPool()). Inside a transaction, use only the provided trx handle; requesting another connection through db can deadlock until timeout.

4. Server functions are the privileged, multi-step writes shared by the ERP, the MES, the public API and background jobs. There are about thirty in packages/server-functions/src/, one directory each, and they own the genuinely heavy writes: posting a shipment or an invoice to the general ledger, issuing material, converting a quote, CSV import. Each is built with defineServerFn, which validates the input, checks the caller's permission, runs, and returns { data, error } instead of throwing. Call one like any other function:

ts
import { serverFns } from "@carbon/server-functions";

const { data, error } = await serverFns
  .system({ db: getDatabaseClient(), companyId, userId })
  .invoke("post-shipment", { type: "post", shipmentId });

Server functions run large Kysely transactions and are callable from anywhere on the server: a route action, the MES, a background job, or the public API.

Keep function code thin and perform set-based work in Postgres. Use Inngest for work that runs for minutes and the Rust service for compute-intensive CAD geometry. MRP [11] and production scheduling live in @carbon/planning and run in Node the same way.

One small edge function [4], written for Deno [2], remains in packages/database/supabase/functions/: embedding. It is self-contained and called over HTTP with client.functions.invoke("embedding", { body }).

RPCs are a narrower fifth path: Postgres functions called with client.rpc("name", args). get_next_sequence from Part 2 is one example.

3.4 The database

Carbon uses Postgres 15 through Supabase [24], which adds PostgREST, authentication, file storage, realtime changes, and an edge-function runtime.

As of August 2026, the schema contains 923 migration files, 362 tables, 101 views, 238 database functions, and 154 enum types.

Each migration [9] is a timestamped SQL file in packages/database/supabase/migrations/, named YYYYMMDDHHMMSS_kebab-name.sql. Create one with pnpm db:migrate:new <name>, apply with pnpm db:migrate. They are applied in filename order and never edited after merge.

A typical table shows every convention at once:

sql
CREATE TABLE "itarCertification" (
    "id" TEXT NOT NULL DEFAULT id('itc'),
    "companyId" TEXT NOT NULL,
    "createdBy" TEXT NOT NULL REFERENCES "user"("id"),
    "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    "updatedBy" TEXT REFERENCES "user"("id"),
    "updatedAt" TIMESTAMP WITH TIME ZONE,
    PRIMARY KEY ("id", "companyId"),
    FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE
);
ALTER TABLE "public"."itarCertification" ENABLE ROW LEVEL SECURITY;

id('itc') generates a prefixed, sortable, URL-safe ID such as itc_3xK9....

Views [26] are the primary read path. Carbon writes to tables and reads from views. For example, the plural purchaseOrders view joins the supplier name, calculates orderTotal, converts currency, counts received quantities, and retrieves a thumbnail. The write path inserts source data; the view derives display values.

Writes land in tables; reads come back through views, which is where computed values like order totals live.

Views are defined in migrations with WITH (security_invoker = true) so the caller's RLS policies apply.

Database types are generated. pnpm run generate:types reads the local schema and writes packages/database/src/types.ts, which describes each table, view, function, and enum. Application code imports from it:

ts
import type { Database } from "@carbon/database";
type PurchaseOrderRow = Database["public"]["Tables"]["purchaseOrder"]["Row"];

Run pnpm run generate:types after pulling or creating migrations and before typechecking. Do not edit generated types directly.

Database logic enforces invariants across ERP, MES, server functions, background jobs, the public API, and direct SQL. Examples:

  • update_picking_list_status() is a trigger that recomputes a picking list's header status whenever one of its lines changes. Written from four different places; recomputing it in each caller would drift.
  • dispatch_event_batch() is the event trigger from Part 4. It must enqueue in the same transaction as the write that caused it, so a rolled-back write never emits a phantom event.

Files live in Supabase Storage buckets: private is the workhorse (attachments, drawings, quality records), public and avatars are self-explanatory, plus one bucket per company and temp-staging for large CAD uploads.


Part 4: What happens after the write

Event processing is independent of the code that performs a write. A table emits events only after a migration registers it with attach_event_trigger('tableName'). The trigger enqueues work only when the company has an active subscription for the table and operation. The same UPDATE can therefore invoke several handlers for one company and none for another.

A subscribed write becomes up to six side effects, none of them visible from the code that did the write.

PGMQ [16] stores the queue in Postgres so triggers can enqueue work in the same transaction. After commit, pg_net [15] sends an HTTP request to Inngest [5], the background job runner.

Inngest maps named events to registered HTTP functions and persists step results. Each step.run("id", fn) is a checkpoint; a retry resumes after completed steps instead of executing them again.

The wiring is three files:

WhatWhere
The clientpackages/lib/src/inngest/client.ts
The list of all 55 job functionspackages/jobs/src/inngest/index.ts
The HTTP endpoint Inngest callsapps/erp/app/routes/api+/inngest.ts

Background jobs run inside the ERP container. Inngest schedules requests and persists execution state; it does not host Carbon's job code.

Firing a job on purpose. For explicit job initiation, call the typed trigger() helper:

ts
await trigger("notify", {
  event: NotificationEvent.JobAssignment /* ... */,
});

Event names are always carbon/<something>.

Scheduled work has two sources. Inngest cron schedules run application jobs: MRP every three hours, exchange rates and audit archiving nightly. All of them are UTC, and any job that needs a company's local day computes it inside the run. Database-level maintenance uses pg_cron [14] instead, for refreshing cached aggregate tables, sweeping the event queue and purging old notifications. pg_cron also decides when two application jobs run: every fifteen minutes it checks whether any notifications are due to be rolled into a digest, and at 04:00 whether any workflow run history is due for cleanup, and it wakes the Inngest function only when there is work.

Notifications originate in packages/jobs/.../notifications/notify.ts and target in-app records, Resend email, and Slack. The top bar receives in-app records through Realtime. packages/notifications defines event types but does not send messages.


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 Inngest jobs:

Inngest jobsWorkflows
Written byus, in TypeScriptcustomers, on a canvas
Stored ascodeJSON rows in workflowVersion
Runs asservice role, sees everythingthe workflow's owner, with their RLS
Changed bya deploythe customer, at any moment

The WORKFLOW branch of the Part 4 event pipeline maps row changes to catalog events, finds subscribed workflows, and queues a run for each. packages/workflows contains pure definition and validation logic. packages/jobs/src/workflows/ contains the runtime. Carbon records every run and step for inspection.

The Assembler: the Rust service

apps/assembler plus crates/ is a Rust HTTP service that converts CAD files (STEP) into web-renderable 3D (GLB) and plans a collision-free disassembly sequence for assembly instructions. It wraps two C++ libraries (OpenCASCADE for geometry, FCL for collision). packages/viewer renders its output in the browser. The service is optional; other components start without it.

The API is asynchronous. A caller submits work, receives a job ID, and waits for a callback or polls as a fallback. The service reads input and writes output through signed storage URLs, without routing files through ERP or holding database credentials. Redis stores job state and result pointers and is required at startup.


Part 6: The repo map

Applications are under apps/, except for the root docs/ application:

AppWhat it isDev port
erpThe main product, and the host for all background jobs. By far the largest.3000
mesShop floor: job operations, work centers, scheduling. Touch-first.3001
academyTraining app for new Carbon users.4111
starterA minimal example app showing auth + database + forms.4000
assemblerThe Rust CAD/3D service.varies
docsPublic documentation site. The only Next.js app here.3002

Libraries are under packages/:

PackageWhat it does
reactThe design system. Check here before writing any UI.
databaseGenerated types, migrations, edge functions, database clients.
server-functionsPrivileged, transactional writes (posting, issuing, converting) shared by the apps, the API and jobs.
planningMRP and the finite scheduler.
authLogin, sessions, requirePermissions, API keys, permission caching.
formValidatedForm, field components, zod helpers.
jobsEvery Inngest background function, plus the workflow runtime.
libShared server utilities: the Inngest client, trigger(), email, Slack.
utilsPure helpers: dates, money, arrays, strings, BOM maths.
workflowsCustomer workflow schema, validation and pure runtime. No I/O.
documentsPDF generation, email templates, barcode and label rendering.
localeLingui setup and language switching.
envThe one place environment variables are read and validated.
loggerStructured logging, with request-id correlation.
notificationsThe notification event taxonomy (enums only).
printingPrinter registry, label queue, physical print delivery.
onboardingNew-company setup content.
kvRedis client plus rate limiting.
stripeBilling. Cloud edition only.
eeEnterprise-only code: Xero, Slack, Jira, Linear, Onshape, plan gating.
viewer3D model and assembly-instruction rendering.
tiptapRich text editor.
devThe crbn CLI that runs your local stack.
checksRepo conformance rules enforced in CI.
configShared tsconfig, Tailwind theme, vitest preset. No runtime code.
glossaryCanonical manufacturing term definitions used in field help.
harnessInternal AI-agent tooling. Not shipped.

Inside an ERP module (there are eighteen, under apps/erp/app/modules/{module}/):

purchasing/
├── purchasing.models.ts    zod schemas and the types derived from them
├── purchasing.service.ts   every database operation for this module
├── purchasing.server.ts    server-only helpers (optional)
├── types.ts                shared types
├── index.ts                barrel, import from "~/modules/purchasing"
└── ui/                     React components, grouped by feature

Part 7: Where it all runs

The same codebase runs locally, in Carbon-operated cloud environments, and on customer-managed servers. Environment configuration determines the deployment model.

The same code base in three shapes, and the four CI workflows that move it between them.

7.1 On a laptop

pnpm dev runs crbn up. The CLI starts an eleven-container service stack, waits for Postgres, applies migrations, regenerates database types, seeds a test user, and starts the applications as host Node processes for hot reload.

Each Git worktree receives isolated ports, a database, and credentials. Worktrees share one Redis container but use separate numbered databases.

7.2 The cloud Carbon runs

Carbon operates a shared cloud on Vercel and dedicated customer installations on AWS.

The shared cloud runs on Vercel. app.carbon.ms for ERP, mes.carbon.ms for MES, learn.carbon.ms (academy) and docs.carbon.ms all resolve to Vercel, as does a separate EU region (app.eu.carbon.ms, mes.eu.carbon.ms) served by its own pair of projects. Each React Router app opts in through its react-router.config.ts, which enables vercelPreset() from @vercel/react-router when the VERCEL environment variable is present and otherwise does nothing. The same source therefore builds for Vercel and containers. Vercel's Git integration performs deployment.

Dedicated installations run on AWS. ERP and MES Docker images run on ECS Fargate [3]. Each service uses a load balancer on port 3000, a /health check, and autoscaling from one to ten tasks based on CPU and memory. sst.config.ts defines this infrastructure with SST [23]. The AWS with SST recipe deploys the same stack. itar.carbon.ms runs in AWS GovCloud.

Each AWS customer has a separate installation. On each deployment, ci/src/deploy.ts reads the workspaces table and runs sst deploy --stage prod once per row, each with that customer's own AWS account, region, domain, certificates, Supabase project, and Redis. Customers are isolated by companyId within an installation (Part 3) and by separate infrastructure. Adding an AWS customer requires a workspace row. If required workspace fields are missing, deployment skips that row and logs the omission.

Postgres is managed Supabase on both paths. There is one Lambda: the Assembler ships as a single image that runs either as a Lambda function behind an API gateway (the default) or as a container, which is why 3D conversion can be turned on per customer without changing the main stack.

7.3 On a customer's own server

contrib/deploying/simple-docker-caddy/ contains a complete self-hosted stack: a Docker Swarm file that brings up Postgres, the Supabase services, Redis, Inngest and both apps on one machine, with Caddy in front as the only thing exposed to the internet. Secrets are Swarm secrets rather than environment variables in a file. Migrations run as a throwaway one-shot container before the apps roll. scripts/backup.sh creates a database dump and uploaded-file archive, and scripts/harden.sh configures the firewall. See Backups.

Which features exist is decided by one variable, CARBON_EDITION: community, cloud, enterprise or test. What each of those buys you is the licensing model. Self-hosting defaults to community; the cloud fan-out sets enterprise.

7.4 How a merge becomes production

Pushing to main triggers each Vercel project's Git-integrated build. AWS deployment uses four independent GitHub Actions workflows:

WorkflowFires whenWhat it does
check.ymlevery pull requestBiome, typecheck, tests, translation and workflow-catalog checks; an edge-functions job fails any ../ import out of a function's directory, then runs deno check
deploy.ymlmerge to main touching apps/erp, apps/mes or packagesbuilds both images from the one root Dockerfile, pushes them to Amazon's image registry tagged with the commit, then fans out sst deploy per workspace
supabase.ymlmerge touching packages/database/supabaseapplies migrations and deploys edge functions, per workspace
inngest.ymlafter deploy.yml succeedscalls PUT /api/inngest on each app so Inngest learns the current list of jobs

check.yml does not build application images, so a successful pull request can still fail during image build. Database and application deployments run without ordering, so migrations must remain compatible with the previous application version.

There is no version tagging and no rollback button. An image is identified by its commit, so rolling back means redeploying an older commit. Configuration is not held in a secrets manager: values are read from the workspaces table at deploy time and set as plain environment variables on the container.

7.5 Knowing what is happening

LogTape emits colored local logs and newline-delimited JSON in production. It redacts sensitive fields and propagates request IDs. Carbon does not provide separate log shipping; use CloudWatch on AWS or docker service logs when self-hosted. PostHog provides product analytics and is disabled in controlled environments. Carbon does not use Sentry or distributed tracing.


Part 8: There's a bug, where do I look?

Start with the observed behavior and follow the request path inward.

Start from what the user saw, then read down the one column that matches.

Common failure modes:

  • TypeScript says a column doesn't exist. Run pnpm run generate:types. Your generated types are behind your migrations.
  • A query returns nothing but the row is definitely there. RLS. Either the companyId is wrong or you are using a user-scoped client where you needed service role.
  • A write half-succeeded. Chained supabase-js calls are not a transaction. Look for a manual rollback that didn't cover the failure.
  • A number on a list screen is wrong. Check the view that computes it.

Part 9: House rules

The root AGENTS.md contains the complete rules. Key constraints:

  • pnpm, never npm.
  • Never use JavaScript Date for parsing, formatting or arithmetic. Use @internationalized/date and @carbon/utils. A build check enforces this. Timezones in a manufacturing schedule are not cosmetic.
  • Never hand-edit the generated database types. Regenerate them.
  • Never skip companyId scoping, even though RLS would probably catch it.
  • Never chain supabase-js writes and call it a transaction. Use Kysely, or an RPC.
  • Never query inside a loop. Collect the ids and make one .in() call.
  • One {module}.service.ts and one {module}.models.ts per module. Don't scatter them.
  • Run pnpm run generate:types after any migration, before typechecking.

Useful commands:

bash
pnpm dev                                        # boot everything
pnpm db:migrate:new <name>                      # create a migration
pnpm db:migrate                                 # apply migrations
pnpm run generate:types                         # regenerate database types
pnpm exec turbo run typecheck --filter=erp      # typecheck one package
pnpm run lint                                   # Biome
pnpm run test                                   # unit tests

Typecheck a scoped package with --filter; whole-repository typechecking can exhaust memory.


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.

2. Deno: an alternative JavaScript runtime to Node.js. Our one remaining edge function (embedding) is written for it, which is why it uses https:// imports instead of node_modules.

3. ECS Fargate: Amazon's service for running Docker containers without managing the servers underneath. The ERP and MES run here in production.

4. Edge function: a small server-side program deployed next to the database, called over HTTP and awaited like a normal function. Ours are written for Deno.

5. Inngest: the background job runner. Send it an event; it calls your function over HTTP and remembers each completed step, so a retry resumes rather than restarts.

6. Kysely: a TypeScript SQL query builder. Unlike supabase-js it holds a real database connection, so it can run real transactions. It also bypasses RLS.

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

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

9. Migration: one timestamped SQL file describing a schema change. The full ordered set of them is the schema; there is no separate schema file.

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

11. MRP: Material Requirements Planning. Works out what to buy and make, and when, given demand and current stock. Runs in Node, from @carbon/planning.

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

13. nanostores: a very small state library. We use it for reference lists (items, customers, suppliers) that many screens read and Realtime keeps fresh.

14. pg_cron: a Postgres extension that runs SQL on a schedule, inside the database. Used for cache refreshes and queue sweeping.

15. pg_net: a Postgres extension that lets the database make outbound HTTP calls. We use it to post events straight to Inngest after a commit, above all the ping that wakes the event queue.

16. PGMQ: a message queue implemented in ordinary Postgres tables. Lets a database trigger enqueue work inside the same transaction as the write that caused it.

17. Postgres: the relational database everything in Carbon is built on. Version 15.

18. PostgREST: a service that exposes Postgres tables and views as a REST API. Every client.from(...).select(...) is a PostgREST call, not raw SQL.

19. Radix UI: an unstyled, accessible React component library. Our design system adds Tailwind styling on top of it.

20. 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.

21. RPC: here, calling a function defined inside Postgres from application code, via client.rpc("name", args).

22. Service role: a database credential that bypasses all RLS. Powerful; use carefully.

23. SST: infrastructure-as-code for AWS, written in TypeScript. sst.config.ts defines our production cluster.

24. Supabase: an open-source bundle around Postgres: REST API, auth, storage, realtime and edge functions. We run its containers locally and use the managed version in production.

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

26. View: a saved query that behaves like a read-only table. Carbon writes to tables and reads from views; computed values like order totals live in the view.

27. Vite: the build tool and dev server behind all our React apps.

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

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