DICOM AI Quota, Safety & Observability
Date: 2026-04-27Status: Approved
Overview
Add hard usage quotas to DICOM AI analysis, a first-time beta terms modal, a persistent AI disclaimer, patient history entries on analysis completion, and a superadmin usage monitor. Controls cost, manages liability, and gives operators visibility into per-clinic AI consumption.1. Data Model
1.1 New table: app.dicom_quota
(clinic_id, period_start)One row per clinic per billing period. Auto-created on first analysis of a new period. Daily reset logic (inline, no cron):
On every quota load, if
day_key !== today (UTC):
clinic_modules.created_at for moduleKey = 'dicom_imaging'):
1.2 New column on app.clinic_modules
moduleKey = 'dicom_imaging'. Null = terms not yet accepted. Set once, never cleared.
2. Limits
| Limit | Value | Scope |
|---|---|---|
| Monthly analyses | 500 | Per clinic, per billing period |
| Daily analyses | 20 | Per clinic, rolling UTC day |
| Monthly tokens (input + output) | 5,000,000 | Per clinic, per billing period |
| Concurrent analyses | 3 | Per clinic, in-flight at once |
| Max DICOM slices per call | 30 | Per request |
| max_completion_tokens | 1000 | Per AI call (down from 8192) |
| temperature | 0.2 | Per AI call (was 0.1) |
3. Server: Quota Enforcement
3.1 Route: POST /api/v1/protected/ai/dicom-analysis
Updated request body adds optional field:
sliceCount defaults to 1. Reject with 400 if > 30.
Enforcement order:
resetsAt is ISO 8601 UTC. For daily_studies, it’s midnight tonight UTC. For monthly limits, it’s the next billing anchor date. For concurrent, it is omitted.
3.2 New route: GET /api/v1/protected/ai/dicom-quota
Returns current period usage for the authenticated clinic:
3.3 New route: POST /api/v1/protected/ai/dicom-terms-accept
Sets dicom_ai_terms_accepted_at = now() on the clinic_modules row for dicom_imaging. Idempotent — if already set, returns 200 with no change.
4. Durable Object: Concurrency Lock
4.1 Changes to ClinicHub
Two new RPC methods. No new fetch() routes, no alarms, no ctx.storage writes.
activeAnalysesis in-memory only — noctx.storagecalls, no I/O on every analysis- If the DO hibernates, counter resets to 0, which is correct (no active WebSockets = no in-flight analyses)
- Stale slot sweep is lazy — runs only inside
acquireAnalysisSlot, never on a timer or alarm - No new wake-up paths introduced — the DO fires only when an analysis starts or ends
5. UI Changes
5.1 Usage counter — DicomPage.tsx header
Fetches GET /api/v1/protected/ai/dicom-quota once on mount (no polling). Renders a compact bar in the workstation header:
monthly_studies / 500:
- < 80%: green
- 80–95%: amber
- ≥ 95%: red
5.2 First-time terms modal — DicomViewer.tsx
Triggered when “Analyse with AI” is clicked and terms_accepted === false (from cached quota response). Analysis does not start until confirmed.
Modal content (warm, not alarming):
Ruby AI Analysis — Beta You’re about to use AI-powered radiograph analysis. A few things to know:On confirm: calls[I understand — start analysis] [Cancel]
- This feature is in beta — results are thorough but always need clinical review
- Ruby highlights findings, but a licensed dentist must make all clinical decisions
- Each analysis uses your clinic’s monthly quota (500 studies/period)
- Completed reports are saved to the patient file for reference
POST /api/v1/protected/ai/dicom-terms-accept, then proceeds to analysis. Updates local quota state so modal never shows again this session. Cache-busts the quota fetch so the header reflects terms_accepted: true.
5.3 Persistent AI disclaimer — DicomViewer.tsx results panel
Non-dismissable banner always rendered at the top of the AI results panel whenever results are visible. Not a toast.
5.4 Quota error messages — DicomViewer.tsx
When the POST returns 429, show a persistent amber banner (not a toast) with friendly copy:
| reason | Message |
|---|---|
monthly_studies | ”Your clinic has used all 500 AI analyses for this period. Resets on [resetsAt date].” |
monthly_tokens | ”Your clinic’s monthly AI token budget is exhausted. Resets on [resetsAt date].” |
daily_studies | ”Daily limit reached — 20 analyses per day keeps costs fair for everyone. Back to full speed tomorrow.” |
concurrent | ”An analysis is already running. Please wait a few seconds and try again.” |
slice_limit | ”This study has too many frames to analyse at once. Please select a smaller slice range (max 30).” |
resetsAt is parsed from the 429 body and formatted as a human-readable date (e.g., “15 May”).
5.5 Patient history tab — PatientDetails.tsx
After a successful analysis, a history entry is written to the patient timeline in the History tab (existing TabsContent value="history" at line 768).
Entry format:
/api/v1/protected/ai/dicom-analysis succeeds on the client, append the entry to whatever data source the history tab reads from (to be confirmed during implementation — check existing history data pattern). If the history tab uses a separate API, emit a cache-bust or refetch trigger after analysis completes.
6. Superadmin: DICOM Usage Monitor
6.1 New component: ui/src/components/superadmin/DicomUsageMonitor.tsx
New tab or section in the superadmin panel. Fetches from:
GET /api/v1/protected/superadmin/dicom-usage
Returns all clinics’ current-period quota rows joined with clinic name:
6.2 Langfuse
Use Langfuse for: cost-per-run breakdowns, model performance, trace debugging, prompt version analysis. The superadmin table is for quota health and abuse detection; Langfuse is for cost forensics.7. Files Touched
| File | Change |
|---|---|
server/src/schema/dicom_quota.ts | New schema file |
server/src/schema/clinic_modules.ts | Add dicomAiTermsAcceptedAt column |
server/src/schema/index.ts | Export new schema |
server/src/lib/schema-ensure.ts | Runtime migration for new column + table |
server/src/routes/ai.ts | Quota enforcement on POST, new GET quota route, new POST terms-accept route |
server/src/routes/superadmin.ts | New GET dicom-usage route |
server/src/lib/ai/agents/dicom-analysis.ts | Update max_completion_tokens to 1000, temperature to 0.2, accept requestId for slot tracking |
server/src/durable-objects/clinic-hub.ts | Add acquireAnalysisSlot / releaseAnalysisSlot RPC methods |
ui/src/components/files/DicomPage.tsx | Add usage counter bar |
ui/src/components/files/DicomViewer.tsx | Add terms modal, persistent disclaimer, quota error banners |
ui/src/components/patients/PatientDetails.tsx | History tab: render DICOM analysis entries |
ui/src/components/superadmin/DicomUsageMonitor.tsx | New superadmin monitor component |
8. Out of Scope
- Multi-slice / batch analysis (slice cap enforced but multi-select UI not added here)
- Token-level streaming (current single-POST pattern unchanged)
- Quota override or manual limit bumps (superadmin can adjust via DB for now)
- Automated Langfuse alerts (configured separately in Langfuse dashboard)

