Skip to content

Tech stack and code structure

How BizOS code is organised and which technologies it uses. Rationale for the core choices is in ADR-0002, ADR-0003, ADR-0004, ADR-0006, ADR-0007, ADR-0008, ADR-0009, ADR-0010, ADR-0011 (superseded), and ADR-0012.

Stack

Decided items are agreed. Proposed items are the default until an ADR accepts or replaces them.

Layer Choice Status
Design approach Domain-Driven Design: modules (level 1) contain features (level 2) Decided
Frontend React Decided
Backend NestJS Decided
Language TypeScript (strict) for frontend, backend, and shared packages Proposed
Deployment shape Modular monolith: one API app and one worker app, split by module inside; every module can be extracted into its own service (see Module communication and extraction) Proposed
Frontend build Vite Proposed
Frontend data and forms TanStack Query (server state), React Router, React Hook Form with Zod Proposed
API style REST with OpenAPI generated from NestJS; typed client generated for the frontend Proposed
Commands, queries, events @nestjs/cqrs inside each module; integration events between modules Proposed
Database PostgreSQL Decided
Tenancy model Single PostgreSQL database with shared schema multi-tenancy (ADR-0012) Decided
Background jobs Redis with BullMQ (@nestjs/bullmq) Proposed
File storage S3-compatible object storage Proposed
Repository layout Nx monorepo with pnpm workspaces Decided
Backend code generation NestJS CLI 11 (root dev dependency, nest-cli.json at the root) for elements inside a module; Nx generators for apps and libraries Decided
Module granularity One Nx library per module and platform (libs/api/<module>, libs/web/<module>), plus one contract library per backend module once another module uses it (libs/api/contracts/<module>) Decided
Testing Jest (backend), Vitest and Testing Library (frontend), Playwright (end-to-end); see Testing Decided
ORM and migrations Drizzle ORM with node-postgres (pg); drizzle-kit for migrations Decided
Local database PostgreSQL 17 in Docker Compose (docker-compose.yml) Proposed
CI and delivery Gitea Actions: ci.yml (lint, test, build) on every push and pull request; deploy.yml started by hand builds Docker images for api, web, admin, docs (MkDocs specs site), pushes them to the Gitea registry, migrates, and switches the environment. No automatic deploys Decided
UI components and styling shadcn/ui (Radix base, Nova preset, Lucide icons) on Tailwind CSS 4, themes astrovista (default, dark) and twitter, in libs/shared/ui Decided

Levels

Level Name DDD meaning Example
1 Module A bounded context: owns its domain model, its tables, its API, and its UI routes crm
2 Feature A vertical slice inside a module: one capability with its use cases, endpoints, and screens crm/opportunities

A module is the unit of ownership: one owner, one folder, one public API. A feature is the unit of delivery: small enough for one person in a few days. Specs follow the same split: a spec area maps to a module and a spec file to a feature (for example specs/admin-portal/02-tenant-management.md maps to admin/tenant-management).

Modules

Module Scope Example features
iam Identity and access for tenant users sign-in, sessions, password-reset, my-profile, roles-permissions, user-management, audit-log, notification-center
system Tenant-level system administration done by tenant admins inside their workspace company-profile, settings, reference-data
tenancy Tenant registry, tenant resolution from subdomain, per-tenant databases and migrations tenant-registry, tenant-resolution, tenant-migrations
onboarding Sign-up and automated tenant provisioning sign-up, provisioning, default-configuration
billing Plans, entitlements, subscriptions, invoices, payments plan-catalog, entitlements, subscriptions, invoices
admin Internal admin portal for operators (see specs) operator-access, tenant-management, provisioning-monitor, backup-restore, system-health
crm Customers and sales customers, contacts, opportunities, products, transactions
contracts Contract tracking contracts, expiry-alerts
tasks Task management task-board, assignments
data-import Excel/CSV import file-upload, column-mapping, import-jobs
integrations External systems api-keys, public-api, inbound-webhooks
dashboard Executive KPIs kpi-calculation, dashboard
assistant AI assistant ai-gateway, chat, draft-and-confirm

Adding a module needs an ADR; adding a feature inside a module does not.

Libraries created so far: libs/api/ iam, system, tenancy, crm, admin (all imported by apps/api) and shared; libs/web/ iam, admin. The other modules get a library when their first feature starts. Contract libraries: libs/api/contracts/tenancy (@bizos/api-tenancy-contracts), whose TenantDirectory port (tenant counts and tenants by state) is bound by tenancy and used by the admin dashboard.

