---
title: Architecture
description: Monorepo layout, runtime apps, and domain/db/integration layering.
---

pnpm workspaces + Turborepo. Apps orchestrate; shared packages own rules, persistence, and adapters.

## Workspace map

```mermaid
flowchart TB
  subgraph apps [Apps]
    web["apps/web :3000"]
    portal["apps/portal :3001"]
    workers["apps/workers"]
    control["apps/control-plane"]
    docsSite["apps/docs :4322"]
  end
  subgraph packages [Packages]
    domain["packages/domain"]
    db["packages/db"]
    integrations["packages/integrations"]
    ai["packages/ai"]
    contract["packages/api-contract"]
  end
  web --> domain
  web --> db
  web --> integrations
  web --> ai
  web --> contract
  portal --> domain
  portal --> db
  workers --> domain
  workers --> db
  workers --> integrations
  control --> db
  docsSite -->|symlink openapi.json| contract
```

Caption: apps depend on shared packages; the docs site consumes the committed OpenAPI artifact via symlink.

## Layering rules

- `packages/domain` — pure TypeScript; no I/O. Permission decisions only via `can()`.
- `packages/db` — Drizzle / PostgreSQL; repositories and migrations.
- `packages/integrations` — interfaces + mock/sandbox/real adapters.
- Apps stay thin: route handlers and UI orchestrate the packages above.

For the executable boundaries behind this summary, continue with:

- [Application architecture](/developers/application-architecture) — transport adapters, typed command/query outcomes, `can()`, transactions, audit, repositories and providers.
- [Tenant isolation and fleet lifecycle](/developers/tenant-isolation) — route/validate separation, physical database isolation, provisioning and fleet migrations.
- [Workers and integrations](/developers/workers-integrations) — job registry, queue delivery, adapter tiers, retries and idempotency.
- [Operations and runbooks](/developers/operations) — purpose-based landing page for every canonical procedure.

## Runtime model

- `apps/web` / `apps/portal` — Next.js App Router.
- `apps/workers` — Node process draining BullMQ on Valkey.
- `apps/control-plane` — `blinqx-control` CLI for provision and fleet migrations.
- Local backing services: Postgres, Valkey, OpenSearch, MinIO, Keycloak via `docker/compose.yml`.

## Permission engine and scope filtering

```mermaid
flowchart TD
  actor["Actor request action, resource"]
  can["can policy, actor, action, resource"]
  ownerCheck{"Owner-only resource type?"}
  roleGrant{"Role grants module + operation?"}
  deptScope{"Grant covers record department?"}
  accessLevel{"Access level admits actor?"}
  allow["Allow plus explain rule"]
  deny["Deny plus explain rule"]
  compile["compileToFilter policy, actor, resourceType"]
  sqlPredicate["Drizzle SQL predicate"]
  searchFilter["OpenSearch filter"]
  residual["Residual can re-check on returned rows"]

  actor --> can
  can --> ownerCheck
  ownerCheck -->|yes| allow
  ownerCheck -->|yes, not owner| deny
  ownerCheck -->|no| roleGrant
  roleGrant -->|no grant| deny
  roleGrant -->|granted| deptScope
  deptScope -->|out of scope| deny
  deptScope -->|in scope| accessLevel
  accessLevel -->|denied| deny
  accessLevel -->|admitted| allow

  actor -.list/search query.-> compile
  compile --> sqlPredicate
  compile --> searchFilter
  sqlPredicate --> residual
  searchFilter --> residual
  residual --> allow
```

Caption: `can()` in `packages/domain` is the single decision path for roles, department scope, and record access level, returning a named `rule` for explainability. Bulk reads share the same logic through `compileToFilter()` (ADR-0002), which compiles the policy into a Drizzle predicate or an OpenSearch filter instead of post-filtering fetched rows. `departmentSplit` can't be pushed into a query, so a permanent per-row `can()` re-check runs on whatever the compiled filter returns — fail-closed by design, not a migration shim.

## Offline sync (desktop client)

```mermaid
sequenceDiagram
  participant Client as Desktop client (Tauri)
  participant SyncApi as "/api/v1/sync/*"
  participant Reconcile as reconcileDeviceSync
  participant Domain as "packages/domain can()"
  participant Dms as DMS (createDocument/addVersion)

  Client->>Client: scan local folder, sha-256 checksum
  Client->>SyncApi: POST manifest to /reconcile
  SyncApi->>Domain: resolve sync_device token, can(actor, action, resource)
  Domain-->>SyncApi: allow/deny per matter/document
  SyncApi->>Reconcile: admitted manifest entries
  Reconcile-->>SyncApi: download/upload/conflict plan
  SyncApi-->>Client: plan (never auto-clobber, conflicts surfaced)
  Client->>SyncApi: upload changed file
  SyncApi->>Dms: addVersion (new immutable version)
  Dms-->>SyncApi: audited, via: device
  SyncApi-->>Client: ack
```

