pediatric-ai-scribe-v3/docs/architecture.md
Daniel 36cb742ce7
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
ci: fix the failing job, split deploy out, and drop the Android build
Three things, one subject: making CI say the truth about this repo.

## The red on every run was ours, not the runners'

Every docker-build run came back success, success, failure — the same
shape for weeks. The failing job was `deploy`, and it was failing to
*not run*:

    if: ${{ github.event.inputs.deploy == 'true' }}

On a push there is no github.event.inputs at all. This Forgejo does not
treat that as false and skip; it dispatches the job, the runner cannot
resolve it, and the task ends in "Early termination". The runners were
never at fault, and nothing about them needed changing.

The `'runs-on' key not defined` line is a red herring: the `build` job
prints it too and succeeds. It names the job's *needs* target, not the
job, and the old android-apk workflow used `needs:` happily for months.

Deploy is now its own workflow with only workflow_dispatch — no
condition to evaluate, so nothing can be dispatched by mistake. No job
in either file now carries a job-level `if`. The one conditional left is
a *step* (push to registry), and step conditions are evaluated by the
runner once the job is already running, which is why that one has always
worked.

## dev and main

docker-build now runs on `dev` as well. Both branches prove the same two
things — tests pass, image builds — and only `main` publishes the image,
so nothing on `dev` can be mistaken for something deployable. Deploying
stays a person pressing a button after looking at the change.
CONTRIBUTING.md documents the flow.

## Android

Removed: the mobile/ Capacitor project, docs/mobile-build.md, and the
Android bits of scripts/release.sh. All of it is in git history — 4613a278
is the last commit that had it — for when it is rebuilt.

src/utils/platform.js stays. isMobileClient only decides token lifetime,
it is twelve lines, and it is the contract a future app would come back
to; deleting it would be a change to auth for no gain.

.github/workflows/ went too — all five. There is no GitHub remote on
this repository, so none of them has ever run, and two of them wrote
into mobile/ paths that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 23:30:52 +02:00

12 KiB

Architecture

Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend.

Stack

Layer Technology
Runtime Node.js 24 (Alpine, digest-pinned) + Express 4
Database PostgreSQL 16 via the digest-pinned pgvector/pgvector:pg16 image
Cache / state Redis for operational cache, prompt suggestions, and queue groundwork
Frontend Vanilla JavaScript SPA, service-worker cache
Mobile Capacitor 6 wrapper (Android + iOS)
Container Docker Compose (app + db + Redis)
Observability Prometheus metrics at /metrics; structured app logs in files, Postgres, and optional Loki
Reverse proxy External (Caddy, Nginx, Traefik — any)

Repository layout

server.js                        # Express entry
Dockerfile                       # node:24-alpine base, plus pandoc, python3/python-pptx/python-docx, poppler
docker-compose.yml               # app + postgres
migrations/                      # node-pg-migrate files (versioned)
scripts/
  maintenance.js                 # REINDEX / collation-drift CLI
  release.sh                     # semver bump + tag + push

src/
  db/
    database.js                  # pg pool, idempotent baseline init, helpers
    migrate.js                   # programmatic node-pg-migrate runner
  middleware/
    auth.js                      # JWT + session-table validation, sliding idle
    logging.js                   # request log
  utils/
    ai.js                        # callAI() multi-provider router
    models.js                    # model registry + server-side whitelist
    prompts.js                   # prompt templates (DB-overridable)
    crypto.js                    # AES-256-GCM (PHI at rest)
    passwords.js                 # argon2id with bcrypt fallback + rehash
    sessions.js                  # token hashing, UA parser, session-id gen
    platform.js                  # isMobileClient() detection
    redact.js                    # PHI redactor for audit details
    auditQueue.js                # batched audit/api/access log writer
    fileType.js                  # magic-byte upload verification
    promptSafe.js                # <UNTRUSTED_*> LLM prompt wrapper
    logger.js                    # audit/api/access + Loki shipper
    errors.js                    # generic 500 responder
    sttProvider.js, ttsProvider.js  # speech-to-text and text-to-speech routing
    documentExport.js            # pptx/docx/pdf export
    slideSpec.js, docSpec.js     # markdown -> typed spec for the renderers
    deckSchema.js, deckBuild.js  # the deck a presentation is designed as
    deckReview.js                # optional vision pass over a rendered deck
  routes/                        # Express routers (auth, hpi, soap, patient education, …)