Repository layout

apps/                        Thin shells: bootstrap, configuration, wiring of module libraries
  api/                       NestJS HTTP API
  worker/                    NestJS standalone process for background jobs (no HTTP server)
  web/                       React app for tenant users (<tenant>.<root-domain>)
  admin/                     React app for the admin portal (admin.<root-domain>)
  <app>-e2e/                 Playwright end-to-end tests for one React app, e.g. apps/web-e2e
libs/
  api/<module>/              One NestJS library per backend module, e.g. libs/api/tenancy
  api/contracts/<module>/    What other modules may use from a module: ports, their types, integration events
  api/shared/                Backend shared kernel: tenant context, auth guards, base types, event bus
  web/<module>/              One React library per frontend module, e.g. libs/web/admin
  shared/ui/                 Shared React components and design tokens
  shared/api-client/         Typed client generated from the OpenAPI spec
db/                          drizzle-kit configs, generated migrations, local database init
architecture/                This folder
specs/                       Functional specs

Every module is an Nx library with its own package.json, named @bizos/<platform>-<module> (for example @bizos/api-admin); a contract library is named @bizos/api-<module>-contracts. Apps import modules by that name only; deep imports into a library are rejected by lint.

Backend module (Clean Architecture)

Every backend module (libs/api/<module>) follows Clean Architecture (Hexagonal Ports and Adapters) with strict dependency inversion (ADR-0010):

flowchart TD
  subgraph Presentation["1. Presentation Layer (features/)"]
    ctrl["Controllers (REST endpoints)"]
    guard["Guards & Middlewares"]
  end
  subgraph Application["2. Application Layer (features/)"]
    service["Use Case Services"]
    cqrs["Command & Query Handlers"]
    dto["Validated Request & Response DTOs"]
  end
  subgraph Domain["3. Domain Core (domain/)"]
    entity["Entities & Value Objects"]
    rules["Pure Business Rules"]
    port["Repository Interfaces (Ports)"]
  end
  subgraph Infrastructure["4. Infrastructure Layer (infrastructure/)"]
    drizzle["Drizzle ORM Schemas & Repositories"]
    db["Database Connection & Pools"]
    client["External Adapters"]
  end

  Presentation --> Application
  Application --> Domain
  Infrastructure -. "implements" .-> port
  Application -. "injects interface" .-> port
Hold "Alt" / "Option" to enable pan & zoom
libs/api/crm/
  package.json                 @bizos/api-crm, Nx tags
  src/
    index.ts                   Exports only the NestJS module, for apps to import
    lib/
      api-crm.module.ts        NestJS module, wires everything below and binds the module's ports
      domain/                  Pure core: Entities, value objects, domain events, repository interfaces
      features/
        opportunities/         Vertical slice per feature
          opportunities.controller.ts  Presentation: REST endpoint, HTTP status, auth guard
          opportunities.service.ts     Application: Use case orchestration
          dto/                         Validated request & response shapes
          opportunities.spec.ts        Pure unit tests with mocked repository ports
        customers/
      infrastructure/          Adapters: Drizzle schemas, repository implementations, external clients

Layer Responsibilities & Invariants:

  1. Domain (domain/): Pure business logic with zero framework or database dependencies. Entities and value objects enforce business rules; repository interfaces specify storage contracts.
  2. Application (features/<feature>/): Use cases orchestrate domain logic and call repository interfaces. Injects tokens (@Inject(USER_REPOSITORY)), never concrete infrastructure classes.
  3. Presentation (features/<feature>/): Receives HTTP requests, parses payloads via DTOs, executes auth guards, and delegates to application use cases.
  4. Infrastructure (infrastructure/): Implements repository interfaces with Drizzle ORM, executes SQL, and handles database connections.

Dual Identity and Tenancy Architecture

BizOS enforces clear separation between platform operations and customer business data (ADR-0012, USR spec):

