Skip to content

Latest commit

 

History

History
1143 lines (947 loc) · 85.5 KB

File metadata and controls

1143 lines (947 loc) · 85.5 KB

Open Nucleus — Architectural Memory

Living document. Updated after every major feature or structural change. Last updated: Hedera HCS integration + Sentinel outbreak detection (2026-03-22)


System Overview

Open Nucleus

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.

Core Architecture

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)
  └─────────────────────┘

Dependency Wiring (main.go)

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()

Package Dependency Graph

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.


Module Details

internal/config

  • config.go — Config struct matching config.yaml / spec section 14. Loaded via koanf.
  • Consumed by: main (passed to pool, server, rate limiter), grpcclient (dial addresses/timeouts), server (port, timeouts).

internal/model (leaf — no internal imports)

  • envelope.go — Envelope struct + JSON(), Success(), ErrorResponse() response writers. Every HTTP response flows through here.
  • errors.go — 16 error code constants (ErrAuthRequired, ErrRateLimited, etc.) + ErrorHTTPStatus map + WriteError() + NotImplementedError().
  • pagination.go — Pagination struct, PaginationFromRequest(r) query parser, NewPagination() constructor.
  • auth.go — NucleusClaims (JWT claims struct), LoginRequest, RefreshRequest, LogoutRequest.
  • rbac.go — 5 role constants, 24 permission constants, RolePermissions matrix map, HasPermission(role, perm).
  • context.go — Context keys (CtxRequestID, CtxClaims) + extraction helpers RequestIDFromContext(), ClaimsFromContext(). This is the glue that lets middleware pass data to handlers without coupling.

internal/middleware

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).

internal/grpcclient

  • pool.go — Pool holds a map[string]*grpc.ClientConn for 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.

internal/service

  • interfaces.go — 9 service interfaces (AuthService, PatientService, SyncService, ConflictService, SentinelService, FormularyService, AnchorService, SupplyService, SmartService) + all DTOs including EraseResponse. Handlers depend only on these interfaces.

internal/service/local/ (monolith — recommended)

In-process adapters that call business logic directly without gRPC:

  • patient.go — patientService wraps pipeline.Writer + sqliteindex.Index + gitstore.Store. Reads FHIR JSON from Git and decrypts via pw.DecryptFromGit(). Implements ErasePatient() for crypto-erasure.
  • auth.go — authService wraps authservice.AuthService directly.
  • smart.go — smartService wraps authservice.SmartService directly.
  • sync.go — syncService wraps syncservice.SyncEngine + history/peer stores. Also conflictService wraps conflict store + event bus.
  • formulary.go — formularyService wraps formularyservice.FormularyService directly.
  • anchor.go — anchorService wraps anchorservice.AnchorService directly.
  • stubs.go — stubSentinelService + stubSupplyService return 503 when Sentinel is not running.

