Status: Accepted (2026-07-02). Extends ADR-0007 (Riverpod / D3) and ratifies the runtime-session design added to App TDD.md §4.8 / §6.2. Enables story A-22 (scan/connect flow). Does not supersede the Connection seam (D1) — it strengthens it.
Through Sprints S1–S3 the app injected a single, static Connection at the ProviderScope root: main.dart overrode connectionProvider with a FakeConnection stand-in (ADR-0007), and every widget bound to that one handle. This was correct for a shell that never talked to a radio.
Making Connect real breaks that assumption in one specific way: the active Connection changes at runtime. The app launches with no board, the user scans, picks a board, a GATT link opens, HELLO negotiates, and only then does a live PbleConnection exist — and it goes away again on disconnect. The transport-facing plumbing this requires (a flutter_blue_plus scan, BleAdapter.connect(id) → BleLink, then PbleConnection.fromLink(...)) touches lib/ble types, which no widget, no lib/app provider, and not main.dart may import (CON-8, FR-BLE-8, enforced by tools/ci/import_boundary.sh, which scans all of lib/** outside lib/pble/).
So the runtime-session orchestration cannot live in the UI or the app wiring. It must live behind the seam, in lib/pble — the sole package permitted to import lib/ble.
A neutral ConnectionManager in lib/pble owns the runtime connection session; the UI drives it through Riverpod and binds only to neutral types. Concretely:
-
ConnectionManager(neutral,lib/pble) is the single object that holds the live session. It composes aScanner(scan seam), aBleReadinessSource(adapter/permission seam), and aConnectionFactory(Future<Connection> Function(String deviceId)), and exposes: a stable facadeConnection, the live scan results, readiness, aConnectPhase(idle | scanning | connecting | connected | failed), the selected board, the last error, and the verbsstartScan/stopScan/connect(id)/disconnect. Its production constructorPbleConnectionManager.production({appName, appVersion})wires the realflutter_blue_plusstack internally, so callers name nolib/bletype. -
The
connectionProviderhandle stays, but is preserved as a stable facade.ConnectionManager.connectionis oneConnectionobject whose identity never changes across scan/connect/disconnect cycles: itsstatereflects the full session lifecycle (disconnectedwhile idle/scanning/failed,connectingduring GATT+HELLO, then the live board'sready/running), and its verbs delegate to the live board when connected and throw a typed not-connected error otherwise.connectionProviderbecomesmanager.connection;connStateProvideris unchanged (connectionProvider.state). Every existing widget (ConnectionStatusPill,PinReferencePage,FilesPage) keeps compiling and now reflects real state. -
The root injection point moves up one level, from
connectionProvidertoconnectionManagerProvider.main.dartoverridesconnectionManagerProviderwithPbleConnectionManager.production(...)(importinglib/pbleonly); tests override it with a fake manager, or overrideconnectionProviderdirectly with aFakeConnection(which still wins, so the S1–S3 widget suite is untouched). -
Riverpod remains the one state approach (ADR-0007). A
ConnectController(Notifier,lib/connect) projects the manager into aConnectStatefor the connect UI; allConnection-derived state stays in derived providers. No second state library is introduced.
Unchanged: the Connection seam (D1), neutral types (D2), the FakeConnection test model (D5), the strict UI → lib/pble → lib/ble layering (NFR-MAINT-1), the service-UUID-filtered scan / no-raw-list rule (CON-1, SEC-4, FR-BLE-1), and scan→connect→use with no pairing/account/identity-gating (FR-CONNECT-5, SEC-6).
- Keep the static
connectionProvider, swap its value at runtime withoverrideWithfrom the UI. Rejected: the UI would need to build aPbleConnectionfrom aBleLink, importinglib/bleand breaking CON-8; and runtime re-scoping of a root override is fragile. - Put the orchestration in
lib/connectorlib/app. Rejected: both arelib/**outsidelib/pble/and may not importlib/ble(import-boundary gate). The bridge fromBleAdapter/BleLinkto a publishedConnectionis intrinsically transport-facing. - Make
connectionProvidernullable (Connection?). Rejected: every existing reader (.state,.deviceInfo()) would need null-handling; the stable disconnected facade preserves the non-null seam with honest typed errors. - A second state-management library (Bloc) for the session. Rejected — contradicts ADR-0007 / NFR-MAINT-2.
Positive
- The whole runtime session is testable with neutral fakes (
FakeScanner, aConnectionFactoryreturningFakeConnection, a fakeBleReadinessSource) — no fake radio, no fakeBleLink(FR-CONN-7, D5). main.dartand every widget staylib/ble-free; the import-boundary and no-leak gates keep passing.- The seam is unchanged in shape, so S1–S3 widgets and their tests carry forward with no rewrite.
Costs / mitigations
- One new neutral concept (
ConnectionManager) and its provider. Mitigation: it is the only new seam object;Scanner/ScanHit/ConnectPhaseare small value types, andproduction()hides all wiring. - The disconnected facade must throw honest typed errors (not silently succeed). Mitigation: frozen in TDD §5/§6.2; covered by
[red]before[green].
Process
- Spec-first: this ADR + the TDD §4.8/§5.2/§6.2/§7.1 freeze land in their own
[docs]commit before A-22's[red]/[green]. - No new dependency:
flutter_blue_plusis already pinned;permission_handleris not added (TDD §7.4). Riverpod 2.xNotifieris used (already the app's state approach).
- Extends ADR-0007 (Riverpod / D1/D3); builds on ADR-0002 (fresh PBLE/1) and ADR-0008 (Signal UI for the connect surface).
- Ratifies App TDD.md §4.8, §5.2, §6.2, §7.1; satisfies specs.md FR-CONNECT-1/2/3/5/6, FR-CONN-5/6, FR-BLE-8, NFR-MAINT-1/2, CON-8.
- Consumed first by App story A-22 (scan/connect flow with diagnostics), Sprint S5.