Caption: the Tauri client (`offline-sync/`) owns no conflict or deletion logic — it only scans and executes. The server runs one shared reconcile decision table (`reconcileDeviceSync` in `packages/domain/src/device-sync`, ADR-0014) so device sync, SharePoint/OneDrive sync (ADR-0013), and permission checks stay in a single place instead of forking per client.

## AI anonymize pipeline

```mermaid
flowchart LR
  prompt["Feature prompt text"]
  anon["anonymize deterministic NER"]
  mapping["Mapping table token to original, in-tenant"]
  assert{"containsRawIdentifiers?"}
  refuse["Refuse to send"]
  gateway["ModelGateway EU-allowlisted"]
  response["Model response tokens"]
  reid["reidentify using mapping"]
  answer["Answer to caller for user acceptance"]
  audit["Audit invocation (user, feature, model, tokens)"]

  prompt --> anon
  anon --> mapping
  anon --> assert
  assert -->|raw identifiers remain| refuse
  assert -->|clean| gateway
  gateway --> response
  response --> reid
  mapping --> reid
  reid --> answer
  gateway --> audit
  audit --> answer
```

Caption: `packages/ai`'s anonymizer is deterministic code, not a model — it masks Dutch identifiers (BSN, IBAN, KvK, phone, address, names) with stable per-value tokens before anything leaves the tenant, and asserts no raw identifier remains before calling `ModelGateway`. The response is re-identified inside the tenant boundary and every invocation is audited; AI proposes drafts, it never persists (CLAUDE.md rule 5).

## Billing to Peppol/UBL

```mermaid
sequenceDiagram
  participant Billing as "sendInvoiceAction"
  participant Domain as "packages/domain billing"
  participant Ubl as "toUbl(invoice)"
  participant Ap as PeppolAccessPoint (Storecove)
  participant Db as invoice table

  Billing->>Domain: sendInvoice(handle, userId, invoiceId, channel: peppol)
  Domain->>Ubl: assembleInvoice, toUbl
  Ubl-->>Domain: UBL XML
  Domain->>Ap: deliver(recipientId, ublXml)
  Ap-->>Domain: deliveryId, status: received
  Domain->>Db: persist peppolStatus, deliveryId
  Billing->>Ap: getDeliveryStatus(deliveryId)
  Ap-->>Billing: delivered / rejected
```

Caption: `sendInvoiceAction` (`apps/web/src/lib/actions/billing.ts`) picks a channel — `pdf`, `ubl`, `peppol`, or `ledes` — and for `peppol` resolves a `PeppolAccessPoint` (mock or Storecove, `packages/integrations/src/peppol`). `packages/domain/src/billing` assembles the invoice and renders UBL XML via `toUbl()` before handing it to the access point for delivery; delivery status round-trips back onto the invoice record.

## Email filing

```mermaid
sequenceDiagram
  participant Outlook as Outlook add-in
  participant Exchange as token-exchange route
  participant Entra as Microsoft Entra / Graph
  participant Web as file-to-matter route
  participant Domain as authorizeMatter can()
  participant Dms as DMS document

  Outlook->>Exchange: Office SSO token A
  Exchange->>Entra: verify JWKS claims + OBO for Graph
  Entra-->>Exchange: Graph token B (server-side only)
  Exchange-->>Outlook: short-lived Basenet bearer
  Outlook->>Web: Bearer + file-to-matter payload
  Web->>Domain: authorizeMatter(matterId, create, document)
  Domain-->>Web: allow/deny
  Web->>Dms: fileEmailToMatterWithLink(email + attachments)
  Dms-->>Web: documentId, audited
  Web->>Web: indexDocument(documentId)
  Web-->>Outlook: documentId, matterId
```

Caption: the Outlook add-in exchanges a verified Office SSO token for a five-minute, Outlook-filing-only Basenet bearer; the OBO Graph token stays server-side. The existing personal-API-token bearer remains a fallback. The taskpane sends the selected message and attachments to the real `file-to-matter` route, which applies the shared permission engine and DMS repository. Filing is the boundary between the provider-backed mailbox read model and the active Documenthub: it produces an immutable DMS document plus `email_link`, and provider-side deletion can never remove the filed copy.

## Source anchors

- Root `README.md`, `AGENTS.md`, `pnpm-workspace.yaml`, `turbo.json`
- OpenWiki (full generated tree): `openwiki/architecture/`

See also: [Application architecture](/developers/application-architecture) · [Tenant isolation](/developers/tenant-isolation) · [Workers and integrations](/developers/workers-integrations) · [Operations](/developers/operations) · [Public API](/developers/public-api)