flowchart LR
  subgraph Realm1["System & Operations Realm"]
    opUser["Operator Accounts<br/>(Owner, Operator, Support)"] --> adminHost["admin.<root-domain><br/>apps/admin"]
    adminHost --> adminApi["libs/api/admin"]
    adminApi --> controlData[("bizos_dev<br/>Platform & Control Data")]
  end

  subgraph Realm2["Customer & Tenant Realm"]
    custUser["Tenant Users<br/>(Admin, Manager, Member, Guest)"] --> tenantHost["{tenant}.<root-domain><br/>apps/web"]
    tenantHost --> tenantApi["libs/api/iam, tenancy, crm"]
    tenantApi --> tenantData[("bizos_dev<br/>Tenant Data (tenant_id)")]
  end
Hold "Alt" / "Option" to enable pan & zoom
  • Credential Isolation: Operator accounts are global platform records; tenant user accounts are scoped by tenant_id. Customer credentials cannot authenticate the admin portal. An authenticated operator can open a tenant Customer Portal without a customer password (ambient SSO): the API provisions a matching IAM platform user if needed, issues a tenant-realm session, and tenant APIs may accept an operator JWT when x-bizos-operator-access is set.
  • Routing & Subdomains: The hostname determines identity realm (admin.<root-domain> vs <tenant>.<root-domain>).

Contract library

libs/api/contracts/crm/
  package.json                 @bizos/api-crm-contracts, Nx tags
  src/
    index.ts                   Everything other modules may use from crm
    lib/
      customer-directory.port.ts     Abstract class: the port and its injection token
      customer-directory.types.ts    Serializable request and response types
      events/
        customer-created.event.ts    Integration event: type name, version, payload

Frontend module (Feature-Based Architecture)

Frontend code for both the Tenant Web App (apps/web) and the Admin Portal (apps/admin) follows a Feature-Based Architecture (Feature-Driven structure). Instead of organizing files by technical type (controllers/, views/, models/), every business capability is organized as a self-contained feature folder under libs/web/<module>/src/lib/features/<feature>/.

libs/web/<module>/
  package.json                 @bizos/web-<module>, Nx tags (platform:web, type:module, scope:<module>)
  src/
    index.ts                   Public API: exports module routes and root views
    lib/
      routes.tsx               Module router linking feature pages
      features/
        <feature-name>/        Self-contained feature slice (e.g. user-management, sign-in)
          pages/               Route-level screen components (e.g. UserManagementPage)
          components/          UI components used exclusively by this feature (e.g. UserTable, AddUserDialog)
          api/                 API client fetch calls and server state mutations for this feature
          hooks/               Custom React hooks encapsulating feature state and data workflows
          types/               Feature-specific interfaces and DTOs (e.g. ManagedUser)
          <feature>.spec.tsx   Co-located component and integration tests

Core Principles for Frontend:

  1. Self-Contained Vertical Slices: All assets required for a feature (page, components, API calls, types, tests) live together inside that feature folder. Deleting or refactoring a feature is clean and contained.
  2. Thin App Shells: apps/web and apps/admin are thin application shells. They configure the HTML entry point, root CSS, router context, and mount module routes (<IamRoutes>, <AdminRoutes>). They contain zero business logic or feature components.
  3. Design System Boundary (libs/shared/ui): Reusable design system primitives (Button, Input, Dialog, Table, Badge, Sidebar) live in libs/shared/ui. They are stateless, presentation-only, and completely decoupled from business domain models. Feature components inside libs/web/<module> compose these UI primitives with domain logic.
  4. Co-location over Separation by Type: Keep tests, styles, and API clients as close to where they are consumed as possible.

Nx tags and dependency rules

Each project has three tags, and eslint.config.mjs at the root enforces the rules below.

Tag Values Rule
type app, module, contract, shared, e2e app may use module, contract, and shared; module may use contract and shared, never another module; contract may use only contract; shared may use only shared; e2e may use only shared
platform api, web A project may depend only on projects of the same platform
scope module name or shared Identifies the module; used for ownership and future rules

Adding a module

# Backend
npx nx g @nx/nest:library libs/api/<module> --name=api-<module> --importPath=@bizos/api-<module> \
  --unitTestRunner=jest --strict --tags="platform:api,type:module,scope:<module>"

# Backend contracts, when another module first needs something from <module>
npx nx g @nx/js:library libs/api/contracts/<module> --name=api-<module>-contracts \
  --importPath=@bizos/api-<module>-contracts --bundler=none --unitTestRunner=none \
  --tags="platform:api,type:contract,scope:<module>"

# Frontend
npx nx g @nx/react:library libs/web/<module> --name=web-<module> --importPath=@bizos/web-<module> \
  --bundler=none --unitTestRunner=vitest --tags="platform:web,type:module,scope:<module>"

