The parts
| Part | What it is | Responsibility |
|---|---|---|
| Trial web application | Next.js 16, React 19, TypeScript (client/) | The user interface and a backend-for-frontend that holds the session and proxies API calls |
| API | Node 24 or later, Express 4, TypeScript (strict), PostgreSQL 18 (server/, process trialion-server) | All domain logic, persistence, authorization, audit, signatures, storage and AI |
| Worker | The same codebase, a second process (trialion-worker) | The audit ledger, mail, exports, summaries and the operational alerts (below) |
| PostgreSQL | One database for all organizations | Everything the application stores; separated by organization with row-level security (Data and tenancy) |
| Object storage | Amazon S3 | Study documents, export files and the audit ledger's anchors |
| SMTP through nodemailer | Invitations, notices and operator alerts (queued, then sent by the worker); sign-in and signature codes (sent straight away) | |
| AI | Google Vertex AI through @google/genai | The form generator and the role assistant, both optional (AI) |
| Platform console | Its own Express API and Next.js application (control-plane/) with its own database | Provisioning 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.
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 schemasDependencies 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)
| Module | What it owns |
|---|---|
platform/organizations | The organization (tenant) and its status |
platform/provisioning | The control plane's handshake: creating a tenant and its first administrator |
platform/onboarding | Where an organization is in its setup wizard, and the overview a new organization sees |
platform/users | People, their memberships of an organization, suspension and removal |
platform/invitations | Invitations, their single-use links and what a link may say |
platform/auth | Sign-in by one-time code, sessions, step-up codes for signatures, workspace entry |
platform/authorization | The access profile of a session and the central authorize decision |
platform/rbac | The permission vocabulary, the standard roles, custom roles, guardrails and the role assistant |
platform/access-exceptions | Time-limited additions and removals of one permission on one grant |
platform/access-report | Who holds what, across the organization |
platform/entitlements | The feature switches, read from the control plane |
platform/audit | The tamper-evident ledger, its outbox, anchoring and verification |
platform/mail | The mail outbox, delivery status and the mail service's events |
platform/notifications | The two in-app notifications |
platform/library | The organization's reusable form templates |
platform/health | Liveness, readiness and the operator's diagnostics |
Study scope (what a study is made of)
| Module | What it owns |
|---|---|
study/studies | The study, its state machine, the lock and the creation wizard |
study/versions | Versions, UAT cycles, snapshots, sign-offs, go-live and amendments |
study/runtime-config | What the runtime executes of a version: the compiled snapshot |
study/forms, study/forms-ai | The eCRF designs, their versions, and AI form generation |
study/visits, study/mapping, study/rules | The visit schedule, which form goes at which visit, and cross-form rules |
study/sites, study/team | Sites and their lifecycle; study and site teams and delegations |
study/subjects, study/contacts | Participants and their lifecycle; contacts that are not visits |
study/data-capture | The values of every form record, corrections and the reasons |
study/events, study/safety-logs | Adverse events and their safety trail; the AE and SAE logs |
study/queries, study/deviations | Queries and their threads; protocol deviations |
study/sdv | Source data verification |
study/signatures | The electronic signature primitive |
study/documents | The document register |
study/exports | Data and design exports |
study/casebook, study/dashboard, study/reports, study/summaries | The 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).
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:
| Work | Where it waits | Done by |
|---|---|---|
| Audit events | The audit outbox, per chain | The worker's relay, into the hash-chained ledger |
| The mail outbox | The 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 | |
| Exports | export_jobs | The worker, one job at a time, by atomic claim |
| Dashboard figures | Summary tables marked by the changes | The 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/queueis 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.
Related
Data and tenancy · Environments and versioning · Running it · The database · Architecture decisions · Security and hosting