Living document. Updated after every major feature or structural change. Last updated: Hedera HCS integration + Sentinel outbreak detection (2026-03-22)
Open Nucleus is an open-source, offline-first electronic health record (EHR) system designed for military forward operating bases, disaster relief zones, and small clinics in sub-Saharan Africa. It assumes zero connectivity as the default and treats network access as a bonus.
Single Go binary (cmd/nucleus/main.go) with all services running in-process. The Python Sentinel Agent runs as a separate process on :50056 (gRPC) / :8090 (HTTP). The Flutter frontend lives in a separate repo (open-nucleus-app) and consumes the HTTP API as a pure REST client.
Dual-layer data model: FHIR R4 resources are stored as encrypted JSON files in a Git repository (source of truth) with SQLite as a rebuildable search index containing extracted fields only (no full FHIR JSON). Every clinical write validates, extracts search fields, encrypts, commits to Git, then upserts SQLite.
Per-patient encryption: AES-256-GCM envelope encryption with master key wrapping per-patient DEKs. Per-provider ECDH key grants allow individual DEK copies per device. Destroying a patient's key renders their Git data permanently unreadable (crypto-erasure).
Consent-based access control: FHIR Consent resources gate patient data access. ConsentCheck middleware enforces consent after JWT auth. Break-glass emergency access creates time-limited (4h) consents with mandatory audit. Verifiable Credentials provide offline consent proofs.
Blind indexes: SQLite stores HMAC-SHA256 blind indexes of PII (names, dates) in a patients_ngrams table, enabling n-gram substring search without exposing plaintext in the index.
Git-based sync: Nodes sync using Git fetch/merge/push over ECDH-encrypted channels. A FHIR-aware merge driver classifies conflicts into auto-merge (safe), review (flag for clinician), or block (clinical safety risk).
Sentinel Agent: LLM-powered sleeper agent with 13 pluggable skills (5 IDSR epidemic detectors, 8 clinical). Dual-engine: LLM reasoning with rule-based fallback. Can run offline on Raspberry Pi 4 with Ollama (phi3:mini) or in rule-only mode without any LLM.
Hedera HCS Anchoring: Git Merkle roots are submitted as messages to Hedera Consensus Service topics. Verification queries the Hedera Mirror Node REST API. did:hedera DIDs use HCS topics as append-only DID document logs. Config selects "hedera" or "stub" backend.
Flutter App (HTTPS REST/JSON)
│
▼
┌─────────────────────────────┐
│ nucleus (single binary) │ HTTP :8080 (TLS)
│ Patient, Auth, Sync, │
│ Formulary, Anchor │
│ ┌─────────┐ ┌──────────┐ │
│ │ Git repo │ │ SQLite │ │
│ │(encrypted│ │(index │ │
│ │ FHIR) │ │ only) │ │
│ └─────────┘ └──────────┘ │
└─────────────────────────────┘
│ gRPC (optional)
▼
┌─────────────────────┐
│ Sentinel :50056 │ Python (separate process)
└─────────────────────┘
cmd/nucleus/main.go is the composition root. It constructs all services in-process:
config.Load(path)
│
▼
gitstore.NewStore(cfg.Data.RepoPath) ← shared Git repository
sql.Open("sqlite", cfg.Data.DBPath) ← shared unified SQLite DB
sqliteindex.InitUnifiedSchema(db) ← all tables in one schema
│
├─► pipeline.NewWriter(git, idx) ← FHIR write pipeline (validate→encrypt→git→sqlite)
│ ▼
│ local.NewPatientService(pw, idx, git)
│ ▼
│ handler.NewPatientHandler(patientSvc)
│
├─► authservice.NewAuthService(cfg, git, keystore, denyList)
│ ▼
│ local.NewAuthService(authImpl)
│ ▼
│ handler.NewAuthHandler(authSvc)
│
├─► authservice.NewSmartService(authImpl, clientStore)
│ ▼
│ local.NewSmartService(smartImpl)
│ ▼
│ handler.NewSmartHandler(smartSvc)
│
├─► syncservice.NewSyncEngine(cfg, git, conflicts, history, peers, mergeDriver, eventBus)
│ ▼
│ local.NewSyncService(syncEngine, historyStore, peerStore)
│ local.NewConflictService(conflictStore, eventBus)
│ ▼
│ handler.NewSyncHandler(syncSvc) + handler.NewConflictHandler(conflictSvc)
│
├─► formularyservice.New(drugDB, interactions, stockStore, dosingEngine)
│ ▼
│ local.NewFormularyService(formularyImpl)
│ ▼
│ handler.NewFormularyHandler(formularySvc)
│
├─► anchorservice.New(git, backend, identity, queue, store, creds, dids, nodeKey)
│ ▼
│ local.NewAnchorService(anchorImpl)
│ ▼
│ handler.NewAnchorHandler(anchorSvc)
│
├─► local.NewStubSentinelService() ← stubs when Sentinel not running
│ local.NewStubSupplyService()
│
├─► middleware.NewSchemaValidator() + load 8 JSON schemas
├─► middleware.NewJWTAuth(pubKey, issuer)
├─► middleware.NewRateLimiter(cfg.RateLimit)
│
▼
router.New(Config{all handlers, middleware, schemaValidator, auditLogger, corsOrigins})
│
▼
server.New(cfg, mux, logger).WithTLS(tlsCfg).Run()
Arrows mean "imports / depends on". No circular dependencies exist.
cmd/nucleus/main (monolith)
├── internal/config
├── internal/service/local ── services/*/ (direct business logic)
│ ── pkg/envelope (encryption)
├── internal/handler ── internal/service (interfaces only)
│ ── internal/model
├── internal/middleware ── internal/config (ratelimit only)
│ ── internal/model (all middleware)
├── internal/router ── internal/handler
│ ── internal/middleware
│ ── internal/model
├── internal/server ── internal/config
│ ── pkg/tls
├── pkg/gitstore ── (go-git/v5)
├── pkg/sqliteindex ── pkg/fhir
└── pkg/envelope ── (crypto/aes, crypto/cipher)
cmd/gateway/main (legacy, still builds)
├── internal/grpcclient ── internal/config
└── internal/service ── internal/grpcclient (gRPC adapters)
internal/model is the leaf package — imported by nearly everything, imports nothing internal.
- config.go —
Configstruct matchingconfig.yaml/ spec section 14. Loaded via koanf. - Consumed by: main (passed to pool, server, rate limiter), grpcclient (dial addresses/timeouts), server (port, timeouts).
- envelope.go —
Envelopestruct +JSON(),Success(),ErrorResponse()response writers. Every HTTP response flows through here. - errors.go — 16 error code constants (
ErrAuthRequired,ErrRateLimited, etc.) +ErrorHTTPStatusmap +WriteError()+NotImplementedError(). - pagination.go —
Paginationstruct,PaginationFromRequest(r)query parser,NewPagination()constructor. - auth.go —
NucleusClaims(JWT claims struct),LoginRequest,RefreshRequest,LogoutRequest. - rbac.go — 5 role constants, 24 permission constants,
RolePermissionsmatrix map,HasPermission(role, perm). - context.go — Context keys (
CtxRequestID,CtxClaims) + extraction helpersRequestIDFromContext(),ClaimsFromContext(). This is the glue that lets middleware pass data to handlers without coupling.
Each middleware is a func(http.Handler) http.Handler or a method that returns one. They compose via chi's r.Use() and r.With().
| File | What it writes to context | What it reads from context | External deps |
|---|---|---|---|
| requestid.go | CtxRequestID (UUID v4) |
— | github.com/google/uuid |
| jwtauth.go | CtxClaims (*NucleusClaims) |
— | github.com/golang-jwt/jwt/v5 |
| rbac.go | — | CtxClaims (reads role + permissions) |
— |
| ratelimit.go | — | CtxClaims (reads Subject for device ID) |
golang.org/x/time/rate |
| validator.go | — | — (reads r.Body) | github.com/santhosh-tekuri/jsonschema/v5 |
| cors.go | — | — (reads Origin header) | — |
| audit.go | — | CtxRequestID, CtxClaims |
log/slog |
| smartscope.go | — | CtxClaims (reads Scope, LaunchPatient) |
pkg/smart |
Context data flow:
requestid.go ──writes──► CtxRequestID ──read by──► audit.go, handlers (via Meta)
jwtauth.go ──writes──► CtxClaims ──read by──► rbac.go, ratelimit.go, audit.go, handlers
Middleware pipeline order on protected routes:
CORS → RequestID → AuditLog → JWTAuth → [per-route: RateLimiter → RequirePermission → SchemaValidator] → Handler
Auth routes skip JWTAuth and RBAC — they only get CORS + RequestID + AuditLog + RateLimiter(CategoryAuth).
- pool.go —
Poolholds amap[string]*grpc.ClientConnfor 6 named services.NewPool()dials all with timeout (non-blocking on failure — stores nil, returns SERVICE_UNAVAILABLE at call time).Conn(name)returns connection or error. - Consumed by: service adapters call
pool.Conn("auth"),pool.Conn("patient"), etc.
- interfaces.go — 9 service interfaces (
AuthService,PatientService,SyncService,ConflictService,SentinelService,FormularyService,AnchorService,SupplyService,SmartService) + all DTOs includingEraseResponse. Handlers depend only on these interfaces.
In-process adapters that call business logic directly without gRPC:
- patient.go —
patientServicewrapspipeline.Writer+sqliteindex.Index+gitstore.Store. Reads FHIR JSON from Git and decrypts viapw.DecryptFromGit(). ImplementsErasePatient()for crypto-erasure. - auth.go —
authServicewrapsauthservice.AuthServicedirectly. - smart.go —
smartServicewrapsauthservice.SmartServicedirectly. - sync.go —
syncServicewrapssyncservice.SyncEngine+ history/peer stores. AlsoconflictServicewraps conflict store + event bus. - formulary.go —
formularyServicewrapsformularyservice.FormularyServicedirectly. - anchor.go —
anchorServicewrapsanchorservice.AnchorServicedirectly. - stubs.go —
stubSentinelService+stubSupplyServicereturn 503 when Sentinel is not running.
- auth.go, patient.go, sync.go, etc. — gRPC adapters via
pool.Conn(name). Used bycmd/gateway/main.gowhen running in distributed microservice mode.
Key pattern: Handlers never touch business logic or gRPC directly. The service interface layer enables both monolith (local adapters) and distributed (gRPC adapters) deployment from the same handler code.
- auth.go —
AuthHandlerholdsservice.AuthService. Methods:Login,Refresh,Logout,Whoami. Whoami short-circuits from JWT claims in context if available. - patient.go —
PatientHandlerholdsservice.PatientService. Methods:List,GetByID,Search,Create,Update,Delete,History,Timeline,Match. Write methods usewriteResponseWithGit()to include git metadata in the response envelope. - clinical.go — Additional methods on
PatientHandlerfor all 22 clinical sub-resource endpoints:ListEncounters,GetEncounter,CreateEncounter,UpdateEncounter,ListObservations,GetObservation,CreateObservation,ListConditions,CreateCondition,UpdateCondition,ListMedicationRequests,CreateMedicationRequest,UpdateMedicationRequest,ListAllergyIntolerances,CreateAllergyIntolerance,UpdateAllergyIntolerance,ListImmunizations,GetImmunization,CreateImmunization,ListProcedures,GetProcedure,CreateProcedure. - resource.go —
ResourceHandlerwith factory methods (ListFactory,GetFactory,CreateFactory,UpdateFactory) for top-level CRUD (Practitioner, Organization, Location).CapabilityStatementHandler()serves FHIR R4 CapabilityStatement at/fhir/metadata. - sync.go —
SyncHandlerholdsservice.SyncService. Methods:Status,Peers,Trigger,History,ExportBundle,ImportBundle. - conflict.go —
ConflictHandlerholdsservice.ConflictService. Methods:List,GetByID,Resolve,Defer. - sentinel.go —
SentinelHandlerholdsservice.SentinelService. Methods:ListAlerts,Summary,GetAlert,Acknowledge,Dismiss. - formulary.go —
FormularyHandlerholdsservice.FormularyService. 16 methods:SearchMedications,GetMedication,ListMedicationsByCategory,CheckInteractions,CheckAllergyConflicts,ValidateDosing,GetDosingOptions,GenerateSchedule,GetStockLevel,UpdateStockLevel,RecordDelivery,GetStockPrediction,GetRedistributionSuggestions,GetFormularyInfo. - anchor.go —
AnchorHandlerholdsservice.AnchorService. 13 methods:Status,Verify,History,Trigger,NodeDID,DeviceDID,ResolveDID,IssueCredential,VerifyCredentialHandler,ListCredentials,ListBackends,BackendStatus,QueueStatus. - supply.go —
SupplyHandlerholdsservice.SupplyService. Methods:Inventory,InventoryItem,RecordDelivery,Predictions,Redistribution. - stubs.go —
StubHandler()returns 501 viamodel.NotImplementedError(). Only used for WebSocket endpoint (Phase 5).
- router.go —
New(Config)builds the chi route tree. Config now includes all 8 handler types +SchemaValidator.validatorMiddleware()helper returns a no-op if SchemaValidator is nil (for tests without schemas). Owns middleware scoping:/health— no middleware beyond global/api/v1/auth/*— global + RateLimiter(CategoryAuth), NO JWT/RBAC/api/v1/*(everything else) — global + JWTAuth, then per-route RateLimiter + RequirePermission + optional SchemaValidator/fhir/metadata— no auth, serves FHIR CapabilityStatement/api/v1/patients/{id}/immunizations,/api/v1/patients/{id}/procedures— patient-scoped clinical/api/v1/practitioners,/api/v1/organizations,/api/v1/locations— top-level FHIR resources
- ~70 REST endpoints wired to real handlers. Only
/wsremains stubbed.
- server.go —
Serverwrapshttp.Serverwith config-driven timeouts.Run()starts listener and blocks until SIGINT/SIGTERM, then callsShutdown()with 10s grace period.
All 8 schemas use inline $defs for reusable Reference ({ reference: string minLength:1 }) and CodeableConcept (anyOf: [ has coding[], has text ]) patterns. They mirror the validation rules in pkg/fhir/validate.go so malformed payloads are rejected at the gateway before the gRPC round-trip.
- patient.json — Requires
resourceType: "Patient",namearray (items:{ family: string, given: string[] }),genderenum,birthDatestring. - encounter.json — Requires
resourceType: "Encounter",statusenum (8 FHIR values),classobject withcode,subjectReference,periodwithstart. - observation.json — Requires
resourceType: "Observation",statusenum (7 values),codeCodeableConcept,subjectReference,effectiveDateTime. - condition.json — Requires
resourceType: "Condition",clinicalStatusCodeableConcept,verificationStatusCodeableConcept,codeCodeableConcept,subjectReference. - medication_request.json — Requires
resourceType: "MedicationRequest",status,intent,medicationCodeableConceptCodeableConcept,subjectReference,dosageInstructionarray (minItems:1). - allergy_intolerance.json — Requires
resourceType: "AllergyIntolerance",clinicalStatusCodeableConcept,verificationStatusCodeableConcept,codeCodeableConcept,patientReference. - immunization.json — Requires
resourceType: "Immunization",statusenum (3 values),vaccineCodeCodeableConcept,patientReference,occurrenceDateTime. - procedure.json — Requires
resourceType: "Procedure",statusenum (8 values),codeCodeableConcept,subjectReference.
proto/
├── common/v1/
│ ├── metadata.proto ← GitMetadata (+ Timestamp), PaginationRequest/Response, NodeInfo
│ └── fhir.proto ← FHIRResource{resource_type, id, json_payload bytes}
├── auth/v1/
│ └── auth.proto ← AuthService: 15 RPCs (register, challenge, authenticate, refresh, logout, identity, devices, roles, validate, health)
├── patient/v1/
│ └── patient.proto ← PatientService: 49 RPCs (CRUD + clinical + immunization + procedure + generic CRUD + batch + index + health)
├── sync/v1/
│ └── sync.proto ← SyncService (14 RPCs) + ConflictService (4 RPCs) + NodeSyncService (3 RPCs)
├── formulary/v1/
│ └── formulary.proto ← FormularyService: 16 RPCs (drug lookup, interactions, allergy, dosing stub, stock, redistribution, info, health)
├── anchor/v1/
│ └── anchor.proto ← AnchorService: 14 RPCs (anchoring, DID, credentials, backend, health)
└── sentinel/v1/
└── sentinel.proto ← SentinelService: 5 alert RPCs + 5 supply chain RPCs
FHIR resources are opaque bytes json_payload — the gateway never parses or transforms them.
Generated Go code lives in gen/proto/ (protoc with go + go-grpc plugins).
AES-256-GCM envelope encryption with master key wrapping.
- envelope.go —
KeyManagerinterface:GetOrCreateKey(),DestroyKey(),Encrypt(),Decrypt(),IsKeyDestroyed().FileKeyManagerimpl stores wrapped DEKs in Git at.nucleus/keys/. In-memory cache withsync.RWMutex.
Auto-generate or load TLS certificates.
- certs.go —
Config{Mode, CertFile, KeyFile, CertDir}.LoadOrGenerate()returns*tls.Config. Modes: "auto" (self-signed Ed25519), "provided" (user PEM), "off" (nil).
Pure functions for working with FHIR resources. No I/O.
- types.go — Resource type constants for 17 types, operation constants (
OpCreate, etc.), row structs for 14 indexed types. NoFHIRJsonfield — row structs contain only extracted search fields. - path.go —
GitPath(resourceType, patientID, resourceID)returns Git file path. Patient-scoped:patients/{pid}/immunizations/{id}.json, etc. Top-level:practitioners/{id}.json. - meta.go —
SetMeta()writesmeta.lastUpdated/versionId/source.AssignID()assigns UUID if absent. - validate.go —
Validate(resourceType, json)structural validation for 12 resource types. - extract.go — Extract functions for all 14 indexed types. Returns row structs with search fields only (no full FHIR JSON).
- softdelete.go —
ApplySoftDelete()for all types. - registry.go — Central resource registry: 17 resource types with scope, interactions, search params.
- outcome.go — FHIR R4 OperationOutcome builder.
- bundle.go — FHIR R4 Bundle builder.
- capability.go — Auto-generates CapabilityStatement from registry.
- provenance.go — Auto-generates Provenance with HL7 v3-DataOperation coding.
Wraps go-git/v5 for clinical data Git repository management.
- store.go —
Storeinterface:WriteAndCommit(),Read(),LogPath(),Head(),TreeWalk(),Rollback().NewStore(repoPath)opens or inits repo. - commit.go —
CommitMessagestruct withFormat()andParseCommitMessage()for structured commit messages per spec §3.3.
Uses modernc.org/sqlite (pure Go, no CGO) for Raspberry Pi 4 deployment. Pure search index — no full FHIR JSON stored. All fhir_json columns have been removed.
- schema.go —
InitSchema()creates 14 resource tables + index_meta + FTS5 + triggers.InitUnifiedSchema()additionally creates auth (deny_list, revocations), sync (conflicts, sync_history, peers), formulary (stock_levels), and anchor (anchor_queue) tables.DropAll()for rebuild. - index.go —
Indexinterface: Upsert/Get/List methods for all 14 resource types +DeletePatientData()for crypto-erasure + bundle + search + timeline + match + meta + summary.NewIndex(dbPath)opens DB with WAL mode.NewIndexFromDB(*sql.DB)for shared DB in monolith. - erase.go —
DeletePatientData(patientID)deletes from 10 tables in a transaction for crypto-erasure. - search.go — FTS5 patient search via
patients_ftsvirtual table. - timeline.go —
GetTimeline()UNION ALL query across encounters, observations, conditions, flags. - match.go —
GetMatchCandidates()broad SQL query for patient identity matching. - summary.go —
UpdateSummary()recomputespatient_summariescounts.GetPatientBundle()returns patient + all active child resources.
The clinical data write pipeline. Single writer for all FHIR data: validate → extract search fields → encrypt → Git commit → SQLite upsert (fields only) → return resource + commit metadata. Supports optional per-patient envelope encryption via WithEncryption(keys) and crypto-erasure via DestroyPatientKey(patientID).
services/patient/
├── cmd/main.go ← gRPC server entrypoint, port :50051
├── config.yaml ← default config
├── internal/
│ ├── config/config.go ← koanf config loader
│ ├── pipeline/writer.go ← Write pipeline (sync.Mutex serialized)
│ └── server/
│ ├── server.go ← gRPC server struct + helpers (levenshtein, soundex)
│ ├── patient_rpcs.go ← List/Get/Bundle/Create/Update/Delete/Search/Match/History/Timeline
│ ├── encounter_rpcs.go ← List/Get/Create/Update
│ ├── observation_rpcs.go ← List/Get/Create
│ ├── condition_rpcs.go ← List/Get/Create/Update
│ ├── medrq_rpcs.go ← List/Get/Create/Update (MedicationRequest)
│ ├── allergy_rpcs.go ← List/Get/Create/Update (AllergyIntolerance)
│ ├── immunization_rpcs.go ← List/Get/Create (Immunization — patient-scoped)
│ ├── procedure_rpcs.go ← List/Get/Create (Procedure — patient-scoped)
│ ├── generic_rpcs.go ← Create/Get/List/Update/Delete (Practitioner/Organization/Location — top-level)
│ ├── flag_rpcs.go ← Create/Update (Sentinel write-back)
│ ├── batch_rpcs.go ← CreateBatch (atomic multi-resource commit)
│ ├── index_rpcs.go ← RebuildIndex, CheckIndexHealth, ReindexResources
│ └── health_rpcs.go ← Health check
└── patient_test.go ← Integration tests (full gRPC roundtrip)
Write pipeline (pipeline/writer.go):
- Validate FHIR JSON (pkg/fhir)
- Assign UUID if CREATE
- Set meta.lastUpdated/versionId/source
- Acquire sync.Mutex (5s timeout)
- Write JSON to Git + commit (pkg/gitstore)
- Extract fields + upsert SQLite (pkg/fhir + pkg/sqliteindex)
- Update patient_summaries
- Auto-generate FHIR Provenance (target ref, activity coding, agents) → write to Git (skip if resourceType == "Provenance")
- Release mutex, return resource + git metadata
Error handling (spec §11): Validation→INVALID_ARGUMENT, NotFound→NOT_FOUND, LockTimeout→ABORTED, GitFail→INTERNAL+rollback, SQLiteFail→log warning (data safe in Git).
Patient matching (spec §7): Weighted scoring (family 0.30, fuzzy 0.20, given 0.15, gender 0.10, birth year 0.10, district 0.05) with Levenshtein distance and Soundex phonetic matching.
Every response (success or error) goes through model.JSON() → model.Envelope{}. Handlers call model.Success(), model.SuccessWithPagination(), or model.WriteError(). Write operations use writeResponseWithGit() to include git metadata in the envelope. Never write raw JSON.
Service returns error → Handler calls model.WriteError(code, msg) → Envelope with status:"error"
gRPC unavailable errors map to ErrServiceUnavailable (503). Validation errors map to ErrValidation (400). The ErrorHTTPStatus map in model/errors.go is the single source of truth for code→status mapping.
POST/PUT requests for FHIR resources are validated against JSON schemas loaded at startup. The SchemaValidator middleware reads the request body, validates against the registered schema, resets the body for downstream handlers, and returns 400 with VALIDATION_ERROR on failure.
- Middleware tests: pass
httptest.Requestthrough middleware, assert onhttptest.Recorderstatus + body + context values. - Handler tests: inject mock service implementations (function fields), assert on response envelope. Mock types use embedded interface for convenience.
- Integration tests (router_test.go): wire real middleware + mock services, test full request flow (login → list patients, 401 without JWT, 503 for service unavailable, no more 501s on stubbed routes).
- E2E smoke tests (
test/e2e/smoke_test.go): Boot all 3 microservices (Auth, Patient, Sync) in-process on dynamic ports, wire the full gateway HTTP handler with real JWT validation, test the complete REST flow (auth → CRUD → sync). 11 tests covering health, auth enforcement, CRUD, sync status, token refresh, and logout. Run viamake test-e2e.
Exported test helpers that wrap internal service setup for E2E tests (Go's internal package restriction prevents direct imports from test/e2e/):
services/auth/authtest/— Starts in-process Auth Service, exposesAddr,PublicKey,GetChallenge(),AuthenticateWithNonce()services/patient/patienttest/— Starts in-process Patient Service, exposesAddrservices/sync/synctest/— Starts in-process Sync Service, exposesAddr
Each package also exports a StartStandalone() function that returns (env, cleanup, error) instead of requiring *testing.T. Used by the smoke test CLI.
Standalone Go program that boots all 5 services (Auth, Patient, Sync, Formulary, Anchor) + gateway in-process, runs 27 REST steps with colored PASS/FAIL output. No external deps, no *testing.T — just go run ./cmd/smoke or make smoke. Exercises: health, auth enforcement, full CRUD (patient + 5 clinical resources), timeline, history, sync, conflicts, formulary (search, interactions, allergy), anchor (status, trigger, DID, backends, queue), schema rejection, and delete. Exit code 0/1 for CI.
| Area | Status | Handler | Service Adapter |
|---|---|---|---|
| Auth (register/challenge/authenticate/refresh/logout/validate/roles/devices) | Handler complete, gRPC adapter wired to auth service :50053 | auth.go | auth.go |
| Patient reads (list/get/search) | Handler complete, gRPC adapter wired to patient service :50051 | patient.go | patient.go |
| Patient writes (create/update/delete) | Handler complete, gRPC adapter wired to patient service :50051 | patient.go | patient.go |
| Patient match/history/timeline | Handler complete, gRPC adapter wired to patient service :50051 | patient.go | patient.go |
| Encounters (list/get/create/update) | Handler complete, gRPC adapter wired to patient service :50051 | clinical.go | patient.go |
| Observations (list/get/create) | Handler complete, gRPC adapter wired to patient service :50051 | clinical.go | patient.go |
| Conditions (list/create/update) | Handler complete, gRPC adapter wired to patient service :50051 | clinical.go | patient.go |
| Medication Requests (list/create/update) | Handler complete, gRPC adapter wired to patient service :50051 | clinical.go | patient.go |
| Allergy Intolerances (list/create/update) | Handler complete, gRPC adapter wired to patient service :50051 | clinical.go | patient.go |
| Immunizations (list/get/create) | Handler complete, gRPC adapter wired to patient service :50051 | clinical.go | patient.go |
| Procedures (list/get/create) | Handler complete, gRPC adapter wired to patient service :50051 | clinical.go | patient.go |
| Practitioners (list/get/create/update) | Handler complete (ResourceHandler factory), gRPC adapter wired to patient service :50051 | resource.go | patient.go |
| Organizations (list/get/create/update) | Handler complete (ResourceHandler factory), gRPC adapter wired to patient service :50051 | resource.go | patient.go |
| Locations (list/get/create/update) | Handler complete (ResourceHandler factory), gRPC adapter wired to patient service :50051 | resource.go | patient.go |
| FHIR CapabilityStatement (/fhir/metadata) | Auto-generated from resource registry, no auth | resource.go | — |
| FHIR Bundle/OperationOutcome builders | Library-only (pkg/fhir), ready for Phase 2 /fhir/ routes | — | — |
| Provenance auto-generation | Auto-generated after every write in pipeline, committed to Git | — | writer.go |
| Resource Registry | Central registry of 15 FHIR types with scope, interactions, search params | — | registry.go |
| Sync (status/peers/trigger/cancel/history/bundle/transports/events) | Handler complete, gRPC adapter wired to sync service :50052 | sync.go | sync.go |
| Conflicts (list/get/resolve/defer) | Handler complete, gRPC adapter wired to sync service :50052 | conflict.go | conflict.go |
| Alerts (list/get/acknowledge/dismiss/summary) | Handler complete, gRPC adapter wired to sentinel service :50056 | sentinel.go | sentinel.go |
| Formulary (16 RPCs: drug lookup, interactions, allergy, dosing, stock, redistribution, info) | Handler complete, gRPC adapter wired to formulary service :50054 | formulary.go | formulary.go |
| Anchor (14 RPCs: anchoring, DID, credentials, backend, queue, health) | Handler complete, gRPC adapter wired to anchor service :50055 | anchor.go | anchor.go |
| Supply chain (inventory/deliveries/predictions/redistribution) | Handler complete, gRPC adapter wired to sentinel service :50056 | supply.go | supply.go |
| JSON Schema Validation | 8 hardened schemas (Reference, CodeableConcept, status enums, required fields mirror validate.go) | — | validator.go |
| WebSocket (/ws) | 501 stub | stubs.go | — |
- Proto: Define RPC + request/response messages in the appropriate
proto/*/v1/*.proto - Service interface: Add method to interface in
service/interfaces.go, add DTOs - Service adapter: Implement in
service/<domain>.gousingpool.Conn("<service>") - Handler: Add method to handler struct in
handler/<domain>.go - Router: Wire the handler method in
router/router.go - Schema: If POST/PUT with FHIR body, add JSON schema in
schemas/and register inmain.go - Tests: Unit test handler with mock service, add integration case in
router_test.go - Update this file
Ed25519 challenge-response authentication, EdDSA JWT issuance, device registry in Git, RBAC with 5 roles.
services/auth/
├── cmd/main.go ← gRPC server entrypoint, port :50053
├── config.yaml ← default config
├── internal/
│ ├── config/config.go ← koanf config loader
│ ├── store/
│ │ ├── schema.go ← SQLite tables: deny_list, revocations, node_info
│ │ └── denylist.go ← In-memory + SQLite deny list for JTI revocation
│ ├── service/
│ │ ├── auth.go ← AuthService: register, challenge, authenticate, refresh, logout, validate, revoke
│ │ └── device.go ← Git-backed device registry (CRUD .nucleus/devices/*.json)
│ └── server/
│ ├── server.go ← gRPC server struct + error mapping
│ ├── auth_rpcs.go ← RegisterDevice, GetChallenge, Authenticate, RefreshToken, Logout, GetCurrentIdentity
│ ├── device_rpcs.go ← ListDevices, RevokeDevice, CheckRevocation
│ ├── role_rpcs.go ← ListRoles, GetRole, AssignRole
│ ├── validation_rpcs.go ← ValidateToken, CheckPermission
│ └── health_rpcs.go ← Health
└── auth_test.go ← 12 integration tests (bootstrap, full auth cycle, brute force, revocation, etc.)
Auth flow: RegisterDevice → GetChallenge (32-byte nonce) → Authenticate (Ed25519 sig of nonce) → JWT issued → ValidateToken (<1ms, all in-memory)
Token validation: VerifyToken parses JWT → check deny list (in-memory map) → check device revocation list. All O(1), no I/O.
RBAC: 5 roles (CHW, Nurse, Physician, SiteAdmin, RegionalAdmin) × 37 permissions. Site scope: "local" (single site) or "regional" (cross-site).
Transport-agnostic Git sync, FHIR-aware merge driver, conflict resolution, event bus.
services/sync/
├── cmd/main.go ← gRPC server entrypoint, port :50052
├── config.yaml ← default config
├── internal/
│ ├── config/config.go ← koanf config loader
│ ├── store/
│ │ ├── schema.go ← SQLite tables: conflicts, sync_history, peer_state
│ │ ├── conflicts.go ← ConflictStore: Create, Get, List (with filters), Resolve, Defer
│ │ ├── history.go ← HistoryStore: Record, List, Get, RecordCompleted, RecordFailed
│ │ └── peers.go ← PeerStore: Upsert, Get, List, Trust, Untrust, MarkRevoked
│ ├── transport/
│ │ ├── adapter.go ← Adapter interface (Name, Capabilities, Start, Stop, Discover, Connect)
│ │ ├── stubs.go ← StubAdapter for unimplemented transports
│ │ └── localnet/localnet.go ← Local network adapter (mDNS + gRPC over TCP)
│ ├── service/
│ │ ├── eventbus.go ← EventBus: pub/sub with type filtering, 7 event types
│ │ ├── syncengine.go ← SyncEngine: orchestrator, TriggerSync, CancelSync, ExportBundle, ImportBundle
│ │ ├── syncqueue.go ← SyncQueue: priority queue for sync jobs
│ │ └── bundle.go ← Bundle format placeholder
│ └── server/
│ ├── server.go ← gRPC server struct + error mapping
│ ├── sync_rpcs.go ← GetStatus, TriggerSync, CancelSync, ListPeers, TrustPeer, UntrustPeer, GetHistory
│ ├── conflict_rpcs.go ← ListConflicts, GetConflict, ResolveConflict, DeferConflict
│ ├── transport_rpcs.go ← ListTransports, EnableTransport, DisableTransport
│ ├── event_rpcs.go ← SubscribeEvents (server-streaming)
│ ├── bundle_rpcs.go ← ExportBundle, ImportBundle
│ ├── nodesync_rpcs.go ← Handshake, RequestPack, SendPack (stubs for node-to-node)
│ └── health_rpcs.go ← Health
└── sync_test.go ← 12 integration tests
Merge Driver: Three-tier classification: AutoMerge (non-overlapping) → Review (overlapping non-clinical) → Block (clinical safety risk). Block rules: allergy criticality, drug interaction, diagnosis conflict, patient identity, contradictory vitals.
Transport: Pluggable via Adapter interface. Local network (mDNS discovery), Wi-Fi Direct, Bluetooth, USB (stubs). Transport selection is automatic.
Event Bus: 7 event types (sync.started/completed/failed, peer.discovered/lost, conflict.new/resolved). Server-streaming gRPC for real-time updates.
- crypto.go — Ed25519
GenerateKeypair(),Sign(),Verify(),EncodePublicKey(),DecodePublicKey() - jwt.go —
NucleusClaims,SignToken(),VerifyToken()— EdDSA JWT via golang-jwt/v5 - nonce.go —
NonceStorewith TTL,Generate(),Consume(),Cleanup() - keystore.go —
KeyStoreinterface,MemoryKeyStore,FileKeyStore(0600 perms) - roles.go — 37 permission constants, 5 role definitions,
HasPermission(),AllRoles() - bruteforce.go —
BruteForceGuardwith sliding window (N fails / M seconds) - auth_test.go — 19 tests
- types.go —
ConflictLevel(AutoMerge/Review/Block),FieldMergeStrategy,SyncPriority(5 tiers) - diff.go —
DiffResources(),DiffResourcesWithBase(),OverlappingFields(),NonOverlappingFields() - classify.go —
Classifierwith block rules per resource type, optionalFormularyChecker - strategy.go — Field merge strategies (LatestTimestamp, KeepBoth, PreferLocal) per resource type
- driver.go —
DriverwithMergeFile()andMergeFields()for three-way merge - priority.go —
ClassifyResource()→ 5-tier sync priority based on resource type and status - merge_test.go — 19 tests
ECDH-based key exchange and AES-256-GCM authenticated encryption for node-to-node sync bundles. Replaces the previous broken scheme that prepended the AES key to ciphertext.
- transport_crypto.go —
DeriveSharedKey()(Ed25519 → X25519 → ECDH → HKDF-SHA256),EncryptPayload(),DecryptPayload()(AES-256-GCM) - transport_crypto_test.go — 11 tests (shared key derivation, round-trip, wrong-key rejection, determinism, nonce uniqueness, edge cases)
Key design decisions:
- Ed25519 → X25519 conversion: private key via SHA-512 + clamping (RFC 8032), public key via Edwards → Montgomery (
u = (1+y)/(1-y) mod p) - HKDF salt:
open-nucleus-sync-v1, info:transport-encryption - Bundle export uses ECIES pattern: ephemeral keypair per bundle, ephemeral public key prepended to ciphertext
- No external deps beyond
golang.org/x/crypto(curve25519, hkdf)
Port :50054, 16 RPCs. Drug database, interaction checking, allergy cross-reactivity, stock management. Dosing RPCs return "not configured" cleanly (awaiting open-pharm-dosing integration).
services/formulary/
├── cmd/main.go ← gRPC entrypoint
├── config.yaml ← default config (root: formulary_service)
├── internal/
│ ├── config/config.go ← koanf loader
│ ├── store/
│ │ ├── schema.go ← SQLite: stock_levels + deliveries tables
│ │ ├── stock.go ← StockStore CRUD
│ │ ├── drugdb.go ← In-memory DrugDB from JSON seed data
│ │ └── interaction.go ← InteractionIndex: O(1) pair lookup + class + allergy
│ ├── dosing/engine.go ← Engine interface + StubEngine
│ ├── service/formulary.go ← Core business logic (search, interactions, stock, predictions)
│ └── server/
│ ├── server.go ← gRPC server + mapError
│ ├── medication_rpcs.go ← Search, Get, ListByCategory
│ ├── interaction_rpcs.go ← CheckInteractions, CheckAllergyConflicts
│ ├── dosing_rpcs.go ← Validate, Options, Schedule (stub)
│ ├── stock_rpcs.go ← StockLevel, Update, Delivery, Prediction, Redistribution
│ ├── formulary_rpcs.go ← GetFormularyInfo
│ └── health_rpcs.go ← Health
├── formulary_test.go ← 26 integration tests
├── formularytest/
│ ├── setup.go ← Start(*testing.T, tmpDir)
│ └── standalone.go ← StartStandalone(tmpDir)
└── testdata/
├── medications/ ← 20 WHO essential medicine JSONs
└── interactions/ ← 17 interaction rules + 4 allergy cross-reactivity rules
Key design decisions:
- DrugDB: In-memory map loaded from embedded JSON. Case-insensitive substring search.
- InteractionIndex: Canonical key
min(a,b):max(a,b)for O(1) pair lookup. Separate class-level and allergy indexes. - CheckInteractions: pair lookup → class lookup → allergy check → stock check → classify overall risk.
- Stock prediction:
daysRemaining = quantity / dailyRate, risk classification (critical/high/moderate/low). - Redistribution: surplus (>90 days supply) vs shortage (<14 days), suggests transfers.
- Dosing:
Engineinterface withPharmEngine(backed by externalgithub.com/Open-Nucleus/open-pharm-dosing) providing 33 frequency codes with parsing, FHIR Timing conversion, schedule generation, and validation.StubEnginekept as fallback.
Adapter layer wrapping the external github.com/Open-Nucleus/open-anchor/go library. Keeps the same package path (pkg/merge/openanchor) and type contracts so all consumers are unaffected. The external library provides DID:key, Verifiable Credentials (with VP support + revocation lists), Merkle trees with proof generation, and pluggable anchor backends (IOTA planned).
- interfaces.go —
AnchorEngine,IdentityEngine,MerkleTreeinterfaces + all types (DIDDocument,VerifiableCredential,CredentialProof,AnchorReceipt,CredentialClaims,VerificationResult,AnchorResult,FileEntry) + sentinel errors — unchanged API contract - convert.go — Type conversion functions between in-repo types and external
anchor.*types - merkle.go —
SHA256Merkledelegates to externalanchor.NewMerkleTree()+GetRoot() - base58.go — Standalone Base58btc encoder/decoder (kept for compatibility)
- didkey.go —
DIDKeyFromEd25519(),ResolveDIDKey(),ExtractPublicKey()delegate to externaldidkey.Backend - credential.go —
IssueCredentialLocal(),VerifyCredentialLocal()delegate to externalanchor.IdentityEngine - stub_backend.go —
StubBackend:Anchor()returnsErrBackendNotConfigured,Available()returns false,Name()returns "none" - local_identity.go —
LocalIdentityEngine: wraps externalanchor.IdentityEngine+didkey.Backend - openanchor_test.go — 13 unit tests (all passing with external library backend)
Port :50055, 14 RPCs. Merkle anchoring, DID management, Verifiable Credentials, queue management. Blockchain backend uses StubBackend (anchors queued in SQLite but never submitted).
services/anchor/
├── cmd/main.go ← gRPC entrypoint
├── config.yaml ← default config (root: anchor_service)
├── internal/
│ ├── config/config.go ← koanf loader
│ ├── store/
│ │ ├── schema.go ← SQLite: anchor_queue table + indexes
│ │ ├── queue.go ← AnchorQueue: Enqueue, ListPending, CountPending, CountTotal
│ │ ├── anchors.go ← Git-backed anchor record CRUD (.nucleus/anchors/)
│ │ ├── credentials.go ← Git-backed credential CRUD (.nucleus/credentials/)
│ │ └── dids.go ← Git-backed DID document CRUD (.nucleus/dids/)
│ ├── service/anchor.go ← Core business logic (14 methods)
│ └── server/
│ ├── server.go ← gRPC server struct + mapError
│ ├── anchor_rpcs.go ← GetStatus, TriggerAnchor, Verify, GetHistory
│ ├── did_rpcs.go ← GetNodeDID, GetDeviceDID, ResolveDID
│ ├── credential_rpcs.go ← IssueDataIntegrityCredential, VerifyCredential, ListCredentials
│ ├── backend_rpcs.go ← ListBackends, GetBackendStatus, GetQueueStatus
│ └── health_rpcs.go ← Health
├── anchor_test.go ← 19 integration tests
├── anchortest/
│ ├── setup.go ← Start(*testing.T, tmpDir)
│ └── standalone.go ← StartStandalone(tmpDir)
Key design decisions:
- Crypto in
pkg/merge/openanchor/: Clean swap to real open-anchor later; service codes to interfaces. - did:key only (no ledger DIDs in V1): Fully offline, deterministic from Ed25519.
- SQLite for queue, Git for records/credentials/DIDs: Queue is transient; records are source of truth (syncs via Git).
- StubBackend: Returns
ErrBackendNotConfigured. Queue fills, never drains. Same pattern as formulary dosing stub. - Merkle tree excludes
.nucleus/: Only clinical data files are included in the tree; internal metadata is excluded. - TriggerAnchor workflow: TreeWalk → SHA-256 each file → Merkle root → skip if unchanged (unless manual) → attempt engine.Anchor() → enqueue on failure → save record in Git.
Port :50056 (gRPC), :8090 (HTTP management). The first Python microservice. Implements all 10 sentinel proto RPCs (5 alert + 5 supply) with in-memory stores and seed data. Stubs open-sentinel interfaces for future swap.
services/sentinel/
├── pyproject.toml ← Python project config
├── requirements.txt ← Pinned deps
├── config.yaml ← Default config
├── proto_gen.sh ← Generate Python proto stubs
├── src/sentinel/
│ ├── main.py ← Async entrypoint (gRPC + HTTP + background tasks)
│ ├── config.py ← SentinelConfig + OllamaConfig dataclasses, YAML loader
│ ├── sync_subscriber.py ← Sync Service event stream skeleton (stub)
│ ├── fhir_output.py ← Alert → FHIR DetectedIssue conversion, EmissionQueue
│ ├── gen/ ← Generated proto Python code (committed)
│ │ ├── common/v1/ ← PaginationRequest/Response
│ │ └── sentinel/v1/ ← SentinelService stub/servicer, all message types
│ ├── server/
│ │ ├── servicer.py ← SentinelServiceServicer (10 RPCs)
│ │ └── converters.py ← Proto ↔ domain model converters
│ ├── http/
│ │ └── health_server.py ← aiohttp server (13 HTTP endpoints)
│ ├── store/
│ │ ├── models.py ← Alert, InventoryItem, DeliveryRecord, SupplyPrediction, etc.
│ │ ├── alert_store.py ← Thread-safe in-memory alert store
│ │ ├── inventory_store.py ← Thread-safe in-memory inventory store
│ │ └── seed.py ← 5 alerts + 10 inventory items + predictions + redistributions
│ ├── ollama/
│ │ └── sidecar.py ← OllamaSidecar: start/stop/watchdog/health
│ └── agent/
│ ├── interfaces.py ← ABCs: SentinelSkill, DataAdapter, AlertOutput, MemoryStore, LLMEngine
│ └── stub.py ← StubAgent (logs "open-sentinel not configured")
└── tests/ ← 68 pytest tests
├── conftest.py ← Fixtures: seeded stores, in-process gRPC server
├── test_config.py ← 4 tests
├── test_alert_store.py ← 11 tests
├── test_inventory_store.py ← 11 tests
├── test_grpc_servicer.py ← 17 tests (all 10 RPCs)
├── test_health_server.py ← 13 tests (all HTTP endpoints)
└── test_fhir_output.py ← 12 tests (FHIR conversion, provenance, queue)
Key design decisions:
- In-memory stores: Thread-safe dicts with seed data. No SQLite/Git yet — stores are populated at startup and persist for session lifetime.
- Seed data: 5 realistic alerts (cholera cluster, measles, stockout, drug interaction, BP trend) + 10 WHO essential medicines across 2 sites + supply predictions + redistribution suggestions.
- StubAgent pattern: Same as formulary dosing stub — clean interfaces with stub implementations that log "not configured". When
open-sentinelexists, swap StubAgent for real SentinelAgent. - FHIR output: Full DetectedIssue conversion with AI provenance tags (rule-only vs ai-generated), severity mapping, reasoning extensions. EmissionQueue stubs the Patient Service write-back.
- Ollama sidecar: Process manager with crash recovery (max 5 restarts), health monitoring, watchdog loop. Disabled by default.
Goal: Standards-compliant FHIR R4 REST API at /fhir/{Type} running parallel to the existing /api/v1/ endpoints.
Key differences from /api/v1/:
- Raw FHIR JSON responses (no envelope wrapper)
- FHIR Bundle for search results (not arrays)
- OperationOutcome for errors (not custom error codes)
Content-Type: application/fhir+jsonon all responses- ETag / If-None-Match for conditional reads (304 Not Modified)
- XML requests rejected with 406
Architecture:
internal/handler/fhir/
├── fhir.go ← FHIRHandler struct + dynamic route registration
├── response.go ← FHIR response writers (resource, bundle, error, 304)
├── middleware.go ← Content negotiation middleware (JSON only)
├── params.go ← FHIR search parameter parser (_count, _offset, patient)
├── dispatch.go ← Resource type → service call dispatch table
├── read.go ← GET /fhir/{Type}/{id}
├── search.go ← GET /fhir/{Type} → Bundle
├── write.go ← POST/PUT/DELETE handlers
├── everything.go ← GET /fhir/Patient/{id}/$everything
└── fhir_test.go ← 22 tests
Dispatch pattern: map[string]*ResourceDispatch built at init, each entry closes over PatientService methods. Reads go through expanded GetResource RPC (all 15 types). Searches call type-specific list methods. Writes extract patient reference from body for patient-scoped types.
ID-only lookups: 8 new GetXByID(id) methods on SQLite Index (drop AND patient_id = ?) enabling FHIR-standard GET /fhir/Encounter/{id} without patient ID in URL.
Route count: ~50 new FHIR endpoints auto-generated from 15 resource type definitions.
Goal: FHIR profiles specific to African healthcare deployment — custom extensions for national IDs, WHO vaccine codes, AI provenance, growth monitoring, and DHIS2 reporting. Adds MeasureReport as a new resource type and StructureDefinition as a read-only endpoint for profile discovery.
Five profiles:
| Profile | Base | Extensions |
|---|---|---|
| OpenNucleus-Patient | Patient | national-health-id (valueIdentifier), ethnic-group (valueCoding) |
| OpenNucleus-Immunization | Immunization | dose-schedule-name (valueString), dose-expected-age (valueString) + CVX/ATC warning |
| OpenNucleus-GrowthObservation | Observation | who-zscore (valueDecimal), nutritional-classification (valueCoding) + growth code + vital-signs constraints |
| OpenNucleus-DetectedIssue | DetectedIssue | ai-model-name, ai-confidence-score, ai-reflection-count, ai-reasoning-chain |
| OpenNucleus-MeasureReport | MeasureReport | dhis2-data-element, dhis2-org-unit, dhis2-period |
New resource types: MeasureReport (full stack: type → registry → validation → extraction → Git path → soft delete → SQLite schema/index → pipeline → RPCs → dispatch), StructureDefinition (read-only, served from profile registry).
Architecture:
pkg/fhir/
├── extension.go ← ExtensionDef, ExtractExtension, HasExtension, ValidateExtensions
├── profile.go ← Profile registry (GetProfileDef, AllProfileDefs, ProfilesForResource, GetMetaProfiles)
├── profile_defs.go ← 5 profile builders with validation functions
├── structuredefinition.go ← GenerateStructureDefinition, GenerateAllStructureDefinitions
├── validate.go ← +ValidateWithProfile, +validateMeasureReport (profile-aware validation)
├── types.go ← +ResourceMeasureReport, +ResourceStructureDefinition, +MeasureReportRow
├── registry.go ← +MeasureReport (SystemScoped), +StructureDefinition (SystemScoped, read-only)
├── extract.go ← +ExtractMeasureReportFields
├── path.go ← +measure-reports/, +.nucleus/profiles/
├── softdelete.go ← +MeasureReport → status="error"
└── capability.go ← +supportedProfile per resource type
Profile validation: ValidateWithProfile runs base Validate then checks meta.profile URLs against the profile registry. Each profile can have required extensions, value type checks, and custom constraint functions (e.g. growth code whitelist, CVX/ATC warning). Unknown extensions pass through (FHIR open model).
StructureDefinition endpoint: GET /fhir/StructureDefinition returns all 5 profiles as FHIR R4 StructureDefinition resources generated from ProfileDef metadata.
Resource count: 15 → 17 (MeasureReport + StructureDefinition). 58 pkg/fhir tests (26 new).
Goal: OAuth2 authorization code flow with SMART on FHIR v2 scopes, enabling third-party clinical apps (growth chart widgets, immunization trackers, DHIS2 connectors) to connect securely via standardized launch protocols. All OAuth2 flows execute on the local node — no cloud IdP required.
Coexistence model: Internal devices use Ed25519 challenge-response. SMART apps use OAuth2 auth code + PKCE. Both produce EdDSA JWTs — SMART tokens carry additional scope, client_id, and launch context claims. FHIR endpoints enforce SMART scopes when present, otherwise fall back to existing RBAC.
Architecture:
pkg/smart/
├── scope.go ← SMART v2 scope parser (patient/Resource.cruds)
├── client.go ← Client model + validation (pending/approved/revoked)
├── authcode.go ← Auth code + PKCE (S256, one-shot exchange)
├── launch.go ← EHR launch token store (one-shot consume)
└── config.go ← SMART configuration builder (/.well-known/smart-configuration)
proto/smart/v1/
└── smart.proto ← SmartService (11 RPCs: OAuth2, client mgmt, launch, health)
services/auth/
├── internal/store/clients.go ← Client storage (Git + SQLite dual store)
├── internal/service/smart.go ← SmartService implementation
└── internal/server/smart_rpcs.go ← gRPC server adapter
internal/
├── service/smart.go ← SmartService interface + gRPC adapter
├── handler/smart.go ← 11 HTTP endpoints (OAuth2 + admin)
└── middleware/smartscope.go ← SMART scope enforcement on FHIR routes
OAuth2 endpoints:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /.well-known/smart-configuration |
Public | SMART discovery |
| GET | /auth/smart/authorize |
JWT | Authorization (returns redirect with code) |
| POST | /auth/smart/token |
Public | Token exchange (client auth via body/Basic) |
| POST | /auth/smart/revoke |
JWT | Token revocation |
| POST | /auth/smart/introspect |
JWT | Token introspection |
| POST | /auth/smart/register |
JWT (admin) | Dynamic client registration |
| POST | /auth/smart/launch |
JWT | Create EHR launch token |
| GET/PUT/DELETE | /api/v1/smart/clients/{id} |
JWT (admin) | Client management |
SMART scope middleware: SmartScope(resourceType, interaction) enforces v2 scopes on all FHIR endpoints. Patient-context scopes restrict access to launch patient only. Wildcard resource (*) supported. No-scope tokens pass through to existing RBAC.
CapabilityStatement: Security section includes oauth-uris extension (authorize, token, revoke, register endpoints) and SMART-on-FHIR service coding when SmartEnabled=true.
New permissions: smart:launch (physician, site-admin, regional-admin), smart:register (site-admin, regional-admin).
Test count: 408 total (37 new SMART tests: 27 pkg/smart, 3 pkg/auth SMART claims, 6 smartscope middleware, 8 handler, 2 capability).
| Phase | Scope | Status |
|---|---|---|
| 1 — Walking Skeleton | Middleware pipeline, auth + patient read handlers, all stubs | COMPLETE |
| 2 — Gateway Gaps | All handler/service/proto definitions, clinical sub-resources, JSON schema validation, zero stubs (except /ws) | COMPLETE |
| 3 — Patient Service | First real backend: services/patient/ + pkg/fhir + pkg/gitstore + pkg/sqliteindex. 38 gRPC RPCs, full write pipeline, 40 tests passing |
COMPLETE |
| 4 — Auth + Sync Services | Auth Service (15 RPCs, Ed25519 + JWT + RBAC) + Sync Service (~25 RPCs + NodeSyncService, FHIR merge driver, event bus) + pkg/auth + pkg/merge. 62 tests passing |
COMPLETE |
| 4.5 — E2E Smoke Tests | Full-stack E2E tests (11 cases), JWT claims fix, patient gRPC adapter wiring, test helper packages | COMPLETE |
| 5 — Formulary + Anchor + Sentinel | Formulary COMPLETE (16 RPCs, 26 tests). Anchor COMPLETE (14 RPCs, 19 tests). Sentinel Agent COMPLETE (10 RPCs, 13 HTTP endpoints, 68 tests). Go gateway adapters wired for all 3. | COMPLETE |
| FHIR Phase 1 — Core Foundation | 5 new resource types (Immunization, Procedure, Practitioner, Organization, Location) + Provenance auto-generation. Resource registry (15 types), CapabilityStatement, Bundle/OperationOutcome builders. 49 Patient Service RPCs, ~70 gateway endpoints. 36 pkg/fhir tests. | COMPLETE |
| FHIR Phase 2 — REST API Layer | Standards-compliant /fhir/{Type} REST API. Raw FHIR JSON (no envelope), Bundle for search, OperationOutcome for errors, ETag/conditional reads. ~50 new endpoints auto-generated from resource registry. Dispatch table, content negotiation, $everything. 22 handler tests. |
COMPLETE |
| FHIR Phase 3 — FHIR Profiles | 5 Open Nucleus profiles (Patient, Immunization, GrowthObservation, DetectedIssue, MeasureReport). Extension utilities, profile registry, profile-aware validation. MeasureReport full stack (17 resource types). StructureDefinition read-only endpoint. CapabilityStatement supportedProfile. 58 pkg/fhir tests. | COMPLETE |
| FHIR Phase 4 — SMART on FHIR | OAuth2 auth code + PKCE, SMART v2 scopes, EHR launch, client registration, scope middleware on FHIR endpoints. 11 gRPC RPCs, 11 HTTP endpoints, CapabilityStatement SMART security, 37 new tests (408 total). | COMPLETE |
| Overhaul Phase 3 — Sync Crypto Fix | Replaced broken AES-GCM (key-in-ciphertext) with ECDH X25519 + HKDF-SHA256 + AES-256-GCM. New pkg/sync (transport_crypto.go), ECIES-pattern bundle encryption in SyncEngine. 11 new crypto tests, 23 total sync tests. |
COMPLETE |
| IPEHR Phase A — Consent Management | FHIR Consent resource type (18th), ConsentManager with VC support, consent middleware (break-glass), HTTP endpoints (4 routes), ConsentService interface. pkg/consent/, pkg/fhir/consent.go, internal/middleware/consent.go, internal/handler/consent.go. |
COMPLETE |
| IPEHR Phase B — Per-Provider Key Wrapping | ECDH key grants via Ed25519→X25519 conversion, per-provider wrapped DEKs. pkg/envelope/grants.go, pkg/crypto/convert.go, shared crypto utilities extracted from sync. |
COMPLETE |
| IPEHR Phase C — Blind Indexes | HMAC-SHA256 blind indexing for PII, n-gram sliding window for substring search, blinded date prefixes. pkg/blindindex/, patients_ngrams table, write pipeline integration. |
COMPLETE |
| Flutter App — Dio + Auth | Dio HTTP client (4 interceptors), Ed25519 utils, auth feature (API, repository, notifiers, login screen), Riverpod providers. | COMPLETE |
| Flutter App — App Shell + Navigation | AppScaffold, sidebar nav, top bar, GoRouter (8 routes), 8 shared widgets, 12 shared models, dashboard/patients/formulary/sync/alerts/anchor/settings screens (placeholders). | COMPLETE |
| Flutter App — Patient Detail Screen | Full patient detail screen: demographics panel (280px), 10 tabbed views (Overview, Encounters, Vitals, Conditions, Medications, Allergies, Immunizations, Procedures, Consent, History), 10 Riverpod FutureProvider.family providers, FHIR value extraction helpers, timeline view for git history. | COMPLETE |
| Flutter App — Patient Forms + Clinical Dialogs | Patient create/edit form (card-based, max 800px, FHIR Patient JSON builder, duplicate detection via /match). 7 clinical form dialogs (Encounter, Observation, Condition, MedicationRequest, Allergy, Immunization, Procedure). Crypto-erase confirmation dialog (type DELETE to confirm). GoRouter edit route. | COMPLETE |
| Flutter App — Formulary Feature | 3-pane formulary screen: left (search+category filter+pagination), center (medication detail + interaction checker toggle), right (stock level+prediction+redistribution). FormularyApi (9 methods), 6 Riverpod providers (search StateNotifier, interaction checker StateNotifier, selected medication, stock info, formulary info). | COMPLETE |
| Flutter App — Polish + Tests | app.dart wired to themeModePr, barrel exports (models.dart, widgets.dart), 5 unit test files (Ed25519, ApiEnvelope, AuthModels, PatientModels, FhirPrimitives), 2 widget test files (LoginScreen, PatientListScreen). | COMPLETE |
| 6 — WebSocket + Hardening | Real-time events, production config, TLS, metrics | Not started |
lib/
├── main.dart ← Window manager init, ProviderScope
├── app.dart ← MaterialApp.router with AppTheme, watches themeModePr
├── core/
│ ├── config/app_config.dart ← Server URL, TLS, polling intervals
│ ├── router/app_router.dart ← GoRouter (initial: /login)
│ ├── theme/ ← AppColors, AppTheme, AppTypography, AppSpacing
│ ├── constants/ ← ApiPaths (all REST endpoints), FhirCodes, Permissions (5 roles × 37 perms)
│ └── extensions/ ← BuildContext helpers, String, Date
├── shared/
│ ├── models/
│ │ ├── api_envelope.dart ← ApiEnvelope<T>, ErrorBody, Warning, GitInfo, Meta, Pagination
│ │ ├── auth_models.dart ← LoginRequest, LoginResponse, RefreshResponse, WhoamiResponse, RoleDTO
│ │ ├── app_exception.dart ← AppException (code, message, statusCode, details)
│ │ └── models.dart ← Barrel export for all 14 model files
│ ├── providers/
│ │ └── dio_provider.dart ← Dio instance + 4 interceptors (Auth, Error, Logging, Retry)
│ ├── utils/
│ │ └── ed25519_utils.dart ← generateKeypair, sign, getPublicKeyBase64, getFingerprint, serialize/deserialize
│ └── widgets/ ← LoadingSkeleton, ErrorState, EmptyState, ConfirmDialog, DataTableCard, PaginationControls, SeverityBadge, StatusIndicator, SearchField, RoleBadge, JsonViewer + widgets.dart barrel export
└── features/
├── shell/
│ ├── providers/ ← ConnectionProvider, ShellProviders
│ └── presentation/ ← AppScaffold, SidebarNav, TopBar
├── dashboard/
│ ├── data/
│ │ ├── dashboard_api.dart ← DashboardApi: parallel fetch (health, alerts summary, sync status, anchor status, patient count)
│ │ └── dashboard_models.dart ← DashboardData (composite model with nullable sub-status fields)
│ └── presentation/
│ ├── dashboard_screen.dart ← Full dashboard: responsive grid (2-3 col), 7 cards (Node Identity, Patient Stats, Alert Summary, Sync Status, Anchor Status, Quick Actions, Recent Activity)
│ └── dashboard_providers.dart ← dashboardApiProvider, dashboardDataProvider (FutureProvider.autoDispose)
├── patients/
│ ├── data/
│ │ ├── patient_api.dart ← PatientApi: list, search, match, get, create, update, delete, erase, history, timeline
│ │ └── clinical_api.dart ← ClinicalApi: full CRUD for Encounters, Observations, Conditions, MedRequests, AllergyIntolerances, Immunizations, Procedures + X-Break-Glass header
│ └── presentation/
│ ├── patient_list_screen.dart ← Full patient list: search bar (debounced), expandable filter panel (gender, DOB range, site, status, alerts), DataTable, pagination, Ctrl+N shortcut
│ ├── patient_list_providers.dart ← patientApiProvider, clinicalApiProvider, PatientListNotifier (StateNotifier), patientSearchProvider (debounced FutureProvider)
│ ├── patient_detail_screen.dart ← Full detail: demographics panel + 10 tabs (Overview, Encounters, Vitals, Conditions, Medications, Allergies, Immunizations, Procedures, Consent, History)
│ ├── patient_detail_providers.dart ← 10 Riverpod FutureProvider.family (detail, encounters, observations, conditions, medications, allergies, immunizations, procedures, consents, history)
│ ├── patient_form_screen.dart ← Patient create/edit form (card layout, FHIR builder, duplicate detection)
│ ├── clinical_form_dialogs.dart ← 7 StatefulWidget dialogs: Encounter, Observation, Condition, MedRequest, Allergy, Immunization, Procedure
│ └── erase_dialog.dart ← Crypto-erase confirmation dialog (type DELETE, red/destructive)
├── formulary/
│ ├── data/
│ │ └── formulary_api.dart ← FormularyApi: searchMedications, getMedication, listByCategory, checkInteractions, checkAllergyConflicts, getStockLevel, getStockPrediction, getFormularyInfo, getRedistributionSuggestions
│ └── presentation/
│ ├── formulary_screen.dart ← 3-pane layout: left (search+results), center (detail/interactions), right (stock)
│ └── formulary_providers.dart ← formularyApiProvider, medicationSearchProvider (StateNotifier), selectedMedicationProvider, interactionCheckerProvider (StateNotifier), stockInfoProvider, formularyInfoProvider
├── sync/
│ ├── data/
│ │ ├── sync_api.dart ← SyncApi: getStatus, listPeers, triggerSync, getHistory, exportBundle, importBundle
│ │ └── conflict_api.dart ← ConflictApi: listConflicts, getConflict, resolveConflict, deferConflict
│ └── presentation/
│ ├── sync_screen.dart ← Full sync screen: status card, peer table, history timeline, conflict master-detail with JSON diff
│ └── sync_providers.dart ← syncApiProvider, conflictApiProvider, syncStatusProvider (5s refresh), syncPeersProvider, syncHistoryProvider, conflictListProvider, selectedConflictProvider, conflictDetailProvider
├── alerts/
│ ├── data/
│ │ └── alert_api.dart ← AlertApi: listAlerts, getSummary, getAlert, acknowledgeAlert, dismissAlert
│ └── presentation/
│ ├── alerts_screen.dart ← Full alerts screen: 5 summary cards, filterable DataTable, detail panel with acknowledge/dismiss
│ └── alerts_providers.dart ← alertApiProvider, AlertListNotifier (StateNotifier with severity/status filters), alertSummaryProvider (30s refresh), selectedAlertProvider, alertDetailProvider
├── anchor/ ← AnchorScreen (placeholder)
├── settings/ ← SettingsScreen (placeholder)
└── auth/
├── data/
│ ├── auth_api.dart ← AuthApi: login, refresh, logout, whoami (uses Dio)
│ └── auth_repository.dart ← AuthRepository: API + FlutterSecureStorage persistence
└── presentation/
├── auth_providers.dart ← Riverpod: authNotifier, deviceNotifier, authApi, authRepository, secureStorage
├── auth_notifier.dart ← StateNotifier<AuthState> (initial, loading, authenticated, error)
├── device_notifier.dart ← StateNotifier<DeviceState> (loading, ready, error) — Ed25519 keypair lifecycle
└── login_screen.dart ← Login card: server URL + test connection, keypair fingerprint, practitioner ID, Ed25519 challenge-response
Four interceptors in execution order:
- AuthInterceptor — injects
Authorization: Bearer $tokenfromAuthNotifier.accessToken, auto-refreshes on 401 and retries - RetryInterceptor — retries connection timeouts up to 2 times
- LoggingInterceptor — prints
[HTTP] --> METHOD /pathand[HTTP] <-- STATUS METHOD /path - ErrorInterceptor — maps
DioExceptiontoAppException, extracts backend error envelope when available
Uses cryptography package (Ed25519 algorithm). Keypairs serialized as JSON {"private": base64url, "public": base64url} for flutter_secure_storage. Fingerprint is first 8 hex chars of public key bytes.
Login flow: User enters server URL → tests connection (GET /health) → device keypair loaded or generated → user enters practitioner ID → click Login → generate nonce (login:<ISO8601>) → sign nonce with Ed25519 → POST /auth/login with {device_id, public_key, challenge_response: {nonce, signature, timestamp}, practitioner_id} → receive JWT tokens + role + site info → persist to secure storage → AuthState.authenticated.
Token refresh: AuthInterceptor catches 401 → calls AuthNotifier.refreshToken() → AuthRepository.refreshToken() → POST /auth/refresh → updates tokens in memory + secure storage → retries original request with new token.
Keypair persistence: DeviceNotifier on init reads from flutter_secure_storage key device_ed25519_keypair. If missing, generates new keypair and writes. "Generate New Keypair" button creates fresh keypair (device re-registration required).
Most complex screen in the app. Layout: fixed-width left panel (280px) + right tabbed content panel.
Left Panel — Demographics:
- Patient name, gender icon, DOB + age, copyable patient ID (monospace), active status badge, site ID
- Quick actions: Edit, History, Erase (destructive with ConfirmDialog → DELETE /patients/{id}/erase → navigate to /patients)
Right Panel — 10 Tabs (TabBar + TabBarView):
- Overview — 4 summary cards: Active Conditions, Current Medications, Active Allergies, Recent Encounters (from PatientBundle)
- Encounters — DataTable (Date, Status, Class, Duration) + "New Encounter" + pagination
- Vitals — DataTable (Date, Code/Display, Value+Unit, Status) + "Record Vital" + pagination
- Conditions — DataTable (Code/Display, Clinical Status badge, Verification, Onset) + "Add Condition"
- Medications — DataTable (Medication, Status, Intent, Dosage) + "Prescribe"
- Allergies — DataTable (Substance, Type, Clinical Status, Criticality badge) + "Add Allergy"
- Immunizations — DataTable (Vaccine, Date, Status) + "Record Immunization"
- Procedures — DataTable (Procedure, Date, Status) + "Record Procedure"
- Consent — DataTable (Scope, Performer, Status, Period, Category, Actions) + "Grant Consent" + per-row "Revoke"
- History — Timeline view with coloured dots, operation badges, commit hashes, author info
Providers (patient_detail_providers.dart): 10 FutureProvider.family<T, String> keyed by patientId:
patientDetailProvider→ PatientBundle (full bundle from GET /patients/{id})patientEncountersProvider→ ClinicalListResponsepatientObservationsProvider,patientConditionsProvider,patientMedicationsProvider,patientAllergiesProvider,patientImmunizationsProvider,patientProceduresProvider→ ClinicalListResponsepatientConsentsProvider→ ConsentListResponsepatientHistoryProvider→ PatientHistoryResponse
FHIR Extraction Helpers (top-level functions):
_extractName(Map patient)— HumanName → "Given Family"_extractGender(Map patient)— capitalised gender_extractBirthDateAndAge(Map patient)— (formatted date, "X years")_extractCodeDisplay(Map resource)— code.coding[0].display from CodeableConcept_extractObservationValue(Map obs)— valueQuantity, valueString, valueCodeableConcept, valueBoolean, component_extractDosageText(Map med)— dosageInstruction text or structured dose+route+timing_extractStatus(Map resource)— resource.status string
DashboardApi (data/dashboard_api.dart): Fetches 5 endpoints in parallel via Future.wait:
GET /health→ healthy bool, nodeId, siteIdGET /api/v1/alerts/summary→ AlertSummaryResponse (total, critical, warning, info, unacknowledged)GET /api/v1/sync/status→ SyncStatusResponse (state, lastSync, pendingChanges)GET /api/v1/anchor/status→ AnchorStatusResponse (merkleRoot, lastAnchorTime, queueDepth)GET /api/v1/patients?per_page=1→ patient count from pagination.total
Each sub-request swallows errors independently — partial dashboard data is displayed.
DashboardData (data/dashboard_models.dart): Composite model with nullable fields for each sub-status.
Dashboard Screen (presentation/dashboard_screen.dart): ConsumerWidget with responsive grid (3 cols >1200px, 2 cols otherwise). 7 cards:
- Node Identity — nodeId, siteId, role, online/offline status
- Patient Stats — large count number + "View All" link to /patients
- Alert Summary — critical (red), warning (amber), info (blue) counts with colored dots + unacknowledged count
- Sync Status — state indicator (idle/syncing/error/complete), last sync (timeAgo), pending changes
- Anchor Status — truncated merkle root (monospace), last anchor time, queue depth
- Quick Actions — FilledButton "New Patient" + OutlinedButtons "Trigger Sync", "View Alerts"
- Recent Activity — placeholder "No recent activity" (full-width card below grid)
Loading state: shimmer skeleton grid. Error state: ErrorState widget with retry.
PatientApi (data/patient_api.dart): Full CRUD client — 10 methods:
listPatients(page, perPage, sort, gender, birthDateFrom, birthDateTo, siteId, status, hasAlerts)searchPatients(query, page, perPage)— blind-index searchmatchPatients(MatchPatientsRequest)— probabilistic matchinggetPatient(id),createPatient(body),updatePatient(id, body),deletePatient(id),erasePatient(id)getHistory(id),getTimeline(id)
ClinicalApi (data/clinical_api.dart): Full CRUD for 7 FHIR sub-resources with optional breakGlass header:
- Encounters: list, get, create, update
- Observations: list (with ObservationFilters), get, create
- Conditions: list (with ConditionFilters), create, update
- MedicationRequests: list, create, update
- AllergyIntolerances: list, create, update
- Immunizations: list, get, create
- Procedures: list, get, create
PatientListNotifier (patient_list_providers.dart): StateNotifier managing PatientListState (patients, page, perPage, totalItems, totalPages, filters, isLoading, error). Methods: fetch, goToPage, setPerPage, applyFilters, clearFilters.
PatientListFilters: gender, birthDateFrom, birthDateTo, siteId, status, hasAlerts, sort — with hasActiveFilters getter.
Patient search: debounced (300ms) via patientSearchQueryProvider (StateProvider) → patientSearchProvider (FutureProvider.autoDispose) → calls PatientApi.searchPatients.
Patient List Screen (patient_list_screen.dart): ConsumerStatefulWidget with:
- Header row: "Patients" title + SearchField (debounced) + "New Patient" FilledButton
- Expandable filter panel: Gender dropdown, Status dropdown, DOB From/To date pickers, Site ID text field, Has Alerts checkbox, Apply/Clear buttons
- DataTable: Name, DOB, Gender, Site (monospace), Last Updated (timeAgo), Alert badge. Row click → /patients/{id}
- Pagination: PaginationControls with rows-per-page selector
- Search mode: when search query non-empty, shows search results instead of paginated list
- Keyboard shortcut: Ctrl+N → navigate to /patients/new
- States: LoadingSkeleton.table during fetch, ErrorState on failure, EmptyState when no patients
SyncApi (data/sync_api.dart): 6 methods:
getStatus()→ SyncStatusResponse (state, lastSync, pendingChanges, nodeId, siteId)listPeers()→ SyncPeersResponse (list of PeerInfo with nodeId, siteId, lastSeen, state, latencyMs)triggerSync(targetNode)→ SyncTriggerResponsegetHistory(page, perPage)→ SyncHistoryResponse (list of SyncEvent with timestamp, direction, peerNode, state, resourcesTransferred)exportBundle(resourceTypes, since)→ BundleExportResponseimportBundle(bundleData, format, author, nodeId, siteId)→ BundleImportResponse
ConflictApi (data/conflict_api.dart): 4 methods:
listConflicts(page, perPage)→ ConflictListResponsegetConflict(conflictId)→ ConflictDetail (includes localVersion/remoteVersion raw JSON, localNode/remoteNode)resolveConflict(ResolveConflictRequest)→ ResolveConflictResponsedeferConflict(DeferConflictRequest)→ DeferConflictResponse
Providers (sync_providers.dart):
syncStatusProvider— FutureProvider.autoDispose with Timer.periodic(5s) auto-refresh via invalidateSelfsyncPeersProvider,syncHistoryProvider,conflictListProvider— FutureProvider.autoDisposeselectedConflictProvider— StateProvider<String?> for conflict ID selectionconflictDetailProvider— FutureProvider.autoDispose.family<ConflictDetail, String>
Sync Screen (sync_screen.dart): ConsumerStatefulWidget with two sections:
- Top Section — Sync Status & Peers:
- Status card: SyncStateBadge (idle=grey, syncing=blue, complete=green, error=red) + last sync (timeAgo) + pending changes + node/site IDs
- Action buttons: "Trigger Sync" (opens dialog with peer dropdown), "Export Bundle" (dialog with resource types + since date), "Import Bundle" (dialog with author + JSON textarea)
- Peer table: DataTable with Node ID, Site ID, Last Seen (timeAgo), State (SeverityBadge), Latency (visual LinearProgressIndicator bar 0-500ms + ms label)
- Sync History: expandable section with timeline rows showing direction arrow (in=down/blue, out=up/teal), timestamp, peer node, state badge, resources count
- Bottom Section — Conflicts:
- Header with conflict count badge (amber when > 0)
- Master-detail layout (2:3 flex ratio):
- Left: DataTable (Resource Type, Resource ID, Status badge, Detected At) with row selection highlighting
- Right: Selected conflict detail — side-by-side JSON viewers (local in blue label, remote in teal label) using JsonViewer widgets
- Resolution buttons: "Accept Local" (tonal), "Accept Remote" (filled), "Defer" (outlined, opens reason dialog)
AlertApi (data/alert_api.dart): 5 methods:
listAlerts(page, perPage, severity?, status?)→ AlertListResponsegetSummary()→ AlertSummaryResponse (total, critical, warning, info, unacknowledged)getAlert(alertId)→ AlertDetailacknowledgeAlert(alertId)→ AlertDetaildismissAlert(alertId, reason)→ AlertDetail
AlertListNotifier (alerts_providers.dart): StateNotifier managing AlertListState (alerts, page, perPage, totalItems, totalPages, severityFilter, statusFilter, isLoading, error). Methods: fetch, goToPage, setPerPage, setSeverityFilter, setStatusFilter, clearFilters.
Providers (alerts_providers.dart):
alertApiProvider— ProvideralertListProvider— StateNotifierProvider.autoDispose<AlertListNotifier, AlertListState>alertSummaryProvider— FutureProvider.autoDispose with Timer.periodic(30s) auto-refreshselectedAlertProvider— StateProvider<String?> for alert ID selectionalertDetailProvider— FutureProvider.autoDispose.family<AlertDetail, String>
Alerts Screen (alerts_screen.dart): ConsumerWidget with:
- Summary cards row: 5 cards (Total, Critical/red, Warning/amber, Info/blue, Unacknowledged/pending) with icon + large count + label
- Filter row: Severity dropdown (all/critical/warning/info), Status dropdown (all/active/acknowledged/dismissed), Clear button
- Alert table + detail panel: Row layout (3:2 flex when detail open, full width otherwise):
- Left: DataTable with Severity (SeverityBadge), Type, Title, Patient ID (monospace), Status (custom badge), Created At
- Right: Detail panel with severity badge + title header, metadata fields (Type, Patient ID, Status, Created At, Acknowledged At/By), description box, action buttons (Acknowledge filled, Dismiss outlined with reason dialog)
- Pagination: PaginationControls with rows-per-page selector
- States: LoadingSkeleton.table during fetch, ErrorState on failure, EmptyState when no alerts match filters
- No code generation: Uses manual StateNotifier/StateNotifierProvider (not riverpod_generator or freezed)
- Dio interceptor order: Auth → Retry → Logging → Error (requests run top-down, errors run bottom-up)
- Self-signed TLS:
AppConfig.acceptSelfSignedCerts = truefor dev (talks to local backend with auto-generated TLS) - Secure storage keys: Prefixed with
auth_for tokens/role,device_for keypair - Connection test: Uses separate Dio instance (no auth interceptor) to hit
/health - Patient Detail: All clinical data extracted from raw
Map<String, dynamic>FHIR resources; no typed models for clinical resources - Tab-per-resource: Each tab has its own provider and loading/error state; overview tab uses the bundle data directly
- Dashboard parallel fetch: All 5 dashboard API calls run concurrently; individual failures don't block the whole dashboard
- ClinicalApi break-glass: All write methods accept
breakGlass: bool→ addsX-Break-Glass: trueheader for emergency consent bypass - Patient list dual mode: Normal paginated list vs debounced search — switching is automatic based on search query presence
- Barrel exports:
shared/models/models.dartandshared/widgets/widgets.dartre-export all files for convenient single-import usage - Theme wiring:
app.dartwatchesthemeModePrfrom settings providers, so theme mode changes from SettingsScreen take effect immediately
test/
├── unit/
│ ├── ed25519_utils_test.dart ← 5 tests: generateKeypair, sign, getPublicKeyBase64, getFingerprint, serialize/deserialize roundtrip
│ ├── api_envelope_test.dart ← 6 tests: success, error, pagination, warnings, null data, git+meta
│ ├── auth_models_test.dart ← 4 tests: LoginRequest.toJson, LoginResponse.fromJson, RoleDTO roundtrip, WhoamiResponse.fromJson
│ ├── patient_models_test.dart ← 6 tests: PatientSummary.fromFhirMap, missing name, PatientBundle.fromJson, WriteResponse with git, null fields, HistoryEntry roundtrip
│ └── fhir_primitives_test.dart ← 10 tests: CodeableConcept roundtrip, null coding, HumanName with given, minimal fields, toJson omits nulls, Quantity roundtrip/int/nulls, FhirAddress roundtrip/minimal/nulls, Coding, FhirReference, FhirPeriod
└── widget/
├── login_screen_test.dart ← 8 tests: title, subtitle, server URL field, practitioner ID field, login button present, login disabled when empty, test connection button, device keypair section, default URL
└── patient_list_screen_test.dart ← 5 tests: "Patients" title, "New Patient" button, search field, add icon, filters section
All tests use flutter_test. Widget tests wrap screens in ProviderScope + MaterialApp. Patient list widget tests override dioProvider with a mock Dio instance to avoid network dependencies.