internal/service/*.go (legacy gRPC adapters — still builds)

  • auth.go, patient.go, sync.go, etc. — gRPC adapters via pool.Conn(name). Used by cmd/gateway/main.go when 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.

internal/handler

  • auth.go — AuthHandler holds service.AuthService. Methods: Login, Refresh, Logout, Whoami. Whoami short-circuits from JWT claims in context if available.
  • patient.go — PatientHandler holds service.PatientService. Methods: List, GetByID, Search, Create, Update, Delete, History, Timeline, Match. Write methods use writeResponseWithGit() to include git metadata in the response envelope.
  • clinical.go — Additional methods on PatientHandler for 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 — ResourceHandler with factory methods (ListFactory, GetFactory, CreateFactory, UpdateFactory) for top-level CRUD (Practitioner, Organization, Location). CapabilityStatementHandler() serves FHIR R4 CapabilityStatement at /fhir/metadata.
  • sync.go — SyncHandler holds service.SyncService. Methods: Status, Peers, Trigger, History, ExportBundle, ImportBundle.
  • conflict.go — ConflictHandler holds service.ConflictService. Methods: List, GetByID, Resolve, Defer.
  • sentinel.go — SentinelHandler holds service.SentinelService. Methods: ListAlerts, Summary, GetAlert, Acknowledge, Dismiss.
  • formulary.go — FormularyHandler holds service.FormularyService. 16 methods: SearchMedications, GetMedication, ListMedicationsByCategory, CheckInteractions, CheckAllergyConflicts, ValidateDosing, GetDosingOptions, GenerateSchedule, GetStockLevel, UpdateStockLevel, RecordDelivery, GetStockPrediction, GetRedistributionSuggestions, GetFormularyInfo.
  • anchor.go — AnchorHandler holds service.AnchorService. 13 methods: Status, Verify, History, Trigger, NodeDID, DeviceDID, ResolveDID, IssueCredential, VerifyCredentialHandler, ListCredentials, ListBackends, BackendStatus, QueueStatus.
  • supply.go — SupplyHandler holds service.SupplyService. Methods: Inventory, InventoryItem, RecordDelivery, Predictions, Redistribution.
  • stubs.go — StubHandler() returns 501 via model.NotImplementedError(). Only used for WebSocket endpoint (Phase 5).

internal/router

  • 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 /ws remains stubbed.

internal/server

  • server.go — Server wraps http.Server with config-driven timeouts. Run() starts listener and blocks until SIGINT/SIGTERM, then calls Shutdown() with 10s grace period.

schemas/

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", name array (items: { family: string, given: string[] }), gender enum, birthDate string.
  • encounter.json — Requires resourceType: "Encounter", status enum (8 FHIR values), class object with code, subject Reference, period with start.
  • observation.json — Requires resourceType: "Observation", status enum (7 values), code CodeableConcept, subject Reference, effectiveDateTime.
  • condition.json — Requires resourceType: "Condition", clinicalStatus CodeableConcept, verificationStatus CodeableConcept, code CodeableConcept, subject Reference.
  • medication_request.json — Requires resourceType: "MedicationRequest", status, intent, medicationCodeableConcept CodeableConcept, subject Reference, dosageInstruction array (minItems:1).
  • allergy_intolerance.json — Requires resourceType: "AllergyIntolerance", clinicalStatus CodeableConcept, verificationStatus CodeableConcept, code CodeableConcept, patient Reference.
  • immunization.json — Requires resourceType: "Immunization", status enum (3 values), vaccineCode CodeableConcept, patient Reference, occurrenceDateTime.
  • procedure.json — Requires resourceType: "Procedure", status enum (8 values), code CodeableConcept, subject Reference.

Proto Structure

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).


Shared Libraries (pkg/)

pkg/envelope — Per-Patient Encryption

AES-256-GCM envelope encryption with master key wrapping.

  • envelope.go — KeyManager interface: GetOrCreateKey(), DestroyKey(), Encrypt(), Decrypt(), IsKeyDestroyed(). FileKeyManager impl stores wrapped DEKs in Git at .nucleus/keys/. In-memory cache with sync.RWMutex.

pkg/tls — TLS Certificate Management

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).

pkg/fhir — FHIR R4 Utilities

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. No FHIRJson field — 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() writes meta.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.

pkg/gitstore — Git Operations

Wraps go-git/v5 for clinical data Git repository management.

  • store.go — Store interface: WriteAndCommit(), Read(), LogPath(), Head(), TreeWalk(), Rollback(). NewStore(repoPath) opens or inits repo.
  • commit.go — CommitMessage struct with Format() and ParseCommitMessage() for structured commit messages per spec §3.3.

pkg/sqliteindex — SQLite Search Index

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 — Index interface: 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_fts virtual 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() recomputes patient_summaries counts. GetPatientBundle() returns patient + all active child resources.

Patient Service (services/patient/)

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):

  1. Validate FHIR JSON (pkg/fhir)
  2. Assign UUID if CREATE
  3. Set meta.lastUpdated/versionId/source
  4. Acquire sync.Mutex (5s timeout)
  5. Write JSON to Git + commit (pkg/gitstore)
  6. Extract fields + upsert SQLite (pkg/fhir + pkg/sqliteindex)
  7. Update patient_summaries
  8. Auto-generate FHIR Provenance (target ref, activity coding, agents) → write to Git (skip if resourceType == "Provenance")
  9. 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.


Cross-Cutting Patterns

Response Envelope

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.

Error Propagation

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.

JSON Schema Validation

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.

Testing Strategy

  • Middleware tests: pass httptest.Request through middleware, assert on httptest.Recorder status + 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 via make test-e2e.

Test Helper Packages

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, exposes Addr, PublicKey, GetChallenge(), AuthenticateWithNonce()
  • services/patient/patienttest/ — Starts in-process Patient Service, exposes Addr
  • services/sync/synctest/ — Starts in-process Sync Service, exposes Addr

Each package also exports a StartStandalone() function that returns (env, cleanup, error) instead of requiring *testing.T. Used by the smoke test CLI.

Interactive Smoke Test CLI (cmd/smoke/)

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.


What's Implemented vs Stubbed

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 —

Adding a New Endpoint (Checklist)

  1. Proto: Define RPC + request/response messages in the appropriate proto/*/v1/*.proto
  2. Service interface: Add method to interface in service/interfaces.go, add DTOs
  3. Service adapter: Implement in service/<domain>.go using pool.Conn("<service>")
  4. Handler: Add method to handler struct in handler/<domain>.go
  5. Router: Wire the handler method in router/router.go
  6. Schema: If POST/PUT with FHIR body, add JSON schema in schemas/ and register in main.go
  7. Tests: Unit test handler with mock service, add integration case in router_test.go
  8. Update this file

Auth Service (services/auth/)

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).


Sync Service (services/sync/)

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.


Shared Libraries — Auth + Merge

pkg/auth — Shared Auth Utilities

  • crypto.go — Ed25519 GenerateKeypair(), Sign(), Verify(), EncodePublicKey(), DecodePublicKey()
  • jwt.go — NucleusClaims, SignToken(), VerifyToken() — EdDSA JWT via golang-jwt/v5
  • nonce.go — NonceStore with TTL, Generate(), Consume(), Cleanup()
  • keystore.go — KeyStore interface, MemoryKeyStore, FileKeyStore (0600 perms)
  • roles.go — 37 permission constants, 5 role definitions, HasPermission(), AllRoles()
  • bruteforce.go — BruteForceGuard with sliding window (N fails / M seconds)
  • auth_test.go — 19 tests

pkg/merge — FHIR-Aware Merge Driver

  • types.go — ConflictLevel (AutoMerge/Review/Block), FieldMergeStrategy, SyncPriority (5 tiers)
  • diff.go — DiffResources(), DiffResourcesWithBase(), OverlappingFields(), NonOverlappingFields()
  • classify.go — Classifier with block rules per resource type, optional FormularyChecker
  • strategy.go — Field merge strategies (LatestTimestamp, KeepBoth, PreferLocal) per resource type
  • driver.go — Driver with MergeFile() and MergeFields() for three-way merge
  • priority.go — ClassifyResource() → 5-tier sync priority based on resource type and status
  • merge_test.go — 19 tests

pkg/sync — Transport-Layer Cryptography

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)

Formulary Service (services/formulary/)

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: Engine interface with PharmEngine (backed by external github.com/Open-Nucleus/open-pharm-dosing) providing 33 frequency codes with parsing, FHIR Timing conversion, schedule generation, and validation. StubEngine kept as fallback.

pkg/merge/openanchor — Anchor Cryptography Library

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, MerkleTree interfaces + 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 — SHA256Merkle delegates to external anchor.NewMerkleTree() + GetRoot()
  • base58.go — Standalone Base58btc encoder/decoder (kept for compatibility)
  • didkey.go — DIDKeyFromEd25519(), ResolveDIDKey(), ExtractPublicKey() delegate to external didkey.Backend
  • credential.go — IssueCredentialLocal(), VerifyCredentialLocal() delegate to external anchor.IdentityEngine
  • stub_backend.go — StubBackend: Anchor() returns ErrBackendNotConfigured, Available() returns false, Name() returns "none"
  • local_identity.go — LocalIdentityEngine: wraps external anchor.IdentityEngine + didkey.Backend
  • openanchor_test.go — 13 unit tests (all passing with external library backend)

Anchor Service (services/anchor/)

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.

Sentinel Agent Service (services/sentinel/) — Python

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-sentinel exists, 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.

FHIR Phase 2 — REST API Layer

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+json on 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.


FHIR Phase 3 — Open Nucleus FHIR Profiles

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).


FHIR Phase 4 — SMART on FHIR

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 Roadmap

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

Flutter Desktop App (open-nucleus-app)

Architecture

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

Dio HTTP Client (shared/providers/dio_provider.dart)

Four interceptors in execution order:

  1. AuthInterceptor — injects Authorization: Bearer $token from AuthNotifier.accessToken, auto-refreshes on 401 and retries
  2. RetryInterceptor — retries connection timeouts up to 2 times
  3. LoggingInterceptor — prints [HTTP] --> METHOD /path and [HTTP] <-- STATUS METHOD /path
  4. ErrorInterceptor — maps DioException to AppException, extracts backend error envelope when available

Ed25519 Utils (shared/utils/ed25519_utils.dart)

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.

Auth Feature

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).

Patient Detail Screen (features/patients/presentation/patient_detail_screen.dart)

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):

  1. Overview — 4 summary cards: Active Conditions, Current Medications, Active Allergies, Recent Encounters (from PatientBundle)
  2. Encounters — DataTable (Date, Status, Class, Duration) + "New Encounter" + pagination
  3. Vitals — DataTable (Date, Code/Display, Value+Unit, Status) + "Record Vital" + pagination
  4. Conditions — DataTable (Code/Display, Clinical Status badge, Verification, Onset) + "Add Condition"
  5. Medications — DataTable (Medication, Status, Intent, Dosage) + "Prescribe"
  6. Allergies — DataTable (Substance, Type, Clinical Status, Criticality badge) + "Add Allergy"
  7. Immunizations — DataTable (Vaccine, Date, Status) + "Record Immunization"
  8. Procedures — DataTable (Procedure, Date, Status) + "Record Procedure"
  9. Consent — DataTable (Scope, Performer, Status, Period, Category, Actions) + "Grant Consent" + per-row "Revoke"
  10. 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 → ClinicalListResponse
  • patientObservationsProvider, patientConditionsProvider, patientMedicationsProvider, patientAllergiesProvider, patientImmunizationsProvider, patientProceduresProvider → ClinicalListResponse
  • patientConsentsProvider → ConsentListResponse
  • patientHistoryProvider → 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

Dashboard Screen (features/dashboard/)

DashboardApi (data/dashboard_api.dart): Fetches 5 endpoints in parallel via Future.wait:

  1. GET /health → healthy bool, nodeId, siteId
  2. GET /api/v1/alerts/summary → AlertSummaryResponse (total, critical, warning, info, unacknowledged)
  3. GET /api/v1/sync/status → SyncStatusResponse (state, lastSync, pendingChanges)
  4. GET /api/v1/anchor/status → AnchorStatusResponse (merkleRoot, lastAnchorTime, queueDepth)
  5. 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.

Patient List Screen (features/patients/presentation/)

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 search
  • matchPatients(MatchPatientsRequest) — probabilistic matching
  • getPatient(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

Sync & Conflicts Screen (features/sync/)

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) → SyncTriggerResponse
  • getHistory(page, perPage) → SyncHistoryResponse (list of SyncEvent with timestamp, direction, peerNode, state, resourcesTransferred)
  • exportBundle(resourceTypes, since) → BundleExportResponse
  • importBundle(bundleData, format, author, nodeId, siteId) → BundleImportResponse

ConflictApi (data/conflict_api.dart): 4 methods:

  • listConflicts(page, perPage) → ConflictListResponse
  • getConflict(conflictId) → ConflictDetail (includes localVersion/remoteVersion raw JSON, localNode/remoteNode)
  • resolveConflict(ResolveConflictRequest) → ResolveConflictResponse
  • deferConflict(DeferConflictRequest) → DeferConflictResponse

Providers (sync_providers.dart):

  • syncStatusProvider — FutureProvider.autoDispose with Timer.periodic(5s) auto-refresh via invalidateSelf
  • syncPeersProvider, syncHistoryProvider, conflictListProvider — FutureProvider.autoDispose
  • selectedConflictProvider — StateProvider<String?> for conflict ID selection
  • conflictDetailProvider — 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)

Alerts Screen (features/alerts/)

AlertApi (data/alert_api.dart): 5 methods:

  • listAlerts(page, perPage, severity?, status?) → AlertListResponse
  • getSummary() → AlertSummaryResponse (total, critical, warning, info, unacknowledged)
  • getAlert(alertId) → AlertDetail
  • acknowledgeAlert(alertId) → AlertDetail
  • dismissAlert(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 — Provider
  • alertListProvider — StateNotifierProvider.autoDispose<AlertListNotifier, AlertListState>
  • alertSummaryProvider — FutureProvider.autoDispose with Timer.periodic(30s) auto-refresh
  • selectedAlertProvider — StateProvider<String?> for alert ID selection
  • alertDetailProvider — 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

Key Design Decisions

  • 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 = true for 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 → adds X-Break-Glass: true header 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.dart and shared/widgets/widgets.dart re-export all files for convenient single-import usage
  • Theme wiring: app.dart watches themeModePr from settings providers, so theme mode changes from SettingsScreen take effect immediately

Tests (test/)

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.