Software Architecture Description
The local persistence subsystem owns the application's local database — a SQLite file encrypted at rest via SQLCipher, located under the configured AppData path — applies schema migrations via Drift on first open, and exposes a typed API to the application layer for the clinic, patient, and recording domain entities.
Hexagonal layer realising the broader local persistence concern. Encapsulates file lifecycle, encryption (SQLCipher), schema migration (Drift), and the typed domain-API contract — keeps the rest of the application unaware of storage details.
cc7fc276970b8e3d7fcf9027298cfa0095eef00c2bb6e8ae4eee52404ffa4fa2
@DougYoungberg
The local SQLite database is encrypted on disk via the SQLCipher extension. The encryption key is held by flutter_secure_storage, which on Windows persists it as an entry in a DPAPI-encrypted file (flutter_secure_storage.dat) under the user's local app-data directory. On application bootstrap, the key is retrieved from secure storage and generated-and-persisted on first launch if absent. On every database connection open, the database connection service applies the key as the first statement (PRAGMA key) before any other operation; SQLCipher then transparently encrypts and decrypts SQLite pages.
Page-level encryption via SQLCipher avoids per-record encryption logic in application code. Holding the key in flutter_secure_storage (rather than alongside the database file) ensures a copy of the database file alone cannot be opened. Bootstrap-time key generation lets the application be self-installing on a fresh workstation without a manual setup step.
a62962252646969da606f5d40f8fee50864ace3a43692de8b29c62a1fbeed8fb
@DougYoungberg
The local SQLite database enforces referential integrity and write atomicity through three schema- and connection-level mechanisms. First, the Drift-declared schema carries foreign-key constraints linking patient rows to their clinic and to their assigned clinical user, and recording rows to their patient. Column-level constraints declared in Dart (NOT NULL by default, plus CHECK on enumerated text columns such as clinics.origin, patients.origin, and recordings.upload_status) cause the SQLite engine to reject schema-violating writes at insert time. Second, the database connection service enables foreign-key enforcement on every connection by issuing PRAGMA foreign_keys = ON immediately after applying the encryption key, so the schema-declared FK relations are enforced by the SQLite engine for every read and write throughout the application's lifetime. Third, application-layer use cases that perform multi-row or cross-table writes execute them inside a single Drift transaction, so a crash or process termination during the mutation leaves the database in either the pre-mutation or the post-mutation state and never a partial state. Together, these three properties of how the persistence subsystem is configured realise SRS-003.
Integrity could in principle be realised at the application layer through per-use-case validation, but is enforced at the schema and connection layer here so that any new use case, any data-migration script, and any future maintenance write inherits the same constraints automatically without duplicating logic. Schema-level FK constraints and SQLite's built-in enforcement engine are battle-tested mechanisms; lifting them into application code would invite duplication and gradual drift. The per-connection PRAGMA foreign_keys = ON statement is non-negotiable because SQLite's default is OFF — an omission would parse FK declarations and silently ignore them, leaving the integrity story broken at runtime. Drift transactions wrap the multi-row case at the use-case boundary because that is the smallest granularity at which atomicity is meaningfully observable to the rest of the application. Audit-log rows intentionally use a polymorphic (entity_type, entity_id) reference rather than a foreign key because audit entries cover events on clinic, patient, recording, and clinical-user subjects — an FK on entity_id could only target one table — and integrity of the audit chain is preserved instead by making audit_id the immutable primary key with the row content fixed at insert time.
1fb8538abde3bc3860262ab683243fae4ce27045bcc31dc75e427bd65c79a4d2
@DougYoungberg
The audit-logging subsystem realises SRS-004 through three software items in the hexagonal architecture. A domain-layer audit-repository port whose surface offers create and read operations only — no update or delete is exposed to the application. A service in the application/infrastructure boundary that exposes one semantic logging method per audited operation (logPatientCreated, logPatientUpdated, logPatientDeleted, logRecordingStarted, logRecordingStopped, logRecordingDeleted, logRecordingUploaded). A Drift-backed adapter that persists entries into the audit_logs table of the local persistence subsystem (ARCH-001). Application- layer use cases invoke the service after the corresponding record mutation; the service composes an entry (action, entity type, entity identifier, actor identifier when available, JSON details, and timestamp) and delegates persistence to the port.
Routing every audit call through one semantic service keeps the audit concern decoupled from each use case: the use case calls "a patient was created" without assembling the entry itself. Limiting the port surface to create-and-read is what enforces the SRS-004 immutability clause at the architectural level — no application-layer code can mutate or delete an entry because no such method is exposed by the port. Composing entries inside the service rather than inside each use case prevents drift in the shape of entries across operations. The persistence handoff to ARCH-001 means audit entries inherit the same encryption-at-rest (ARCH-002) and integrity (ARCH-003) properties as the rest of the local store.
d5dbb469c6940e7b5d709b6f836a7b6c4096c44582959560c23d9c103c47bc67
@DougYoungberg
The audit_logs table in the local SQLite database stores one row per audit entry. Each row carries an audit identifier (UUID, primary key, immutable), an action string drawn from the application's enumerated set of audited operations, an entity_type and entity_id polymorphic reference to the subject record, a nullable user_id for the authenticated actor, a JSON-serialised details payload, and a UTC timestamp defaulted at the database level. No production code path issues UPDATE or DELETE against the table; the audit-repository port surface (ARCH-004) does not expose those operations.
Polymorphic (entity_type, entity_id) is used in place of a foreign key because audit entries cover events on multiple subject tables (patient, recording, system bootstrap, future clinical_user) — an FK on entity_id could only target one. Integrity instead rests on the immutable primary key being chosen at insert time and the database-side default for timestamp preventing application code from back-dating entries. The JSON details payload allows operation-specific context (e.g., the changed-field list on a patient update, the file size and target clinic on an upload) without inflating the schema with per-operation columns; the trade-off is that detail content is not directly indexable and is intended for human-readable reconstruction of what happened rather than for analytical queries.
b4f2ae7558b9c98456db381d46324adf474c77c5b2e4eddd9e825e783339b427
@DougYoungberg
The audit-trail review-and-export surface realises SRS-005 by extending the Activities overlay (lib/presentation/overlays/activities_overlay.dart) with a tab titled "Audit Log". The pre-existing first tab is renamed from "Recent Activities" to "Recent Recordings" so that its name matches what it actually lists (rows from the recordings table) and does not collide with the new audit-trail tab. The resulting tab set, in order, is: Recent Recordings, Audit Log, Statistics, Archive. The Audit Log tab subscribes to the auditLogListProvider exposed by the application/providers layer, which streams the audit_logs rows from the local persistence subsystem (ARCH-001) via the audit-repository read side (ARCH-004). Entries are rendered as a scrollable list ordered by timestamp descending, each row showing the action, the entity_type/entity_id pair, and the UTC timestamp. The tab carries an Export button whose on-press handler serialises the currently-loaded entries to a file in one of two portable, human-readable formats — CSV (one row per entry, fields: audit_id, timestamp, action, entity_type, entity_id, user_id, details) or JSON (an array of entry objects with the same fields) — and writes the file to a workstation path chosen by the operator through the platform's standard save-file dialog.
The audit-trail viewer is placed in the existing Activities overlay because that overlay already groups operator-visible event surfaces (recent activities, statistics, archive) and audit-log review is a peer concern — adding a parallel tab there preserves the operator's mental model rather than introducing a separate menu entry. The auditLogListProvider is already exposed by the application layer for unrelated reasons, so the viewer attaches to existing wiring rather than introducing a new data path; the read side of the audit-repository port (ARCH-004) is already permitted by the SRS-004 immutability rule (the port surface offers create and read only — no mutating method exists for the viewer to invoke). CSV and JSON are both portable, human-readable, and parseable without specialised tooling on a clinical workstation; the export deliberately writes the same fields shown on screen so the exported file is a faithful copy of what the operator reviewed — a transformation rather than a copy would defeat the off-device review use case. The save-file dialog is the platform-native path because it lets the operator choose a destination outside the application's working directory (typically a clinical share or removable media), which is the realistic workflow for handing the file to an auditor.
5ba229d3b36ffd3ddc7705b5933bb742e3f639fa7fc16d4ad6c759c865b1ab15
@DougYoungberg
The application's top-level desktop window is shown maximised at launch by the native Windows runner: Win32Window::Show() (windows/runner/win32_window.cpp) calls ShowWindow with SW_SHOWMAXIMIZED, invoked from flutter_window.cpp during startup. The Dart application bootstrap additionally calls windowManager.maximize() after the window_manager package initialises, reinforcing the maximised state once the Flutter engine is running. Runtime window state — size, position, and maximised flag — is owned by the WindowStateNotifier (application layer), which applies changes through the window_manager package and persists them to SharedPreferences for restoration on the next launch. The window's minimum size is configured as 1024x768 through the window_manager package's WindowOptions in main.dart, which the package enforces against resize requests so the window cannot be made smaller than that floor.
Launching maximised is set at the native window layer so the window is already maximised the instant it first paints, before the Flutter engine and Dart layer are ready to act; the Dart bootstrap maximise call then keeps the state correct once window_manager owns the window. Window-state persistence is kept in the application-layer notifier so size and position survive across sessions independently of the launch behaviour.
59e14f00ae2b25d6492e269b9ff3fe6debc9ec0a0f261bd3eba952eb582fe3fa
@DougYoungberg
coverage-plan: verified at system level by ST-004, which observes the maximised window in the running application. No flutter_test integration test is feasible for the launch maximise itself: it is performed by the native Win32 runner and by the window_manager package singleton at bootstrap, neither of which is injectable in a widget test. The minimum window size is verified the same way — at system level by ST-012 (planned), which resizes the window below the minimum and asserts the bounds are clamped — since it too is enforced by the window_manager singleton rather than in a widget tree.
The recording control widget occupies the fixed top region of the main window. It is a provider-bound presentation container (RecordingWidget) that subscribes to the recording, selected-patient, and signal-engine application providers and renders, through its view (RecordingWidgetView) and the recording sub-components (status badge, action buttons, center content, and menu panel), the recording controls, the current patient name and recording status, and the elapsed-recording-time readout. It dispatches operator actions — start and stop recording — to the recording view-model and holds no recording domain logic itself. The recorded duration shown in that readout is derived from the native recording engine's sample-accurate timing, pushed into the recording view-model as signal is captured rather than from a wall-clock timer, and is formatted as zero-padded HH:MM:SS by the recording widget state (RecordingWidgetState.formattedElapsedTime).
Keeping the recording widget as a thin provider-bound container, with rendering delegated to a separate view and sub-components, decouples the always-visible top region from recording domain logic. The widget is driven entirely by provider state, so its presentation can be exercised by pumping it with seeded provider values independently of how a recording is actually performed.
535519a4d087d4a5931deb257e7c48e7266ea4f7b96de89810a49f41b5c2685c
@DougYoungberg
The bottom-bar mode controller owns the bottom bar's signal/menu mode. The mode is modelled as a domain value object (BottomBarMode) carrying the two modes and the pure toggle and per-mode content definitions; an application view-model (BottomBarNotifier) holds the current mode — signal mode at launch — and switches it via toggleMode(). The bottom bar widget (BottomBar) watches the mode provider and renders the controls for the current mode: the signal-parameter controls (high-pass, low-pass and notch filters, sensitivity, timebase, and montage) in signal mode, and the Patients/Montages/Settings/Activities navigation buttons in menu mode. A single toggle control invokes toggleMode() when the operator activates it; switching to menu mode opens the corresponding navigation overlay (the last-opened one, or Patients by default) and switching back to signal mode closes it. Dismissing the open overlay — by Escape, a click outside it, or re-selecting the active navigation item — clears the navigation selection, which resets the controller to signal mode.
Modelling the mode and its toggle as a pure domain value object, with the view-model holding only the current-mode state, keeps the mode-switch rule free of presentation and platform concerns so it can be verified at unit level, while the widget stays a thin renderer of whichever mode is current.
ea1afa48d51bd8a9eb5e14ecb9ebf753683c392a6412f641e1257291efd544ac
@DougYoungberg
The overlay navigation subsystem manages the application's modal navigation overlays (Patients, Montages, Settings, Activities). The open-overlay state is held as a single selected index in the navigation view-model (NavigationNotifier), which drives which overlay the main page renders. A companion overlay view-model (OverlayNotifier) tracks overlay visibility, the dismissible flag, and per-overlay data. Because the rendered overlay is keyed off one index rather than a stack, at most one overlay is shown at a time and selecting another replaces it: MainPage renders the selected overlay in a single AnimatedSwitcher slot above a dimmed signal area, with the recording widget and bottom bar layered above it. Dismissal is wired two ways: a KeyboardListener closes the active overlay on the ESC key, and a full-screen barrier behind the overlay closes it when the operator taps outside the overlay content. Both paths call clearSelection(), which is gated by a dismissible flag (overlayDismissibleProvider) so an overlay with in-progress edits (the montage editor) intercepts the close attempt instead of discarding unsaved work.
Modelling the open overlay as one selected index — rather than a navigation stack — is what structurally enforces the single-overlay rule (SRS-010): there is no representation in which two overlays are open. Concentrating dismissal in clearSelection(), invoked by both the ESC handler and the tap-outside barrier, keeps the two dismissal triggers (SRS-009) behaviourally identical and routed through one place, and the dismissible gate keeps that single path safe for edit-bearing overlays.
d9ef3a3e1718ca469e67bab37fc0eedccbbf3fc9aea11b6ff4cce247fa7b3908
@DougYoungberg
The theme subsystem owns the application's visual theming. The available themes are modelled as a domain value object (ThemeType: minimalLight, minimal, and darkGlass — surfaced to the operator as Light, Dark, and Dark Glass). AppTheme.forPreset builds the Flutter ThemeData for a given type, and application providers expose the active selection: the UISettingsNotifier holds the selected ThemeType, currentThemeTypeProvider reads it, and the theme-data and theme-mode providers derive the rendered theme from it, so the whole application re-themes when the selection changes.
Modelling the theme set as a domain value object with a pure type-to-ThemeData builder keeps the available themes and their construction free of presentation state, while the active selection lives in one application provider so the entire widget tree reads a single source of truth for the current theme.
500e401aad99ac68139f2fceaebd95c62c8c7bf29f5c215054493b98de99333c
@DougYoungberg