Design decisions

The project keeps a numbered decision log (docs/decisions/ in the repository). These records exist so that adopters, auditors, and future maintainers can see why the system is shaped the way it is, not just what it does. Summaries below; the full records carry rationale and consequences.

ADR-0001: Build for the sponsor/CRO oversight seat

The system’s center of gravity is sponsor/CRO-side oversight across many sites, not the site coordinator’s day-to-day binder. The relational questions that motivate the product (cross-site completeness, gaps, expirations) live in the oversight seat, and site-side eISF is a crowded, commoditizing market. Site-facing workflows started minimal; ADR-0023 later built the site seat exactly the way this record predicted: as a permission scope over the same schema, not a different data model.

ADR-0002: TypeScript + Postgres; R is a client, not the implementation

Postgres 16 is the system of record; the backend and frontend are TypeScript end-to-end (Hono + Drizzle; Vite + React). R, the analysis stack of the teams this is built for, consumes the OpenAPI-documented REST API. Keeping R out of the implementation but first-class at the API boundary is deliberate: the API must be good enough that a data-science team never needs a backdoor. The cookbook is that claim’s acceptance test.

ADR-0003: Audit trail written and guarded by the database

audit_event rows are produced by AFTER-triggers on every domain table, with the acting person bound per transaction. An application-layer audit writer is one forgotten code path away from a gap; triggers make every write path (API, seed scripts, ad-hoc psql) leave the same trail. Events are hash-chained in-database, and UPDATE/DELETE on audit events, versions, and signatures raise an error for all roles. History rewrites require explicit, documented trigger disablement, loud on purpose.

ADR-0004: Completeness status is derived by views, never stored

expected_document stores no status and no fulfilled-by pointer; a view joins placeholders to fulfilling documents at query time. Stored status flags drift, and that drift is precisely the incumbent-system failure this project exists to fix. The cost is a join instead of an index lookup: nothing at realistic scale, and convertible to a materialized view later without changing any consumer.

ADR-0005: Seed an illustrative TMF subset, not a from-memory copy

The seed contains roughly 40 hand-checked artifacts across the TMF Reference Model’s major zones, labeled as an illustrative subset, rather than attempting to reproduce the complete model from an LLM’s memory. Reproducing the official taxonomy from model memory risks hallucinated artifact numbers and names, which is unacceptable in a regulated-domain tool. Numbering follows the official scheme, and pnpm db:import-tmf now loads the CDISC download verbatim without migration, the follow-through this decision planned for.

(This decision is logged as part of the project’s LLM-practice transparency policy: it is a deliberate hallucination-risk mitigation, documented so the mitigation itself is auditable.)

ADR-0006: Operational layer as facts + derived lifecycle views

Monitoring visits, issues, enrollment, and milestones are dated facts in tables; lifecycle stages are computed by views, extending ADR-0004 from document completeness to operational workflows. Trip reports are ordinary documents with the same immutable versions and hash-bound signatures (per-visit records, exempt from sibling supersede). Enrollment is as-reported aggregates; the EDC owns subject-level data, as a firm boundary. No bespoke workflow modules: configuration is rows, lifecycles are SQL views, and the v_* views are documented public query surface.

ADR-0007: Quarto docs site with static, source-verified examples

This site is a Quarto website whose code examples are displayed, never executed: publishing needs only Quarto, not the running stack. The drift risk that live execution would catch is mitigated at writing time instead (every example is checked against the actual route definitions and views, not written from memory), and any page can opt into executed chunks later if a live-CI setup becomes worth its cost.

ADR-0008: Provider-agnostic OIDC for identity; grants in the data model for authorization

Authentication is standard OIDC JWT validation: any compliant identity provider works via three environment variables, no provider SDK. Authorization is access_grant rows (role + optional study/site scope) with the role-to-operation map in one place, so access reviews are a query and every grant change is in the audit trail. Signing re-authentication (§11.200) is a freshly issued token for the same subject inside a short freshness window, recorded on the signature row and required by a database CHECK.

ADR-0009: Blob storage behind a driver interface; WORM via S3 Object Lock