public/                          # SPA
  index.html                     # shell, loads components on demand
  sw.js                          # service worker (cache shell, network-first API)
  js/                            # vanilla JS modules, no build step
  components/                    # per-tab HTML fragments
  css/styles.css

.forgejo/workflows/
  docker-build.yml               # dev + main: tests, image; main also publishes
  deploy.yml                     # manual dispatch: deploy.sh against the host

Request pipeline

request
  → helmet          (CSP, HSTS, X-Content-Type-Options, …)
  → CORS            (APP_URL + CORS_ORIGINS whitelist, fail-closed in prod)
  → cookieParser
  → express.json    (10 MB cap)
  → rate limiters   (general 200 req/min, per-endpoint tighter on auth)
  → static          (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
  → route           (feature routers under /api/*)
    → authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
  → handler
  → response

On boot, server.js:

  • Validates JWT_SECRET and DATA_ENCRYPTION_KEY — refuses to start in production without them.
  • Runs initDatabase() (idempotent baseline) then node-pg-migrate (versioned delta).
  • Checks pg_database collation version; auto-REINDEXes + refreshes on drift.
  • Reads git HEAD for BUILD_ID; injects ?v=BUILD_ID into every local /js/*.js and /css/*.css reference in index.html.
  • Registers SIGTERM/SIGINT handlers that drain the audit queue and close the pool before exit.

Auth model

Hybrid, runtime-selected by User-Agent and X-Client header:

Client Token transport Persistence Idle policy
Web browser ped_auth httpOnly cookie, sameSite=lax 30 d maxAge (sliding) 24 h from last write request
Capacitor app (PedScribe-Android / Capacitor UA) Authorization: Bearer <jwt> iOS Keychain / Android EncryptedSharedPreferences via capacitor-secure-storage-plugin No server-side idle check (persistent)

Sessions are validated against user_sessions.token_hash on every request. Any logout / password-change / admin-revoke drops the row and the next request gets 401. The service worker clears its caches on logout so a stale shell never shows PHI on a shared workstation.

Conventional-commits auto-tag workflow can push a new semver tag using a RELEASE_PAT PAT secret so downstream release workflows fire on the tag push (the default GITHUB_TOKEN is blocked from triggering other workflows by design).

Frontend

Single HTML document with #auth-screen and #main-app sections. Tabs are per-feature HTML fragments under public/components/ fetched on demand. JS modules talk via window globals and CustomEvent on document — no bundler, no framework. Loader order is fixed in index.html.

Post-note helpers such as billing suggestions, don't-miss review, and patient education handouts are reusable browser-side actions backed by authenticated JSON APIs. The patient education helper generates a parent-facing plain-text draft from the edited note and keeps the clinician in the review loop before copying or sharing.

authFetch.js installs a global fetch interceptor that treats any 401 on an authenticated request as a signal to clear local session state and redirect to login. A BroadcastChannel('pedscribe-auth') pushes that signal to sibling tabs so logging out in one tab drops UI in every open tab.

Docker topology

Container Image Internal port External
pediatric-ai-scribe ped-ai-local:latest (built from repo) 3000 127.0.0.1:3552
pedscribe-db pgvector/pgvector:pg16 5432 not exposed
ped-ai-redis Redis 6379 not exposed

Named volumes: pgdata (database), scribe-logs (filesystem audit logs), and Redis data if persistence is enabled by compose. Application health-check polls GET /api/health.

A reverse proxy terminates TLS and forwards to 127.0.0.1:3552. The app is never bound to a public interface directly.

Service worker

sw.js implements two strategies:

  • Shell assets (/, /js/*, /css/*, /components/*) — cache-first.
  • /api/* — network-first with cached fallback. Ensures fresh data online, last-known-good when offline.

Precached on install: index.html, core JS, main stylesheet, login component. Cleared on logout (caches.keys() → caches.delete()).

Clinical Assistant And MCP

The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse.

Speech

Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through LiteLLM; upstream provider choice belongs in LiteLLM config. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.

Operational map

The sections above describe the code. These describe the running system: who owns what, what crosses each boundary, and where the truth lives when two places disagree.

Ownership

Ped-AI is a self-hosted Express application with a browser frontend, PostgreSQL storage, Redis operational state, LiteLLM model routing, and optional MCP-backed clinical retrieval.

Area Owner Notes
Web app Ped-AI Auth, UI, clinical workflows, admin settings, notes, My Resources, bedside tools
Database PostgreSQL Users, sessions, settings, saved app data, audit/API/access logs
Operational cache Redis Prompt suggestions, lightweight state, queue groundwork; not clinical answer caching
Model gateway LiteLLM Text, speech and image model discovery and routing
Clinical retrieval MCP service Nextcloud access, indexing, search, rerank, source metadata
Reverse proxy Caddy or equivalent TLS and public routing

Request Flow

Normal app request:

browser
  -> reverse proxy
  -> Express middleware
  -> auth/session check when protected
  -> route handler
  -> PostgreSQL/Redis/provider calls as needed
  -> JSON or HTML fragment response

Clinical Assistant request:

browser
  -> Ped-AI clinical assistant route
  -> MCP semantic search for indexed clinical sources
  -> Ped-AI builds grounded answer prompt
  -> LiteLLM chat model
  -> Ped-AI returns answer plus source metadata
  -> browser renders markdown, citations, and source cards

Ped-AI owns the user workflow and rendering. MCP owns retrieval and indexed source metadata. LiteLLM owns model routing.

Runtime Boundaries

Boundary Main Risk Current Direction
Browser to Ped-AI XSS, stale shell, session handling Sanitized rendering, httpOnly cookie for web, cache busting
Ped-AI to PostgreSQL schema drift, slow queries migrations, maintenance checks, indexes where needed
Ped-AI to Redis unavailable operational state Redis is useful but should not hold required clinical answers
Ped-AI to LiteLLM provider downtime, wrong model mode metadata-based model discovery and timeouts
Ped-AI to MCP retrieval latency/failure explicit MCP client layer and graceful fallback messages
MCP to Nextcloud stale indexed metadata scanner/indexer updates source metadata over time

Source Of Truth

Data Source Of Truth
User accounts and sessions Ped-AI PostgreSQL
Admin app settings Ped-AI PostgreSQL app_settings
Clinical source documents Nextcloud and MCP index
Clinical source title/path shown to users MCP result metadata, especially indexed file_path
Clinical answer text Generated per request; intentionally not cached
Model availability LiteLLM metadata and configured fallbacks

Deployment Shape

Production usually runs:

Caddy/TLS
  -> pediatric-ai-scribe container
     -> pedscribe-db container
     -> ped-ai-redis container
     -> LiteLLM endpoint
     -> MCP endpoint

The app should stay private behind the reverse proxy. Do not expose PostgreSQL, Redis, MCP internals, or provider keys publicly.

Design Principles

  • Keep Ped-AI stateless enough to run more than one app container.
  • Keep clinical answer generation live and source-grounded; do not cache final clinical answers.
  • Prefer model capability metadata over model-name regexes.
  • Prefer indexed file names and paths over embedded PDF metadata for source titles.
  • Keep renderer fixes narrow and tested because LLM markdown is messy.
  • Keep old frontend globals working until the affected feature is intentionally converted to ESM.