Then create domain/, features/, and infrastructure/ under src/lib/, add the library to the consuming app's (or module's) package.json as "@bizos/<name>": "workspace:*", and run pnpm install and npx nx sync.

Adding code inside a backend module

Use the NestJS CLI with the target folder as the last argument. Paths are relative to the repository root, and the element is registered in the nearest NestJS module.

npx nest g controller tenant-management libs/api/admin/src/lib/features
npx nest g service tenant-management libs/api/admin/src/lib/features
npx nest g guard tenant-state libs/api/tenancy/src/lib/infrastructure --dry-run   # preview first

Do not use nest g application, library, or sub-app; create apps and libraries with Nx as above.

Module communication and extraction

Every backend module is written so it could run as its own service later, without changing its callers or its business code (ADR-0008). The module, not the feature, is the unit of extraction.

flowchart LR
  subgraph api["apps/api (one process today)"]
    tasks["tasks module"] -- "injects" --> port["CustomerDirectory port<br/>@bizos/api-crm-contracts"]
    crm["crm module"] -- "binds" --> port
  end
  crm -- "crm.customer-created" --> bus[("Integration events<br/>outbox + BullMQ")]
  bus -- "delivers at least once" --> tasks
Hold "Alt" / "Option" to enable pan & zoom
Need Use Never
Read data owned by another module Its port (batch reads), or a local copy kept up to date from its events Import its module library, join or read its tables
React to something that happened in another module Subscribe to its integration event Be called from inside its transaction
Change data owned by another module A command on its port, or an event it reacts to Write its tables

Contracts

  • A contract library holds ports (abstract classes), their request and response types, and integration event types. It imports only other contract libraries: no NestJS, Drizzle, or entity types, and no functions or classes with behaviour.
  • Types are serializable: plain objects, strings, numbers, booleans, arrays; dates as ISO 8601 strings; ids as UUID strings.
  • Ports offer batch reads (getByIds(ids)) so callers never loop over remote calls.
  • Change contracts compatibly: add optional fields; for a breaking change add a new port method or a new event version and keep the old one until no consumer uses it.
  • The module that implements ports is decorated @Global() and exports only its port bindings, so other modules inject the port without importing the module. The app chooses the implementation by importing the module; after extraction it imports a module that binds the same ports to HTTP adapters instead.
// libs/api/contracts/crm: the port
export abstract class CustomerDirectory {
  abstract getByIds(ids: string[]): Promise<CustomerSummary[]>;
}

// libs/api/crm: the binding
@Global()
@Module({
  providers: [{ provide: CustomerDirectory, useClass: CustomerDirectoryService }],
  exports: [CustomerDirectory],
})
export class ApiCrmModule {}

// libs/api/tasks: the caller
constructor(private readonly customers: CustomerDirectory) {}

Integration events

  • Domain events from @nestjs/cqrs stay inside their module. A module publishes an integration event only when another module needs to react.
  • Envelope: id (UUID), type (<module>.<event> in kebab case, e.g. crm.customer-created), version (integer), occurredAt, tenantId (null for control-plane events), correlationId, payload.
  • An event is written in the same transaction as the change that caused it (transactional outbox) and delivered at least once by apps/worker. Consumers are idempotent (deduplicate on id) and do not rely on order across event types.

HTTP, jobs, and configuration

  • All controllers of a module are served under /api/<module>/: @Controller('<module>/<feature>') behind the global api prefix.
  • BullMQ queue names start with the module name, e.g. data-import.rows.
  • Module settings are environment variables prefixed with the module name in upper snake case (CRM_, DATA_IMPORT_). A module never reads another module's settings; shared infrastructure settings (database URLs, Redis) come from @bizos/api-shared.

Extracting a module

Extract only with a concrete reason: independent scaling (for example assistant), heavy background load (data-import), fault isolation or a separate release cadence (integrations), or a separate team. Then:

  1. Create apps/<module>-service that imports the module and its job handlers.
  2. Give the module its own database (one per tenant if it uses tenant data): move its PostgreSQL schema and start a separate migration folder from a baseline of that schema.
  3. In apps/api and apps/worker, replace the module import with a module that binds the same ports to HTTP adapters. Callers do not change.
  4. Move the module's event consumers to the new app.
  5. Route /api/<module>/ to the new service at the reverse proxy.