The content-addressed blob store is a three-method driver interface. The s3 driver, pointed at a bucket created with Object Lock in COMPLIANCE mode, extends the database’s trigger-enforced immutability to the document bytes: a contract test proves a locked version cannot be deleted even by the root credential. The local driver remains the dev default.

ADR-0010: Validation artifacts are generated from live runs, never hand-written

pnpm validation:iq checks a live environment’s installed controls and emits a sign-off-able Installation Qualification report; pnpm validation:artifacts runs the real test suite and emits the OQ report and the requirement traceability matrix, joined on requirement tokens appearing verbatim in test names. A hand-maintained matrix drifts silently, the same failure mode as a stored status column, which this project exists to reject (ADR-0004, applied to evidence).

ADR-0011: A generic source-system filing interface; EDC integration stays outside the data boundary

Source systems (an EDC first) file documents into the TMF through the same audited upload endpoint people use, authenticated as machine identities (an OIDC client-credentials subject mapped to a provisioned person record) holding the ingest role: read and upload, never sign or approve. Filed versions carry source_system/source_ref provenance and land as pending_review like any upload: automation feeds the TMF, a human still blesses it. The reference client is edc-core, this project’s AGPL sibling on the data-capture side; the boundary holds: documents and aggregates come in, subject-level clinical data never does.

ADR-0012: Regulatory specifics are verified against source texts, never written from model memory

Every regulation-specific claim in these docs (a GAMP 5 appendix, a Part 11 section, an ICH expectation, a CDISC definition) is checked against the full source text before it lands, and cites the section it relies on. The texts are licensed, so the library lives outside the repo (the same reason the CDISC spreadsheet is never vendored, ADR-0005). The rule exists because its failure mode was observed here: an LLM-drafted GAMP categorization claim read like standard validation vocabulary and was subtly out of step with the Second Edition’s actual text. Plausible prose is precisely what model memory produces, and only the source caught it.

(Like ADR-0005 and ADR-0010, this is logged as part of the project’s LLM-practice transparency policy: where ground truth exists in an authoritative source, content comes from the source.)

ADR-0013: Documentation serves two audiences; the user guide lives only on the docs site

