chore: update readme and prd

This commit is contained in:
voltsrage
2026-06-27 13:32:04 +08:00
parent 04bdb7e85c
commit 66ae95956a
3 changed files with 494 additions and 85 deletions
+69 -8
View File
@@ -2,7 +2,7 @@
A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
**Implementation status:** Phases 18 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion retry with exponential backoff, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 (E2E verification and clinical scenario docs) is partially implemented. See [Implemented Phases](#implemented-phases) for the full breakdown.
**Implementation status:** Phases 18 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion deferral with exponential backoff retry, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 is partially complete: the E2E verification script (`scripts/run-vigilcare-records-verification-p9.sh`) and clinical scenario documentation ([digitization-workstation-guide.md](docs/digitization-workstation-guide.md)) are in place; extended demo seed data (patients and batches across all statuses) is not yet implemented. See [Implemented Phases](#implemented-phases) for the full breakdown.
## Domain Model — How It Maps to a Real Clinical System
@@ -192,7 +192,7 @@ VigilCareRecords/
│ └── run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
└── docs/
├── plans/ # Phase 19 implementation guides
├── digitization-workstation-guide.md # Clerk workflow and UI reference
├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
└── vigilcare-records-prd.md # Product requirements and phase roadmap
```
@@ -359,7 +359,9 @@ Open `http://localhost:3028`. The Vite dev server proxies `/api` requests to the
| `verifier1` | `password` | `/verification` — field-level verification |
| `admin1` | `password` | `/dashboard` — supervisor queue overview |
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for the full clerk workflow.
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for clinical scenarios (backfill, live capture, corrections) and the full clerk workflow.
**Paper originals:** The scanned document is the working reference for entry and verification. The physical chart remains the legal original until jurisdiction-specific retention rules apply. Scans are never deleted on batch rejection.
Production build:
@@ -443,9 +445,8 @@ Error response:
| Field | Type | Description |
|---|---|---|
| `accessToken` | string | JWT bearer token (15 min) |
| `token` | string | JWT bearer token (15 min) |
| `refreshToken` | string | Opaque refresh token (7 days) |
| `expiresAt` | DateTimeOffset | Access token expiration |
| `userId` | Guid | User ID |
| `username` | string | Username |
| `displayName` | string | Display name |
@@ -570,7 +571,9 @@ Error response:
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/digitization-batches/{id}/approve` | Clinical Approver, Administrator | Approve and atomically promote draft data to live clinical tables |
| POST | `/digitization-batches/{id}/promote` | Clinical Approver, Administrator | Manually promote an `APPROVED` batch (used after deferred promotion or by operators) |
| GET | `/digitization-batches/{id}/promotion-result` | Any authenticated | Retrieve live entity IDs created during promotion |
| GET | `/digitization-batches/{id}/events` | Administrator, Verifier, Clinical Approver | Cursor-paginated batch audit trail with actor username and full name |
**POST `/approve` headers:**
@@ -602,6 +605,7 @@ Error response:
| Code | Meaning |
|---|---|
| 200 | Batch approved and promoted (or idempotent replay) |
| 202 | Promotion deferred — batch stays `APPROVED`; `PromotionRetryService` retries automatically (`PROMOTION_DEFERRED`) |
| 400 | Missing or invalid `Idempotency-Key` header |
| 404 | Batch not found |
| 409 | Illegal status transition or separation of duties violation |
@@ -624,6 +628,27 @@ Error response:
All work queue endpoints support pagination via `?page=1&pageSize=20` (except `overview`).
### Batch Audit Trail
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/digitization-batches/{id}/events` | Administrator, Verifier, Clinical Approver | Cursor-paginated digitization events for a batch |
Query parameters: `after` (ISO-8601 cursor from previous page's `nextCursor`), `pageSize` (default 50, max 200). Events are ordered chronologically (oldest first).
**Event object (`BatchEventResponse`):**
| Field | Type | Description |
|---|---|---|
| `id` | Guid | Event ID |
| `batchId` | Guid | Batch ID |
| `eventType` | string | e.g. `uploaded`, `verified`, `promoted`, `promotion_retry_failed` |
| `actorUserId` | Guid | User who performed the action |
| `actorUsername` | string | Actor username |
| `actorFullName` | string | Actor display name |
| `occurredAt` | DateTimeOffset | Event timestamp |
| `metadataJson` | string? | Optional JSON (field checks, rejection reason, etc.) |
**GET `/work-queue/overview` response:**
| Field | Type | Description |
@@ -967,6 +992,20 @@ createdAt DateTimeOffset
expiresAt DateTimeOffset 24-hour TTL
```
### PromotionAttempt
Tracks each promotion attempt for deferred-retry batches. `PromotionRetryService` polls rows where `nextRetryAt <= now` and the batch is still `APPROVED`.
```
id Guid PK
batchId Guid FK → DigitizationBatch
attemptNumber int 1-based attempt counter
succeeded bool whether this attempt completed promotion
errorMessage string? failure reason when succeeded = false
attemptedAt DateTimeOffset
nextRetryAt DateTimeOffset? scheduled retry time (null on success)
```
### User
```
@@ -1006,7 +1045,9 @@ occurredAt DateTimeOffset
## Pagination
List endpoints use offset pagination:
### Offset pagination
List endpoints (batch list, work queues) use offset pagination:
| Param | Default | Description |
|---|---|---|
@@ -1025,11 +1066,31 @@ Response shape:
}
```
### Cursor pagination
`GET /digitization-batches/{id}/events` uses cursor pagination on `occurredAt`:
| Param | Default | Description |
|---|---|---|
| `after` | — | ISO-8601 timestamp cursor from the previous page's `nextCursor` |
| `pageSize` | 50 | Events per page (max 200) |
Response shape:
```json
{
"items": [],
"pageSize": 50,
"nextCursor": "2026-06-27T12:00:00Z",
"hasMore": true
}
```
---
## Implemented Phases
Phases 18 are fully implemented and verified via integration tests and per-phase scripts. Phase 9 (E2E verification and clinical scenario documentation) is partially implemented.
Phases 18 are fully implemented and verified via integration tests and per-phase scripts. Phase 9 (E2E verification, clinical scenario docs, extended seed data) is partially complete.
| Phase | Feature | Status |
|---|---|---|
@@ -1041,4 +1102,4 @@ Phases 18 are fully implemented and verified via integration tests and per-ph
| 6 | Track B live capture: `LiveCaptureController` with clinician-only endpoints, `AttestationService` (role + password re-confirm), synchronous promotion via `LiveCaptureService`, Redis-backed critical threshold evaluation, inline critical alerts + committed `ClinicalAlert` rows, `observation.recorded` and `alert.generated` outbox events, open-encounter + vitals outpatient workflow, `LiveCaptureIntegrationTests`, Phase 6 verification script | Done |
| 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing, split-pane scan viewer, draft entry with auto-save, verification checkboxes, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard shell | Done |
| 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done |
| 9 | E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario documentation | Partial |
| 9 | E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario docs (`docs/digitization-workstation-guide.md`); extended seed data (demo patients/batches across all statuses) | Partial |
+321 -21
View File
@@ -1,39 +1,327 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public static class DataSeeder
{
// Deterministic IDs for referencing seeded entities in tests and scripts
private static readonly Guid IntakeClerk1Id = Guid.Parse("a1000000-0000-0000-0000-000000000001");
private static readonly Guid IntakeClerk2Id = Guid.Parse("a1000000-0000-0000-0000-000000000002");
private static readonly Guid EntryClerk1Id = Guid.Parse("a2000000-0000-0000-0000-000000000001");
private static readonly Guid EntryClerk2Id = Guid.Parse("a2000000-0000-0000-0000-000000000002");
private static readonly Guid Verifier1Id = Guid.Parse("a3000000-0000-0000-0000-000000000001");
private static readonly Guid Verifier2Id = Guid.Parse("a3000000-0000-0000-0000-000000000002");
private static readonly Guid Approver1Id = Guid.Parse("a4000000-0000-0000-0000-000000000001");
private static readonly Guid Approver2Id = Guid.Parse("a4000000-0000-0000-0000-000000000002");
private static readonly Guid Clinician1Id = Guid.Parse("a5000000-0000-0000-0000-000000000001");
private static readonly Guid Clinician2Id = Guid.Parse("a5000000-0000-0000-0000-000000000002");
private static readonly Guid Admin1Id = Guid.Parse("a6000000-0000-0000-0000-000000000001");
private static readonly Guid Admin2Id = Guid.Parse("a6000000-0000-0000-0000-000000000002");
// Patient IDs
private static readonly Guid Patient1Id = Guid.Parse("b1000000-0000-0000-0000-000000000001");
private static readonly Guid Patient2Id = Guid.Parse("b1000000-0000-0000-0000-000000000002");
private static readonly Guid Patient3Id = Guid.Parse("b1000000-0000-0000-0000-000000000003");
private static readonly Guid Patient4Id = Guid.Parse("b1000000-0000-0000-0000-000000000004");
private static readonly Guid Patient5Id = Guid.Parse("b1000000-0000-0000-0000-000000000005");
public static async Task SeedAsync(AppDbContext db)
{
var existing = await db.Users.Select(u => u.Username).ToListAsync();
if (existing.Count >= 12) return;
if (await db.Users.AnyAsync()) return;
var all = new[]
// ── Users (2 per role) ────────────────────────────────────────────────
var users = new[]
{
CreateUser("intake1", "Intake Clerk 1", UserRole.IntakeClerk),
CreateUser("intake2", "Intake Clerk 2", UserRole.IntakeClerk),
CreateUser("entry1", "Entry Clerk 1", UserRole.DataEntryClerk),
CreateUser("entry2", "Entry Clerk 2", UserRole.DataEntryClerk),
CreateUser("verifier1", "Verifier 1", UserRole.Verifier),
CreateUser("verifier2", "Verifier 2", UserRole.Verifier),
CreateUser("approver1", "Clinical Approver 1", UserRole.ClinicalApprover),
CreateUser("approver2", "Clinical Approver 2", UserRole.ClinicalApprover),
CreateUser("clinician1", "Dr. Tanaka", UserRole.Clinician),
CreateUser("clinician2", "Dr. Chen", UserRole.Clinician),
CreateUser("admin1", "Administrator 1", UserRole.Administrator),
CreateUser("admin2", "Administrator 2", UserRole.Administrator),
CreateUser(IntakeClerk1Id, "intake1", "Intake Clerk 1", UserRole.IntakeClerk),
CreateUser(IntakeClerk2Id, "intake2", "Intake Clerk 2", UserRole.IntakeClerk),
CreateUser(EntryClerk1Id, "entry1", "Entry Clerk 1", UserRole.DataEntryClerk),
CreateUser(EntryClerk2Id, "entry2", "Entry Clerk 2", UserRole.DataEntryClerk),
CreateUser(Verifier1Id, "verifier1", "Verifier 1", UserRole.Verifier),
CreateUser(Verifier2Id, "verifier2", "Verifier 2", UserRole.Verifier),
CreateUser(Approver1Id, "approver1", "Clinical Approver 1", UserRole.ClinicalApprover),
CreateUser(Approver2Id, "approver2", "Clinical Approver 2", UserRole.ClinicalApprover),
CreateUser(Clinician1Id, "clinician1", "Dr. Tanaka", UserRole.Clinician),
CreateUser(Clinician2Id, "clinician2", "Dr. Chen", UserRole.Clinician),
CreateUser(Admin1Id, "admin1", "Administrator 1", UserRole.Administrator),
CreateUser(Admin2Id, "admin2", "Administrator 2", UserRole.Administrator),
};
db.Users.AddRange(users);
var existingSet = existing.ToHashSet();
var missing = all.Where(u => !existingSet.Contains(u.Username)).ToArray();
if (missing.Length == 0) return;
// ── Batches across all types, tracks, and statuses ────────────────────
var now = DateTimeOffset.UtcNow;
// Batch 1: VITALS_SHEET / BACKFILL / PROMOTED (full lifecycle complete)
var batch1Id = Guid.Parse("c1000000-0000-0000-0000-000000000001");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch1Id,
Status = BatchStatus.Promoted,
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.Backfill,
PatientId = Patient1Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000001/abc123.pdf",
DocumentSha256 = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
EnableRetroactiveAlerts = false,
EnteredByUserId = EntryClerk1Id,
VerifiedByUserId = Verifier1Id,
ApprovedByUserId = Approver1Id,
PromotedAt = now.AddHours(-12),
CreatedAt = now.AddDays(-3),
UpdatedAt = now.AddHours(-12),
});
db.DraftPatients.Add(new DraftPatient
{
Id = Guid.NewGuid(), BatchId = batch1Id,
FullName = "Maria Santos", DateOfBirth = new DateOnly(1978, 3, 15),
Sex = "female", BloodType = BloodType.APos,
EmergencyContact = "Juan Santos - 555-0101",
NoKnownAllergies = false,
AllergiesJson = "[\"Penicillin\", \"Sulfa drugs\"]",
CreatedAt = now.AddDays(-3), UpdatedAt = now.AddDays(-2),
});
db.DraftEncounters.Add(new DraftEncounter
{
Id = Guid.NewGuid(), BatchId = batch1Id,
AdmissionDate = now.AddDays(-5),
Department = Department.InternalMedicine, RoomBed = "2A-04",
AdmissionReason = "Pneumonia with elevated WBC",
Status = "active",
CreatedAt = now.AddDays(-3), UpdatedAt = now.AddDays(-2),
});
db.DraftObservations.AddRange(
CreateObservation(batch1Id, "HEART_RATE", 88m, "bpm", now.AddDays(-5)),
CreateObservation(batch1Id, "TEMP_C", 38.7m, "C", now.AddDays(-5)),
CreateObservation(batch1Id, "BP_SYSTOLIC", 128m, "mmHg", now.AddDays(-5)),
CreateObservation(batch1Id, "BP_DIASTOLIC", 82m, "mmHg", now.AddDays(-5)),
CreateObservation(batch1Id, "RESP_RATE", 22m, "breaths/min", now.AddDays(-5)),
CreateObservation(batch1Id, "SPO2", 94m, "%", now.AddDays(-5)),
CreateObservation(batch1Id, "WBC_K_UL", 14.2m, "K/uL", now.AddDays(-5))
);
// Batch 2: PATIENT_REGISTRATION / BACKFILL / PENDING_VERIFICATION
var batch2Id = Guid.Parse("c1000000-0000-0000-0000-000000000002");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch2Id,
Status = BatchStatus.PendingVerification,
BatchType = BatchType.PatientRegistration,
Track = BatchTrack.Backfill,
PatientId = Patient2Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000002/def456.pdf",
DocumentSha256 = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
EnteredByUserId = EntryClerk2Id,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-6),
});
db.DraftPatients.Add(new DraftPatient
{
Id = Guid.NewGuid(), BatchId = batch2Id,
FullName = "Kenji Nakamura", DateOfBirth = new DateOnly(1952, 11, 8),
Sex = "male", BloodType = BloodType.ONeg,
EmergencyContact = "Yuki Nakamura - 555-0202",
NoKnownAllergies = true,
CreatedAt = now.AddDays(-1), UpdatedAt = now.AddHours(-6),
});
// Batch 3: LAB_RESULTS / BACKFILL / REJECTED (awaiting re-entry)
var batch3Id = Guid.Parse("c1000000-0000-0000-0000-000000000003");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch3Id,
Status = BatchStatus.Rejected,
BatchType = BatchType.LabResults,
Track = BatchTrack.Backfill,
PatientId = Patient3Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000003/ghi789.pdf",
DocumentSha256 = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
EnteredByUserId = EntryClerk1Id,
VerifiedByUserId = Verifier2Id,
RejectionReason = "Potassium value unclear on scan - decimal position ambiguous (5.2 vs 52). Please re-examine source document.",
CreatedAt = now.AddDays(-2),
UpdatedAt = now.AddHours(-18),
});
db.DraftPatients.Add(new DraftPatient
{
Id = Guid.NewGuid(), BatchId = batch3Id,
FullName = "Leilani Tupou", DateOfBirth = new DateOnly(1990, 7, 22),
Sex = "female", BloodType = BloodType.BPos,
CreatedAt = now.AddDays(-2), UpdatedAt = now.AddHours(-20),
});
db.DraftObservations.AddRange(
CreateObservation(batch3Id, "POTASSIUM_MEQ_L", 52m, "mEq/L", now.AddDays(-4)),
CreateObservation(batch3Id, "GLUCOSE_MG_DL", 110m, "mg/dL", now.AddDays(-4)),
CreateObservation(batch3Id, "LACTATE_MMOL_L", 1.8m, "mmol/L", now.AddDays(-4))
);
// Batch 4: ENCOUNTER_SUMMARY / BACKFILL / IN_ENTRY
var batch4Id = Guid.Parse("c1000000-0000-0000-0000-000000000004");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch4Id,
Status = BatchStatus.InEntry,
BatchType = BatchType.EncounterSummary,
Track = BatchTrack.Backfill,
PatientId = Patient1Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000004/jkl012.pdf",
DocumentSha256 = "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5",
EnteredByUserId = EntryClerk2Id,
CreatedAt = now.AddHours(-4),
UpdatedAt = now.AddHours(-2),
});
// Batch 5: ALLERGY_UPDATE / BACKFILL / AWAITING_CLINICAL_APPROVAL
var batch5Id = Guid.Parse("c1000000-0000-0000-0000-000000000005");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch5Id,
Status = BatchStatus.AwaitingClinicalApproval,
BatchType = BatchType.AllergyUpdate,
Track = BatchTrack.Backfill,
PatientId = Patient4Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000005/mno345.pdf",
DocumentSha256 = "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6",
EnteredByUserId = EntryClerk1Id,
VerifiedByUserId = Verifier1Id,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-3),
});
db.DraftPatients.Add(new DraftPatient
{
Id = Guid.NewGuid(), BatchId = batch5Id,
FullName = "Anh Tran", DateOfBirth = new DateOnly(1985, 1, 30),
Sex = "female",
AllergiesJson = "[\"Codeine\", \"Iodine contrast\", \"Latex\"]",
NoKnownAllergies = false,
CreatedAt = now.AddDays(-1), UpdatedAt = now.AddHours(-5),
});
// Batch 6: VITALS_SHEET / LIVE_CAPTURE / PROMOTED (Track B)
var batch6Id = Guid.Parse("c1000000-0000-0000-0000-000000000006");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch6Id,
Status = BatchStatus.Promoted,
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.LiveCapture,
PatientId = Patient5Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000006/pqr678.pdf",
DocumentSha256 = "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1",
EnteredByUserId = Clinician1Id,
ClinicianAttestation = true,
PromotedAt = now.AddHours(-1),
CreatedAt = now.AddHours(-2),
UpdatedAt = now.AddHours(-1),
});
db.DraftObservations.AddRange(
CreateObservation(batch6Id, "HEART_RATE", 72m, "bpm", now.AddHours(-2)),
CreateObservation(batch6Id, "TEMP_C", 36.8m, "C", now.AddHours(-2)),
CreateObservation(batch6Id, "BP_SYSTOLIC", 120m, "mmHg", now.AddHours(-2)),
CreateObservation(batch6Id, "BP_DIASTOLIC", 78m, "mmHg", now.AddHours(-2)),
CreateObservation(batch6Id, "SPO2", 98m, "%", now.AddHours(-2))
);
// Batch 7: MEDICATION_LIST / BACKFILL / UPLOADED (fresh, unassigned)
var batch7Id = Guid.Parse("c1000000-0000-0000-0000-000000000007");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch7Id,
Status = BatchStatus.Uploaded,
BatchType = BatchType.MedicationList,
Track = BatchTrack.Backfill,
PatientId = Patient2Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000007/stu901.pdf",
DocumentSha256 = "a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8",
CreatedAt = now.AddMinutes(-30),
UpdatedAt = now.AddMinutes(-30),
});
// Batch 8: MIXED / BACKFILL / VERIFIED
var batch8Id = Guid.Parse("c1000000-0000-0000-0000-000000000008");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch8Id,
Status = BatchStatus.Verified,
BatchType = BatchType.Mixed,
Track = BatchTrack.Backfill,
PatientId = Patient3Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000008/vwx234.pdf",
DocumentSha256 = "b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9",
EnteredByUserId = EntryClerk2Id,
VerifiedByUserId = Verifier1Id,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddHours(-8),
});
// Batch 9: VITALS_SHEET / BACKFILL / APPROVED (waiting for promotion — tests retry worker)
var batch9Id = Guid.Parse("c1000000-0000-0000-0000-000000000009");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch9Id,
Status = BatchStatus.Approved,
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.Backfill,
PatientId = Patient4Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000009/yza567.pdf",
DocumentSha256 = "c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0",
EnteredByUserId = EntryClerk1Id,
VerifiedByUserId = Verifier2Id,
ApprovedByUserId = Approver1Id,
CreatedAt = now.AddDays(-1),
UpdatedAt = now.AddMinutes(-45),
});
db.DraftObservations.AddRange(
CreateObservation(batch9Id, "HEART_RATE", 92m, "bpm", now.AddDays(-3)),
CreateObservation(batch9Id, "TEMP_C", 37.2m, "C", now.AddDays(-3)),
CreateObservation(batch9Id, "RESP_RATE", 18m, "breaths/min", now.AddDays(-3)),
CreateObservation(batch9Id, "SPO2", 96m, "%", now.AddDays(-3))
);
// Batch 10: Correction batch (supersedes batch 1)
var batch10Id = Guid.Parse("c1000000-0000-0000-0000-000000000010");
db.DigitizationBatches.Add(new DigitizationBatch
{
Id = batch10Id,
Status = BatchStatus.PendingVerification,
BatchType = BatchType.VitalsSheet,
Track = BatchTrack.Backfill,
PatientId = Patient1Id,
SupersedesBatchId = batch1Id,
DocumentRef = "scans/2026/06/c1000000-0000-0000-0000-000000000010/correction.pdf",
DocumentSha256 = "d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1f2a7b8c9d0e1",
EnteredByUserId = EntryClerk2Id,
CreatedAt = now.AddHours(-2),
UpdatedAt = now.AddHours(-1),
});
db.DraftObservations.AddRange(
CreateObservation(batch10Id, "HEART_RATE", 88m, "bpm", now.AddDays(-5)),
CreateObservation(batch10Id, "TEMP_C", 38.7m, "C", now.AddDays(-5)),
CreateObservation(batch10Id, "SPO2", 95m, "%", now.AddDays(-5)) // corrected from 94
);
// ── Audit events for completed batches ─────────────────────────────────
db.DigitizationEvents.AddRange(
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Uploaded, ActorUserId = IntakeClerk1Id, OccurredAt = now.AddDays(-3) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.EntryStarted, ActorUserId = EntryClerk1Id, OccurredAt = now.AddDays(-2).AddHours(-6) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.SubmittedForVerification, ActorUserId = EntryClerk1Id, OccurredAt = now.AddDays(-2) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Verified, ActorUserId = Verifier1Id, OccurredAt = now.AddDays(-1) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Approved, ActorUserId = Approver1Id, OccurredAt = now.AddHours(-14) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch1Id, EventType = DigitizationEventType.Promoted, ActorUserId = Approver1Id, OccurredAt = now.AddHours(-12) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.Uploaded, ActorUserId = IntakeClerk2Id, OccurredAt = now.AddDays(-2) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.EntryStarted, ActorUserId = EntryClerk1Id, OccurredAt = now.AddDays(-2).AddHours(-3) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.SubmittedForVerification, ActorUserId = EntryClerk1Id, OccurredAt = now.AddHours(-20) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch3Id, EventType = DigitizationEventType.Rejected, ActorUserId = Verifier2Id, OccurredAt = now.AddHours(-18),
MetadataJson = JsonSerializer.Serialize(new { rejectionReason = "Potassium value unclear on scan - decimal position ambiguous" }) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch6Id, EventType = DigitizationEventType.Uploaded, ActorUserId = Clinician1Id, OccurredAt = now.AddHours(-2) },
new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batch6Id, EventType = DigitizationEventType.Promoted, ActorUserId = Clinician1Id, OccurredAt = now.AddHours(-1) }
);
db.Users.AddRange(missing);
await db.SaveChangesAsync();
}
private static User CreateUser(string username, string fullName, UserRole role) => new()
private static User CreateUser(Guid id, string username, string fullName, UserRole role) => new()
{
Id = Guid.NewGuid(),
Id = id,
Username = username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
FullName = fullName,
@@ -41,4 +329,16 @@ public static class DataSeeder
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow
};
private static DraftObservation CreateObservation(
Guid batchId, string code, decimal value, string unit, DateTimeOffset recordedAt) => new()
{
Id = Guid.NewGuid(),
BatchId = batchId,
ObservationCode = code,
Value = value,
Unit = unit,
RecordedAt = recordedAt,
CreatedAt = DateTimeOffset.UtcNow,
};
}
+104 -56
View File
@@ -1,5 +1,29 @@
# PRD: VigilCare Records — Paper Chart Digitization & Approval Platform
## Implementation Status
**Phases 18 are complete.** Phase 9 is partially complete.
| Phase | Scope | Status |
|---|---|---|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine | Done |
| 2 | Draft entry API, plausibility validation, submit-for-verification | Done |
| 3 | Verification, rejection, separation of duties, work queues | Done |
| 4 | Promotion to VigilCareClinical live tables, outbox, idempotency | Done |
| 5 | Corrections / supersession, patient digitization history | Done |
| 6 | Track B live capture with clinician attestation | Done |
| 7 | Digitization workstation UI (`vigilcare-records-web`) | Done |
| 8 | Prometheus metrics, supervisor overview, batch events API, promotion retry | Done |
| 9 | E2E verification script, clinical scenario docs, extended seed data | Partial |
**Delivered in Phase 9 so far:** `scripts/run-vigilcare-records-verification-p9.sh` (full workflow + metrics smoke test), [digitization-workstation-guide.md](digitization-workstation-guide.md) (backfill, live capture, corrections).
**Remaining in Phase 9:** Extended `DataSeeder` with demo patients and batches across all statuses/types/tracks for dashboard demos without manual data entry.
See [README.md](../README.md) for API reference, quick start, and verification scripts.
---
## Overview
A clinical records intake system that converts paper-based patient charts into structured, human-verified digital records before they enter the VigilCareClinical alerting pipeline. Designed for small hospitals, district clinics, and island health systems where the majority of historical and day-to-day records still exist on paper.
@@ -14,7 +38,7 @@ VigilCareClinical ([vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md
This project maps to `sd-mid-009` (Outbox Pattern for approval promotion events), `sd-mid-013` (CQRS — draft vs live read models), `sd-junior-004` (RBAC), and senior trade-off conversations around **data quality gates vs time-to-value** in resource-constrained health systems.
**Stack:** .NET 8 Web API, PostgreSQL, MinIO (scanned document storage), Redis (work-queue assignment locks), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose. Vue 3 digitization workstation UI (separate repo or `VigilCare.Records.Web` project).
**Stack:** .NET 8 Web API, PostgreSQL 16, MinIO (scanned document storage), Redis 7 (batch assignment locks and live-capture threshold cache), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose. Vue 3 digitization workstation UI in `vigilcare-records-web/` (Vite, Pinia, Tailwind CSS).
**Prerequisite / companion:** VigilCareClinicalAPI Phases 12 minimum (patient, encounter, observation ingest). Full VigilCare value unlocks as approved observations flow into the existing Kafka alert pipeline.
@@ -300,7 +324,7 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
---
### 1. Document Upload and Batch Creation
### 1. Document Upload and Batch Creation — *implemented (Phase 1)*
**Description:** Intake clerk scans or uploads a paper record. System stores the file in MinIO, computes SHA-256, creates a batch in `uploaded` state, and writes a `DigitizationEvent`.
@@ -320,7 +344,7 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
---
### 2. Draft Data Entry
### 2. Draft Data Entry — *implemented (Phase 2)*
**Description:** Entry clerk views the scan alongside structured form fields. Saves draft patient updates, encounter context, and observations. Batch moves to `in_entry` on first save.
@@ -355,7 +379,7 @@ This keeps digitization history, patient coverage stats, and Prometheus batch me
---
### 3. Verification and Rejection
### 3. Verification and Rejection — *implemented (Phase 3)*
**Description:** Verifier reviews entry against the scan. Can approve field-by-field or reject the entire batch with a mandatory reason. Verifier cannot be the entry clerk.
@@ -364,7 +388,7 @@ On verify pass: status → `verified` or `awaiting_clinical_approval` per [site
**Endpoints:**
- `GET /api/v1/work-queue/verification` — batches in `pending_verification`, sorted by `submittedAt ASC`
- `GET /api/v1/work-queue/clinical-approval` — batches in `awaiting_clinical_approval`, sorted by `submittedAt ASC`
- `POST /api/v1/digitization-batches/:id/verify` — body: `{ "fieldChecks": [{ "fieldPath": "observations[0].value", "passed": true }], "passed": true }`
- `POST /api/v1/digitization-batches/:id/verify` — body: `{ "fieldChecks": [{ "fieldName": "observations[0].value", "status": "ok", "note": null }], "passed": true }`
- `POST /api/v1/digitization-batches/:id/reject` — body: `{ "reason": "..." }` → status `rejected`, notifies entry clerk
Reject is allowed from `pending_verification` (verifier; separation of duties applies) or `awaiting_clinical_approval` (clinical approver; separation of duties does **not** apply — approver may not have been the entry clerk by role design).
@@ -373,12 +397,13 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
---
### 4. Approval and Promotion to VigilCareClinical
### 4. Approval and Promotion to VigilCareClinical — *implemented (Phase 4)*
**Description:** Final approval triggers an atomic promotion: draft records become live Patient / Encounter / Observation rows in VigilCareClinical (same database in integrated deployment, or HTTP calls to VigilCareClinical API in split deployment). Batch status → `promoted`.
**Endpoints:**
- `POST /api/v1/digitization-batches/:id/approve` — requires `verified` or `awaiting_clinical_approval`; restricted to `ClinicalApprover` or `Administrator`; Idempotency-Key supported
- `POST /api/v1/digitization-batches/:id/approve` — requires `verified` or `awaiting_clinical_approval`; restricted to `ClinicalApprover` or `Administrator`; `Idempotency-Key` required; returns **202** with `PROMOTION_DEFERRED` on transient failure (batch stays `approved`)
- `POST /api/v1/digitization-batches/:id/promote` — manual promotion of an `approved` batch (also used after deferral)
- `GET /api/v1/digitization-batches/:id/promotion-result` — live IDs created: `patientId`, `encounterId`, `observationIds[]`
**Promotion transaction sequence:**
@@ -402,7 +427,7 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
---
### 5. Corrections and Supersession
### 5. Corrections and Supersession — *implemented (Phase 5)*
**Description:** Approved records are not silently edited. A correction creates a new batch with `supersedesBatchId` pointing to the original. Correction goes through the full entry → verify → approve cycle. On promotion, erroneous live observations are marked `superseded` (append-only — not deleted).
@@ -414,7 +439,7 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
---
### 6. Live Capture (Track B)
### 6. Live Capture (Track B) — *implemented (Phase 6)*
**Description:** Credentialed clinician enters vitals or labs at point of care on a tablet. No verification queue. Each submission creates an audit `DigitizationBatch` (see [Track B audit model](#track-b-audit-model)), writes draft observations for traceability, and promotes live observations synchronously with full alert evaluation.
@@ -428,38 +453,46 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
---
### 7. Patient Registry (Draft and Live)
### 7. Patient Registry (Draft and Live) — *partially implemented*
**Description:** Search and link batches to patients. Support new patient registration through the draft pipeline.
**Description:** Search and link batches to patients. New patient registration flows through the draft pipeline on a `patient_registration` batch; there is no standalone draft-patient API in v1.
**Endpoints:**
- `GET /api/v1/patients/search?q=` — search live VigilCareClinical patients by MRN or name
- `POST /api/v1/patients/draft` — create draft-only patient (no MRN until approval)
**Implemented endpoints:**
- `GET /api/v1/patients/search?q=` — search live patients by MRN or name (min 2 characters)
- `GET /api/v1/patients/:id/digitization-history` — all batches for a patient with correction chain and audit trails
**Deferred (not in v1):**
- `POST /api/v1/patients/draft` — create draft-only patient without a batch
- `GET /api/v1/patients/:id/summary` — live patient + pending draft batches + digitization coverage stats
**Digitization coverage stat:** `approvedBatchCount / estimatedTotalBatches` — optional manual `estimatedChartSections` per patient for progress tracking.
**Digitization coverage stat (future):** `approvedBatchCount / estimatedTotalBatches` — optional manual `estimatedChartSections` per patient for progress tracking.
---
### 8. Work Queues and Operational Dashboard
### 8. Work Queues and Operational Dashboard — *implemented (Phase 8)*
**Description:** Supervisors monitor backlog, assignment, and throughput.
**Endpoints:**
- `GET /api/v1/work-queue/entry` — batches awaiting or in entry
- `GET /api/v1/work-queue/clinical-approval` — batches awaiting physician sign-off after verification
- `GET /api/v1/work-queue/overview` — counts by status, average time-in-queue, reject rate
- `GET /api/v1/digitization-batches/:id/events` — cursor-paginated audit trail
- `GET /api/v1/work-queue/entry` — batches awaiting or in entry (`UPLOADED`, `IN_ENTRY`, `REJECTED`)
- `GET /api/v1/work-queue/verification` — batches in `PENDING_VERIFICATION`, sorted by submission time
- `GET /api/v1/work-queue/clinical-approval` — batches in `AWAITING_CLINICAL_APPROVAL`
- `GET /api/v1/work-queue/overview` — counts by status, average time-in-queue, 24h reject rate, oldest pending verification (administrator only)
- `GET /api/v1/digitization-batches/:id/events` — cursor-paginated audit trail with actor username and full name
**Metrics (Prometheus):**
**Promotion retry (Phase 8):** When `POST .../approve` fails due to transient infrastructure errors, the batch stays `APPROVED` and returns **202** with `PROMOTION_DEFERRED`. `PromotionRetryService` retries with exponential backoff; operators may also call `POST .../promote` manually.
**Metrics (Prometheus) — implemented at `GET /metrics`:**
- `digitization_batches_by_status` (gauge)
- `digitization_promotion_duration_seconds` (histogram)
- `digitization_rejection_total` (counter)
- `digitization_queue_age_seconds` (gauge — oldest pending verification)
Docker Compose exposes Prometheus on port **9095** and Grafana on **3013**.
---
### 9. Authentication and Audit
### 9. Authentication and Audit — *implemented (Phases 1, 8)*
**Description:** JWT auth with role claims. Every state transition writes a `DigitizationEvent`. Document access logged.
@@ -477,18 +510,20 @@ Reject is allowed from `pending_verification` (verifier; separation of duties ap
---
## Digitization Workstation UI
## Digitization Workstation UI — *implemented (Phase 7)*
Separate Vue 3 SPA or Razor-hosted frontend. Four primary views:
Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api` → API on **5217**). Four primary views:
| View | User | Purpose |
|---|---|---|
| **Intake** | Intake clerk | Upload, assign patient, print MRN label |
| **Entry** | Data entry clerk | Side-by-side scan + form |
| **Intake** | Intake clerk | Upload, assign patient, assign entry clerk |
| **Entry** | Data entry clerk | Side-by-side scan + form with auto-save |
| **Verification** | Verifier | Side-by-side with field checkboxes, approve/reject |
| **Queue dashboard** | Supervisor | Backlog, reject rate, clerk throughput |
| **Queue dashboard** | Administrator | Backlog metrics from work-queue overview |
Not a full EMR UI. No clinical alerting views — those remain in VigilCareClinical's ward dashboard.
Role-based routing and JWT refresh are implemented. Not a full EMR UI clinical alerting views remain in VigilCareClinical's ward dashboard.
See [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows and clinical scenarios.
---
@@ -522,38 +557,42 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved
## Acceptance Criteria
| Criterion | Verification |
|---|---|
| Separation of duties | Entry clerk cannot verify or approve own batch — `409` |
| Draft isolation | Draft observations never appear in VigilCareClinical alert queries or ward dashboard |
| Promotion atomicity | Partial promotion (patient created, observations failed) never committed |
| Idempotent approval | Duplicate `Idempotency-Key` on approve returns same result, no duplicate observations |
| Rejection loop | Rejected batch returns to entry; resubmit reaches verification again |
| Backfill alert suppression | Default backfill promotion creates observations with zero alerts |
| Live capture alert path | Track B critical potassium triggers synchronous alert in VigilCareClinical |
| Audit completeness | Every status transition has a `DigitizationEvent` with actor and timestamp |
| Document immutability | Scan object in MinIO not modified or deleted on reject/correct |
| Plausibility at draft | Value 520 for potassium rejected at draft save, not at promotion |
| Clinical approval routing | `vitals_sheet` verify-pass → `awaiting_clinical_approval` when site config requires it |
| Track B audit batch | Live capture creates `DigitizationBatch` in `promoted` with draft observations and events |
| Draft assignment guard | Unassigned clerk receives `409 BATCH_NOT_ASSIGNED` on draft save |
| Cross-patient duplicate scan | Same SHA for two different patients allowed; same patient within 24h rejected |
| Criterion | Verification | Status |
|---|---|---|
| Separation of duties | Entry clerk cannot verify or approve own batch — `409` | Done |
| Draft isolation | Draft observations never appear in VigilCareClinical alert queries or ward dashboard | Done |
| Promotion atomicity | Partial promotion (patient created, observations failed) never committed | Done |
| Idempotent approval | Duplicate `Idempotency-Key` on approve returns same result, no duplicate observations | Done |
| Rejection loop | Rejected batch returns to entry; resubmit reaches verification again | Done |
| Backfill alert suppression | Default backfill promotion creates observations with zero alerts | Done |
| Live capture alert path | Track B critical potassium triggers synchronous alert in VigilCareClinical | Done |
| Audit completeness | Every status transition has a `DigitizationEvent` with actor and timestamp | Done |
| Document immutability | Scan object in MinIO not modified or deleted on reject/correct | Done |
| Plausibility at draft | Value 520 for potassium rejected at draft save, not at promotion | Done |
| Clinical approval routing | `vitals_sheet` verify-pass → `awaiting_clinical_approval` when site config requires it | Done |
| Track B audit batch | Live capture creates `DigitizationBatch` in `promoted` with draft observations and events | Done |
| Draft assignment guard | Unassigned clerk receives `409 BATCH_NOT_ASSIGNED` on draft save | Done |
| Cross-patient duplicate scan | Same SHA for two different patients allowed; same patient within 24h rejected | Done |
| Supervisor metrics | Work-queue overview and Prometheus gauges reflect live batch counts | Done |
| Promotion retry | Transient promotion failure defers to `APPROVED` with automatic retry | Done |
| E2E verification script | `./scripts/run-vigilcare-records-verification-p9.sh` passes against running API | Done |
| Extended demo seed data | Startup seed includes patients/batches across all statuses for dashboard demos | Pending |
---
## Build Order
| Phase | Focus |
|---|---|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine |
| 2 | Draft entry API (patient, encounter, observations), submit-for-verification |
| 3 | Verification, rejection, separation of duties, work queues |
| 4 | Promotion service → VigilCareClinical live tables, outbox integration, idempotency |
| 5 | Corrections / supersession, patient digitization history |
| 6 | Track B live capture with clinician attestation |
| 7 | Digitization workstation UI (entry + verification side-by-side) |
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job |
| 9 | Seed data, E2E verification script, clinical scenario documentation |
| Phase | Focus | Status |
|---|---|---|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine | Done |
| 2 | Draft entry API (patient, encounter, observations), submit-for-verification | Done |
| 3 | Verification, rejection, separation of duties, work queues | Done |
| 4 | Promotion service → VigilCareClinical live tables, outbox integration, idempotency | Done |
| 5 | Corrections / supersession, patient digitization history | Done |
| 6 | Track B live capture with clinician attestation | Done |
| 7 | Digitization workstation UI (entry + verification side-by-side) | Done |
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Done |
| 9 | Extended seed data, E2E verification script, clinical scenario documentation | Partial |
---
@@ -634,7 +673,13 @@ This phase connects Records to Clinical. Run against a VigilCareClinical Phase 2
### Phases 79 — UI, Observability, Documentation
Build the side-by-side workstation UI. Add Prometheus metrics and a supervisor queue view. Write `docs/digitization-workstation-guide.md` and an E2E script `./scripts/run-vigilcare-records-verification.sh`.
**Phase 7 (done):** Vue 3 workstation UI with intake, entry, verification, and supervisor dashboard views. Role-based routing, split-pane scan viewer, presigned URL refresh.
**Phase 8 (done):** Prometheus metrics, `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202) and `PromotionRetryService`, Docker Prometheus/Grafana stack. Verification: `./scripts/run-vigilcare-records-phase-8-verification.sh`.
**Phase 9 (partial):**
- Done: `docs/digitization-workstation-guide.md`, `./scripts/run-vigilcare-records-verification-p9.sh`, project README
- Remaining: extend `DataSeeder.cs` with demo patients and batches across all statuses, types, and tracks
---
@@ -678,6 +723,9 @@ Build the side-by-side workstation UI. Add Prometheus metrics and a supervisor q
## References
- [README.md](../README.md) — API reference, quick start, verification scripts, data models
- [digitization-workstation-guide.md](digitization-workstation-guide.md) — clinical scenarios (backfill, live capture, corrections)
- [plans/](plans/) — phase-by-phase implementation guides (Phases 19)
- [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest
- [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
- [Completed/national-digital-services-architecture.md](Completed/national-digital-services-architecture.md) — scan-and-verify pattern for paper-to-digital government services