Frontend UI

  • shadcn/ui components live in libs/shared/ui/src/components/ui/, configured by libs/shared/ui/components.json. Themes are :root[data-theme="<name>"] (and :root[data-theme].dark) blocks of light and dark CSS variables in libs/shared/ui/src/styles/globals.css, selected by data-theme and the dark class on <html>. Light tokens must use that higher-specificity selector so they are not overwritten by the AstroVista :root fallback that follows. index.html sets the default, and each app calls applyStoredTheme() from @bizos/shared-ui/lib/theme before rendering so the user's choice applies on load; the ThemeSwitcher component (@bizos/shared-ui/components/theme-switcher) in the portal headers and the sign-in screens changes it. The choice is stored in the bizos-theme cookie on the parent domain (<root-domain>), so admin.<root-domain> and <tenant>.<root-domain> share it; on localhost both dev servers share it because cookies ignore the port. A new theme also needs an entry in THEMES in that file. Available themes: astrovista (Outfit, Merriweather, Fira Code, the default in Dark mode when no preference is set) and twitter (Open Sans). Theme fonts are Fontsource packages imported in libs/shared/ui/src/lib/fonts.ts. Add a theme from tweakcn by copying its registry cssVars into a new block rather than running shadcn add, which overwrites the default :root variables.
  • Fonts load from JavaScript: each app's main.tsx imports @bizos/shared-ui/lib/fonts. Do not @import font packages in CSS; on Windows the Tailwind Vite plugin rewrites their url()s into paths the dev server cannot serve.
  • Each React app's src/styles.css imports @bizos/shared-ui/globals.css and adds @source "../../../libs" so Tailwind sees classes used in libraries. Apps load Tailwind through @tailwindcss/vite.
  • Import components by subpath, for example import { Button } from '@bizos/shared-ui/components/ui/button'; class names are merged with cn from @bizos/shared-ui/lib/utils.
  • Table row Action columns use RowActions from @bizos/shared-ui/components/row-actions: outline black pencil (Edit) and outline red trash (Delete). Do not use text links or filled icon buttons for those actions.
  • Add a component with pnpm dlx shadcn@latest add <name> -c libs/shared/ui. Generated files may be edited; keep edits small so later updates can be compared with --diff.
  • Components used by one module only go in that module's libs/web/<module>; move them to libs/shared/ui when a second module needs them.
  • A React app renders the routes its module exports. apps/admin renders AdminRoutes from @bizos/web-admin. apps/web renders IamRoutes from @bizos/web-iam. An admin sees AdminRoutes after sign-in. A customer sees a shell with a header, sidebar, and footer, and a note that the customer screen will be built later. Both roles can open change password from the signed-in screen. Success deletes every session for that user and returns them to sign-in. The admin portal frame (shadcn sidebar, header, page not found) is the layout feature of libs/web/admin; each feature's pages are added as child routes of that layout. The layout provides the TooltipProvider the sidebar's tooltips need.
  • tsconfig.base.json has no DOM types; React projects add "lib": ["es2022", "dom"] to their tsconfig.lib.json, tsconfig.app.json, or tsconfig.spec.json when their code touches window or document.
  • The @nx/enforce-module-boundaries rule is off for libs/shared/ui/src/components/ui/ only, because shadcn writes self-imports through the @bizos/shared-ui/... alias.
  • Dev server ports: web 4200, admin 4201. Both dev servers proxy /api to the API on port 3000 so the session cookie stays on the app's origin.

Testing

