Skip to content
ImplementedFor engineers, architects, technical due diligence

Architecture

Last updated Oct 21, 2026

The parts

PartWhat it isResponsibility
Trial web applicationNext.js 16, React 19, TypeScript (client/)The user interface and a backend-for-frontend that holds the session and proxies API calls
APINode 24 or later, Express 4, TypeScript (strict), PostgreSQL 18 (server/, process trialion-server)All domain logic, persistence, authorization, audit, signatures, storage and AI
WorkerThe same codebase, a second process (trialion-worker)The audit ledger, mail, exports, summaries and the operational alerts (below)
PostgreSQLOne database for all organizationsEverything the application stores; separated by organization with row-level security (Data and tenancy)
Object storageAmazon S3Study documents, export files and the audit ledger's anchors
MailSMTP through nodemailerInvitations, notices and operator alerts (queued, then sent by the worker); sign-in and signature codes (sent straight away)
AIGoogle Vertex AI through @google/genaiThe form generator and the role assistant, both optional (AI)
Platform consoleIts own Express API and Next.js application (control-plane/) with its own databaseProvisioning organizations, feature switches, platform administrators and the platform ledger (Platform console)

The browser never holds a security token. The web application keeps the session server-side in an encrypted cookie (iron-session, split over up to eight cookies of about 3.5 KB each) and injects the bearer token when it proxies to the API, refreshing the pair and retrying once when the API refuses an expired token. Client-side script cannot read it. Everything under /api/v1 that is not one of the few explicit sign-in handlers goes through one catch-all proxy, and nothing the browser sends names the workspace: the API binds it from the session's own access profile.

Interactive · Architecture ExplorerOpen full screen →
Loading diagram…

The modules

The API is a modular monolith. Every module has the same four layers, behind one public entry point (index.ts), wired by its own composition.ts:

modules/<scope>/<module>/
├── domain/          entities, state machines, invariants: no I/O
├── application/     use cases, depending on ports they declare
├── infrastructure/  adapters: repositories, gateways, clocks
└── interface/       controllers, routes, request schemas

Dependencies point inward. The domain layer does no I/O, so every state machine and invariant is unit-testable without a database. Modules never reach into one another's layers: they talk through public types, the in-process event bus and the database's own gates.

Platform scope (what an organization is made of)

ModuleWhat it owns
platform/organizationsThe organization (tenant) and its status
platform/provisioningThe control plane's handshake: creating a tenant and its first administrator
platform/onboardingWhere an organization is in its setup wizard, and the overview a new organization sees
platform/usersPeople, their memberships of an organization, suspension and removal
platform/invitationsInvitations, their single-use links and what a link may say
platform/authSign-in by one-time code, sessions, step-up codes for signatures, workspace entry
platform/authorizationThe access profile of a session and the central authorize decision
platform/rbacThe permission vocabulary, the standard roles, custom roles, guardrails and the role assistant
platform/access-exceptionsTime-limited additions and removals of one permission on one grant
platform/access-reportWho holds what, across the organization
platform/entitlementsThe feature switches, read from the control plane
platform/auditThe tamper-evident ledger, its outbox, anchoring and verification
platform/mailThe mail outbox, delivery status and the mail service's events
platform/notificationsThe two in-app notifications
platform/libraryThe organization's reusable form templates
platform/healthLiveness, readiness and the operator's diagnostics

Study scope (what a study is made of)

ModuleWhat it owns
study/studiesThe study, its state machine, the lock and the creation wizard
study/versionsVersions, UAT cycles, snapshots, sign-offs, go-live and amendments
study/runtime-configWhat the runtime executes of a version: the compiled snapshot
study/forms, study/forms-aiThe eCRF designs, their versions, and AI form generation
study/visits, study/mapping, study/rulesThe visit schedule, which form goes at which visit, and cross-form rules
study/sites, study/teamSites and their lifecycle; study and site teams and delegations
study/subjects, study/contactsParticipants and their lifecycle; contacts that are not visits
study/data-captureThe values of every form record, corrections and the reasons
study/events, study/safety-logsAdverse events and their safety trail; the AE and SAE logs
study/queries, study/deviationsQueries and their threads; protocol deviations
study/sdvSource data verification
study/signaturesThe electronic signature primitive
study/documentsThe document register
study/exportsData and design exports
study/casebook, study/dashboard, study/reports, study/summariesThe casebook grid, dashboards, reports and the summary tables they read

The platform console's modules

admins, audit, auth, organizations and stats: small modules of the control plane, with no clinical data (Platform console).

Interactive · Module Dependency GraphOpen full screen →
Loading graph…

What a request meets

The order is deliberate: the audit hook is armed before anything that can reject, so a request refused by the limiter or by a malformed body is still audited; the limiter runs before the parser so an abusive client cannot extract a full parse. Study-scoped routes then pass through a chain before any module sees them:

This is the order on Who can and cannot, and the layer that stops a request gives the refusal, so a person who is not allowed never learns the state of a study. A person who is not a member of a study gets not found, not forbidden. The study-state check is applied at the mount (version_work, cleaning, governance, metadata intents) rather than remembered route by route, after an early finding that design routers accepted writes on a locked study. Every operation declares its access, and a route-coverage test holds every registered screen to a permission; a screen that is not in the registry is unreachable.

Durable work: nothing is only in memory

Work that must not be lost is written to tables, in the same transaction as the change that causes it, and carried out by the worker:

WorkWhere it waitsDone by
Audit eventsThe audit outbox, per chainThe worker's relay, into the hash-chained ledger
MailThe mail outboxThe worker's mail relay, which records what became of each message. Sign-in and signature codes are the exception: they are sent straight away and never queued or logged, because a code is a credential
Exportsexport_jobsThe worker, one job at a time, by atomic claim
Dashboard figuresSummary tables marked by the changesThe worker's projector, every second, and a slower reconcile

The worker also verifies the audit chains (every hour from each chain's last checkpoint, and in full once a week), makes the ledger's monthly partitions ahead of time, anchors each chain's end once a day in storage that cannot be rewritten (S3 Object Lock in compliance mode: production refuses to start without it unless AUDIT_ANCHOR_REQUIRED is switched off), and sweeps operational health every minute: audit outbox lag or dead letters, mail not leaving, summary lag, export jobs claimed by a worker that stopped, and backups that are stale or never verified. A condition raises an alert (an e-mail to ALERT_EMAILS) and clears itself when it recovers (Running it). Domain events themselves travel an in-process bus that is not durable; that is why nothing that matters depends on it alone.

Observability

Structured JSON logs (pino) with credentials redacted, correlated by a request id carried through async-local storage; GET /healthz (liveness) and GET /readyz (readiness, 503 when the database is unreachable) ahead of authentication; GET /api/v1/_internal/info for the operator (needs system.monitor). Metrics and tracing are not emitted: the metrics interface is a no-op and no tracing library is wired.

What is not production-grade yet

  • One API process and one worker. The deployment runs each as a single PM2 process; the API's rate-limit counters live in its memory, so they are per process. Running several API processes needs a shared store for the limiter first.
  • The generic job queue is a stub. infrastructure/queue is an in-memory queue that carries no work today; everything durable uses the tables above. There is no Redis in the application.
  • No metrics, no distributed tracing. Operators rely on logs, the probes and the alerts.
  • The event bus is in-process. Audit and mail are durable through their outboxes; notifications are not.

Data and tenancy · Environments and versioning · Running it · The database · Architecture decisions · Security and hosting