The user guide and glossary give clinical operations readers a task-based, code-free path through the app, alongside the technical material. The guide is screenshot-driven and gets no docs/*.md mirror, a deliberate waiver of the usual overlap convention. Its binding rule: guide content must never describe UI the app doesn’t have. The motivating incident was a screenshot caption promising an issue-entry form before one existed; the form (and the rest of the point-and-click surface the guide needed) was built before the guide that describes it.

ADR-0014: The docs carry a web-verified feature-gap roadmap

The roadmap records the honest distance to incumbent CTMS/eBinder systems: deliberate boundaries first, then genuine gaps, each “incumbents do X” claim verified against the vendor’s or standards body’s own public page and cited with an access date: ADR-0012’s rule extended from regulatory texts to market claims. Gaps are not commitments, the page never claims a feature exists, and a gap moves off the page in the same change that ships it.

ADR-0015: Review has two outcomes: approve, or return with a recorded reason

A reviewer can return a pending version for correction with a required reason, recorded as an immutable, audited fact row: part of the document’s permanent record, like a signature. The document shows returned until a corrected version reopens review, and the returned version itself can never be approved (fix-forward, like “no delete button”). Returning takes the same approve authority as approving but is not a signature, so no re-authentication ceremony. This closed the roadmap’s top gap (ADR-0014) in the same change that shipped it.

ADR-0016: An administration write surface, and waivers as fact rows

Studies, sites, people, role assignments, access grants, and requirement rules gained an API and UI write surface: ordinary audited rows behind the existing administer permission, with endings as dated facts (an end_date, a revoked_at) and no DELETE routes. Creating or revoking an unscoped grant takes an equally unscoped admin grant, so scoped admins cannot escalate. Expected documents can now be waived: a fact row (who, when, why; reason required) that the status view reads as waived where it would have said missing. A filed document always wins over a waiver, waived rows leave the completeness denominator, and lifting a waiver is itself a recorded fact. This closed the roadmap’s #1 and #3 gaps (ADR-0014) in the same change.

ADR-0017: Digest notifications are a stateless job over the derived views

pnpm digest queries the same views the dashboard reads, composes one plain-text oversight email per study (expiring/expired documents, overdue visits, action items, issues, milestones; a broken audit chain leads the message), and sends it to everyone holding a study-wide admin or trial-ops grant. No notification table, no subscription list, no read state: the digest is a pure function of the record at send time, so it can never disagree with the dashboard. Cron owns the cadence, the mail system owns delivery, and the dev stack ships mailpit for parity, the same pattern MinIO provides for object storage.

ADR-0018: Review assignments resolve themselves; the queue is derived

A review assignment is a fact row (which pending version, assigned to whom, due when) with no status column and no completion flag. v_review_queue derives unassigned | assigned | overdue for every document awaiting review, and an approval or return is what clears the entry: an assignment is finished exactly when its version stops being the reviewable one. Reassignment inserts a new row (the latest stands), routing takes the same approve authority as returning, the assignee must hold a grant that can approve the document, and overdue assignments join the digest. “My work” is a filter, not a feature, and the queue can never disagree with the documents.

ADR-0019: Document search is a metadata query, not an index

v_document_search flattens each document’s metadata (title, artifact taxonomy, site, person, uploader, file names, filing source) into a searchable haystack, and search requires every word to match. No Elasticsearch, no tsvector table to keep in sync: a search index is derived state that can drift from the record, which is the failure mode this system exists to avoid. Substring semantics keep codes and site numbers predictable (“04.01 002”); Postgres FTS with a generated indexed column is the documented scale path. Content full-text followed as ADR-0022, safe to add precisely because versions are immutable.

ADR-0020: TMF transfer is a verifiable package; EMS waits for the source text

pnpm export-tmf writes a study’s complete handover package (content- addressed bytes, versions, signatures with their §11.70 hashes, returns, waivers, the completeness snapshot, and the whole hash-chained audit trail), verifiable on the receiving side with nothing but shasum -a 256 -c manifest.sha256. It deliberately does not claim CDISC eTMF-EMS conformance: the EMS text is not in the verified source library, and this project does not write a standard’s file layout from model memory (ADR-0012). A plausible exchange.xml that misnames one element is worse than an honest package that never claimed it. The manifest carries the fields an EMS serializer would map; obtaining the EMS v1.0.x text was the unblock, and ADR-0024 followed the day it landed.

ADR-0021: Multi-study is a rollup query and a selection, not a second system

GET /portfolio returns one row per study (completeness, attention items, review-queue size, issues, enrollment vs target) computed by grouping the same views the study dashboards read, so the portfolio can never disagree with them. The header’s study switcher is client state persisted like the theme, falling back to the first study when a re-seed regenerates ids, and the seed grew a second study (CORC-2202, a Phase 1b in startup) so the contrast is real. No portfolio tables, no aggregation jobs, nothing to sync.

ADR-0022: Content full-text search is derived text of immutable bytes

Search now reads what a document says, not only what it is filed as. Every uploaded version’s text is extracted (PDF via pdf.js, text/* directly) into document_content_text, keyed by the same content hash as the blob store: extracted text of immutable bytes can never go stale, the property ADR-0019 named when it scoped this out. The table is derived state deliberately outside the audited record: no audit trigger, no immutability trigger, rebuildable any time with pnpm db:extract-text. Failures and unsupported types are recorded as rows, never block an upload, and scanned image-only PDFs simply match by metadata alone (OCR stayed a deliberate non-feature until ADR-0031). Query semantics stay ADR-0019’s substring-AND (each word must match the metadata haystack or the content), and results carry a snippet of the content match.

ADR-0023: Site-seat log workflows: structured logs as facts, the seat as a permission scope

The one seat ADR-0001 deliberately skipped now exists, built the way that record predicted: a site_staff access role granted with a study-site scope, not a second application. A person whose every grant is site-scoped lands on their site’s page and nowhere else; the site page reads site-scoped endpoints that serve the oversight seat identically. Delegation-of-authority and training logs are dated fact rows with derived status, delegation (delegate, tasks, dates, authorizing PI) and training_record (person, topic, completion, optional expiry and certificate link), and the views carry the cross-checks the oversight seat wants: whether the authorizer actually held an active PI role on the start date, and whether the delegate has open credential items in the document record next door. Writing a log entry takes a new log operation held by site_staff and admin only: monitors read every log, but the log is the site’s record of itself. The signed DoA log document (artifact 05.03.01) remains the authoritative Part 11 record; entry-level e-signatures are named future work, not claimed.

ADR-0024: eTMF-EMS exchange.xml is a serialization over the export package, with nothing invented

The conformance ADR-0020 deliberately left unclaimed, claimed the day it could be: the eTMF-EMS v1.0.2 spec and official XSD entered the verified source library, and pnpm export-tmf -- --study <p> --ems <agreement-id> now writes an exchange.xml (one <OBJECT> per document version over the same content-addressed files) validated against the official schema on every export. Reading the actual text vindicated ADR-0012 twice: the normative XSD spells two elements differently from the spec’s own tables (RENTENTIONDATE, USEROID), exactly the kind of detail a from-memory serializer gets plausibly wrong. The standard’s mandatory facts the schema didn’t carry (TMF RM unique IDs, the model version, site countries) are recorded by the verbatim importer and the admin surface, never invented: the export refuses, listing every gap, until they are on record. What the standard’s tags cannot express rides in its own <METADATA> extension point, so the mapping loses nothing.

ADR-0025: EMS import is a client of the filing interface, and nothing is replayed

The receiving half of the exchange, built the way ADR-0011 said ingestion works: pnpm import-ems is an API client holding the ingest role: no second ingestion path, no direct database writes. It runs the standard’s own receiving-side checks (XSD validation, checksum verification, spec §4.1), maps artifacts by TMF RM unique ID against the verbatim-imported taxonomy (the spec’s primary key, and the one thing that survives model-version drift) and refuses the whole batch, every blocker at once, when an object has no honest home: unknown unique IDs, unresolvable sites, country-level or RESTRICTED objects. Iterations thread onto one document by provenance and re-runs file nothing, because the API now answers what a source system already filed. The partner’s signatures, audit records, and lifecycle states stay in the retained package: this system’s audit chain records the filings it witnessed, and nothing becomes effective without a human.

ADR-0026: Bulk review is a series of signings, not a lighter signature

The human half of ADR-0025’s honest cost: an imported batch lands as dozens of pending documents, and Part 11 anticipated exactly this in §11.200(a)(1)(i): a series of signings during one continuous period of controlled access, first signing with all components. Bulk approval from the review queue is that series: one re-authentication opens it, and every selected version still gains its own signature row bound to its own content hash: N signatures on the record, never one signature waved over N documents. Bulk return shares one immutable reason. Both are all-or-nothing in one transaction with every blocker listed, and both run the same code path as the single-document ceremony, so the two can never drift.

ADR-0027: Preview is a scoped read of the signed bytes

ADR-0026 made the ceremony cheap; diligence still cost a page per document. Every queue row now opens the version inline, and what it shows is the exact immutable bytes a signature would hash: no thumbnails, no converted renditions, nothing derived that could drift from the record. The read resolves the version’s study/site scope like every other version-addressed route, so the site seat previews only its own site, a boundary the old content-addressed download never drew. Reads still leave no audit rows: the trail records what happened to the record, not who looked at it.

ADR-0028: Auditor UX is a rendering of the record, not a module

Everything an inspection needs was already a record; what was missing was the inspector’s way in. The binder page serves the study in the reference model’s own zone → section → artifact order over one GET, from the same derived views as every other surface, empty slots included, because an empty slot is information. “Verify bytes” re-fetches any version’s content and recomputes its SHA-256 in the reader’s browser, comparing against the recorded hash and every signature bound to it: the §11.70 linkage demonstrated, not just displayed, trusting neither the server’s headers nor the UI’s word. And the read-only auditor’s seat is now seeded and real: the UI renders every seat only the operations its grants hold, so reading the record is a clean surface, not a field of buttons that answer 403.

ADR-0029: Container packaging: two images, and the api cannot migrate itself

Tagging a release publishes two OCI images to GHCR: an api image that is a tsx runtime carrying the full db package, and a multi-stage Vite build of the web app served by nginx, which also owns the /api/* reverse proxy, the same contract the dev proxy already speaks. The decision worth recording is what does not happen: the api never runs migrations at boot. It connects as the DML-only role migration 0004 created, so it cannot alter the schema even in principle. Boot-time migration would mean handing the owner credential to the long-running, network-exposed process, undoing that separation. A deployment instead runs a one-shot migrate container with the owner credential, which exits before the api starts. And because the api image ships the db package, that one-shot runner is the api image: in a pinned deployment, the migration code version can never drift from the code it migrates for.

ADR-0030: Office-format previews render in the viewer’s browser

ADR-0027 left Word and Excel files (plans, logs, trackers) as a download offer, because a stored rendition would be a second artifact to keep honest against the record. The way out is to store nothing: the preview panel already fetches the exact signed bytes, and for office formats the web app now converts them to HTML in the viewer’s browser, the same trust model as the browser’s built-in PDF viewer, which is also client software rendering the signed bytes. docx converts with mammoth; xlsx with a small OOXML reader of our own (exceljs, which the server keeps for the importer, never finishes loading under a browser bundler, and a preview needs only cell text). The output lives in an iframe with an empty sandbox, because a document under review is untrusted input, and the panel is labeled “Rendered in your browser for reading — the downloaded file is the record.” No endpoint, no schema, no stored artifact: there is nothing to drift.

ADR-0031: OCR for image-only PDFs is derived text, recovered by the backfill

The paper-born share of a TMF (approval letters, certificates, signature pages) arrives as scanned PDFs with no text layer, which ADR-0022 could only match by metadata. OCR now recovers what exists only as pixels, and it lands exactly where ADR-0022 put every other extraction: in document_content_text, content-addressed, derived, rebuildable, outside the audited record, with its extractor column saying tesseract.js, because OCR output is a reading of pixels, not a fact about bytes, and a search hit built on it must stay traceable to the engine that guessed it. The engine is tesseract.js (WASM) over pages rendered by pdf.js: pure npm dependencies, no OCR service, no system binary, nothing leaving the machine. It never runs in the upload request (OCR costs seconds per page; extraction must not block an upload): pnpm db:extract-text re-reads every PDF whose text layer came back under 20 characters and updates the row in place, blank scans included, so nothing retries forever. The seeded demo ships a scanned IRB approval letter whose body (“renewed approval for a further twelve months”) is findable only because OCR read it.

ADR-0032: Single-VM production shape, Caddy for TLS, thin infra-as-code

ADR-0029 published the images; this decision says where they run. The supported production shape is one VM running infra/compose.prod.yaml: Caddy terminating TLS as the only published service, and the bring-up order from the deployment guide wired into the file itself. The migrate one-shot is the only container holding the owner credential, and on every bring-up it also rotates the runtime roles’ passwords from .env, so the least-privilege separation that migration 0004 created in the schema is now enforced by the topology too. Compose profiles cover the supported variations (bundled Postgres or managed, bundled MinIO or a real Object Lock bucket). Infrastructure-as-code stays deliberately thin and VM-shaped: a provider-agnostic cloud-init, plus one small Terraform root each for AWS, Azure, and DigitalOcean that provisions the VM, firewall, and disk, then delegates every application step to that same cloud-init. No Kubernetes at this phase; a single-tenant pilot (ADR-0001) needs a compose file and a DNS record, and that is also the answer the sibling edc-core gives, which matters for organizations installing both. Two smaller things ride the same change: the web image now serves its IdP configuration at runtime from container env via /env.js, so one pinned image works against any identity provider, and a devcontainer definition boots the seeded demo stack in GitHub Codespaces for zero-install evaluation. This ADR was drafted as 0031 while OCR shipped under that number; it is recorded as 0032.


Three of these (ADR-0004, ADR-0006, and ADR-0010) are the same idea applied again and again, and it is the idea the whole system rests on: state that can be computed is never stored. If you take one thing from this log, take that.