Level Tool Where Run
Backend unit and integration Jest with @swc/jest *.spec.ts next to the code, in libs/api/<module> and the NestJS apps pnpm test, or npx nx test <project>
Frontend unit and component Vitest with Testing Library in jsdom *.spec.tsx next to the component, in libs/web/<module>, libs/shared/ui, and the React apps pnpm test, or npx nx test <project>
End-to-end Playwright (Chromium) apps/<app>-e2e/src/*.spec.ts, one project per React app pnpm e2e, or npx nx e2e <app>-e2e
  • Each Vitest project loads src/test-setup.ts, which adds the @testing-library/jest-dom matchers (toBeInTheDocument, toHaveValue, ...). Projects that render the shared sidebar also stub window.matchMedia and ResizeObserver there, because jsdom has neither.
  • Query the screen the way a user sees it: getByRole, getByLabelText, getByText. Use a test id only when an element has no accessible name. Simulate input with @testing-library/user-event, not fireEvent.
  • End-to-end tests start the app's dev server unless one is already running on its port; set BASE_URL to run them against a deployed environment. Install the browser once with pnpm exec playwright install chromium.
  • End-to-end tests cover critical flows that cross apps or the API, such as sign-in and tenant provisioning; everything else is tested at unit or component level. They drive the browser and never import app code.
  • Rule 8 applies at every level: a test that checks a spec requirement starts its name with the requirement ID.

Database access

There are two kinds of database, each with its own schema set and migrations:

Database Used by Schema file in a module Migrations
Control plane (one) tenancy, onboarding, billing, admin src/lib/infrastructure/database/control-plane.schema.ts db/migrations/control-plane/
Tenant (one per tenant) All other modules src/lib/infrastructure/database/tenant.schema.ts db/migrations/tenant/
  • drizzle-kit finds schema files by these names (see db/*.config.ts), so a module adds tables by creating the file; no central list.
  • Each module puts its tables and enums in a PostgreSQL schema named after the module, declared with Drizzle's pgSchema('<module>') (dashes become underscores, e.g. data_import). The existing tenants (tenancy) and settings (system) tables are still in public; see open questions.
  • Tables belong to the module that defines them. Schema files are not exported from a module's index.ts; other modules use its contracts.
  • No foreign keys, joins, views, or transactions across module schemas. A row in another module is referenced by its UUID only.
  • Primary keys are UUIDs, so rows keep their identity when a module moves to its own database.
  • DatabaseModule from @bizos/api-shared provides CONTROL_PLANE_DB (a Drizzle instance) and TenantDatabaseManager, which keeps one connection pool per tenant (get(tenantId, connectionString)). A pool idle longer than TENANT_POOL_IDLE_MS is closed, and release(tenantId) closes one pool on demand.
  • The tenancy module applies db/migrations/tenant/ to one tenant or to every tenant that is not deleted. pnpm db:migrate:tenant migrates only the local development database.
  • Column names are written explicitly in snake_case; timestamps use timestamp with time zone.
  • Change a schema, then run pnpm db:generate:control-plane or pnpm db:generate:tenant, review the generated SQL, and commit it with the code. Never edit an applied migration.

Rules

  1. Domain is pure. domain/ imports nothing from NestJS, the ORM, or other modules. Business rules live here, not in controllers or handlers.
  2. Modules talk through contracts. A module never imports another module library. Synchronous calls go through ports in the other module's contract library (@bizos/api-<module>-contracts); side effects go through integration events. No cross-module database joins, foreign keys, or transactions. See Module communication and extraction.
  3. Features stay inside their module. A feature may use its module's domain and shared code, but not another feature's internals. Code needed by two features moves to the module's domain/ or to shared/.
  4. Tenant context is implicit and mandatory. Every tenant request resolves the tenant from the subdomain once; repositories get the tenant database connection from that context. No handler passes a tenant ID by hand, and no query can run without a tenant context except in tenancy, onboarding, billing, and admin, which use the control-plane database. Across a module boundary that may become remote, the tenant travels explicitly: in the event envelope, or in request headers.
  5. Validate at the edge. DTOs are validated in the controller; the domain assumes valid input and enforces invariants.
  6. Boundaries are checked by tooling. Nx project tags and the @nx/enforce-module-boundaries lint rule fail the build when rule 1, 2, or 3 is broken.
  7. Naming. Folders and files in kebab-case; module and feature names are the same in backend, frontend, and specs.
  8. Tests trace to specs. A test that checks a spec requirement starts its name with the requirement ID, e.g. it('TEN-03 suspends the tenant', ...), so drift between code and specs/ can be found with a search.

Open questions

  1. Authentication: build on Passport within NestJS, or use an external identity provider. Either way, credentials must be verifiable by any service without a synchronous call to iam.
  2. Integration events for IAM are rows in iam.outbox. The API and apps/worker poll that table and deliver each event at least once. BullMQ remains the proposed transport when Redis is added. Tenant request context on AsyncLocalStorage is in @bizos/api-shared.
  3. tenants and settings are still in the public schema; move them to tenancy and system with a migration before more tables are added.
  4. The API has one OpenAPI document; split it per module (/api/<module>/) when the typed client is generated, so a module's client can follow it to its own service.