Add functional requirements for service & characteristic discovery#47
Conversation
The PRD covered scanning/connections (FR-3, FR-8) and reading/writing data (FR-4, FR-7) but not the GATT layer in between. Add a new §10, "Service and Characteristic Discovery and Management", covering service and characteristic/descriptor discovery, the discovery lifecycle and readiness state machine, subscription (notify/indicate) management, GATT cache invalidation and re-discovery (Service Changed / didModifyServices), and stale-reference/reconnect handling. Scope is discovery and characteristic setup only; value read/write is deferred to FR-4 (command protocol) and FR-7 (chunking) via cross-refs. Requirements are grounded in researched iOS CoreBluetooth pitfalls (filtered discovery, ordering constraints, OS-level GATT caching, CCCD handling, unacknowledged notifications, never reusing CB objects across connections). Also tie the new section into existing requirements: FR-1.2 (reconnection triggers re-discovery), FR-4.1 (commands target discovered characteristics), and FR-9.2 (log discovery events). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hg9mZheWnLsn8fSrB7JZ88
itsniper
left a comment
There was a problem hiding this comment.
Review: GATT discovery PRD vs architecture findings
Scope is right (services/characteristics only; not a full PRD rewrite). The CoreBluetooth pitfalls coverage (filtered discovery, ordering, CCCD via setNotifyValue, OS GATT cache, no live CB object reuse) is solid and well grounded.
Where this misses the mark relative to the ObjC-vs-ReliaBLE architecture evaluation (docs/investigations/objc-ble-vs-reliable-architecture-2026-07-15.md and the ObjC readiness model):
The critical architectural role of discovery is under-specified
In the production ObjC stack, discovery was not just a catalog API — it was a hard readiness gate on the per-peripheral work queue:
- queue created suspended
- unsuspend only after required services/chars exist
didModifyServicesre-suspends and rediscovers before any further work
ReliaBLE's reliability unit today is connection state. FR-10 correctly introduces discovery state, but it never makes connected ≠ ready-for-GATT an invariant that FR-4/FR-5 must honor. Without that, shipping GATT later recreates the early write/notify races the ObjC gate prevented.
Gaps to close within this PR's GATT scope (not full command-layer PRD)
- Readiness gate invariant — FR-10 must require that command/GATT ops wait until ready (and re-wait after re-discovery).
- Declare-desired-services API shape — auto-on-connect (FR-10.1.2) needs where/when the app declares the filter (connect options? config? per-peripheral registration?).
- Discovery vs
ConnectionState— separate stream vs extend connection lifecycle; document thatconnectedis notready. - State restoration — existing
Background.mdalready says GATT must re-wire on restore; FR-10.6 should cover restore explicitly, not only "new connection". - Re-subscribe after reconnect — FR-10.4 enables notify, FR-10.6 rediscovers, but nothing requires re-enabling subscriptions after drop/restore (notify-only apps break otherwise).
didModifyServicesmust re-gate work, not only re-run discovery in the background while commands continue.- Partial discovery policy — missing required UUID → hard fail vs partial ready?
- Peripheral key identity — "peripheral identifier" must align with existing
Peripheral.id(name/localName/cbIdentifier; FR-8.5 still open).
Intentionally out of scope (flag only — post-merge collaboration)
Do not expand this PR into full FR-4/5. Just leave explicit forward references so later work doesn't invent a parallel model:
- serial-per-peripheral queue + reconnect-and-rerun (ObjC moat)
- command watchdogs / exactly-once completion
- demand-connect / idle disconnect (deliberate product fork; optional later)
Recommended build order from the investigation still holds: GATT substrate → serial queue → readiness gate → reconnect-and-rerun → watchdogs → only then deps/priority/chunking.
Inline comments mark the specific FR text to tighten. Happy to iterate on wording in-PR; once merged we can align the rest of the architecture into the PRD.
(Posted as COMMENT rather than REQUEST_CHANGES because GitHub disallows requesting changes on your own PR.)
| discovered before any interaction, and that discovered view must be kept correct across | ||
| firmware changes and reconnections. This section covers discovery and characteristic setup | ||
| only; the movement of application data over characteristics is covered by the command | ||
| protocol (FR-4) and chunking (FR-7). |
There was a problem hiding this comment.
Readiness as reliability invariant (missing).
This intro correctly positions discovery between connect and data movement, but it still describes discovery as a catalog/setup phase rather than a gate.
Please add an explicit invariant here (or as FR-10.3.x), roughly:
A peripheral is not eligible for command execution or characteristic I/O until discovery has reached a terminal ready state for the app-declared UUID set.
connected(FR-1.3.1 / connection streams) is not sufficient.
This is the ObjC "discovery gates the command queue" property that the architecture comparison flagged as the first reliability primitive to land with GATT. Without it, FR-4/5 implementors will race writes against incomplete discovery.
|
|
||
| - FR-10.1: Service Discovery: | ||
| - FR-10.1.1: Provide an API to discover services on a connected peripheral, requiring the integrating app to declare the specific service UUIDs of interest. Do not expose unfiltered "discover all services" as the default path — Apple explicitly discourages it because enumerating an entire remote GATT table negatively affects battery life and time-to-ready. | ||
| - FR-10.1.2: Support both automatic discovery on connection (using a caller-declared set of desired services/characteristics) and explicit, on-demand discovery invoked by the integrating app. |
There was a problem hiding this comment.
API shape for "caller-declared" services is underspecified.
FR-10.1.1–10.1.2 require a declared UUID set for auto-on-connect discovery, but not where that declaration lives. ReliaBLE's connection model is explicit connect(to:) (not demand-driven), so this needs a deliberate choice, e.g.:
- per-
connectoptions (desiredServices/desiredCharacteristics), and/or - a sticky per-peripheral registration on the manager, and/or
- config defaults
Also specify: if auto-discovery is enabled and no UUID set was declared, is that a hard error (preferred — matches "no unfiltered default") or a no-op leave-not-ready?
Please nail this at requirement level so implementation doesn't invent three competing APIs.
|
|
||
| - FR-10.2: Characteristic and Descriptor Discovery: | ||
| - FR-10.2.1: Provide an API to discover characteristics for a discovered service, allowing the integrating app to declare the characteristic UUIDs of interest. Enforce CoreBluetooth's ordering constraint: services are discovered before characteristics, and characteristics before descriptors. | ||
| - FR-10.2.2: Track per-service discovery completion, correctly handling the separate completion callback fired for each service, and report a peripheral as "ready" only once all requested discoveries have completed. |
There was a problem hiding this comment.
"Ready" needs a hard consumer contract, not just a status bit.
"Report a peripheral as ready only once all requested discoveries have completed" is good, but incomplete without:
- Partial discovery policy — if a declared service/characteristic UUID is absent (narrow filter, wrong firmware, stale cache), is that discovery failure (not ready + error) or partial ready with a missing-UUID report? ObjC-style reliability prefers fail-closed for required UUIDs.
- Gate on consumers — FR-4 commands / any future read-write-notify API must await/park until ready (and fail fast if discovery failed), not merely observe a stream.
- Cross-ref — add an explicit FR-4.1 (or FR-5.2) note that enqueue/execute is gated on FR-10 readiness.
Without (2)–(3), "ready" is informational only and won't prevent the early-I/O races this section exists to stop.
|
|
||
| - FR-10.3: Discovery Lifecycle and Readiness: | ||
| - FR-10.3.1: Model discovery as an explicit, per-peripheral state machine driven entirely by delegate callbacks. Never assume synchronous availability of services or characteristics after initiating discovery. | ||
| - FR-10.3.2: Provide callbacks/streams (mirroring the asynchronous event pattern of FR-2.3) reporting discovery progress, completion, and errors. |
There was a problem hiding this comment.
Discovery lifecycle vs existing ConnectionState streams.
ReliaBLE already ships connection lifecycle streams (connected / disconnected / reconnecting, etc.). FR-10.3.1–10.3.2 introduce another per-peripheral state machine + streams, but don't define the relationship.
Please specify one of:
- A) Discovery states are a separate feed (e.g.
discoveryStateChanges) and docs must stressconnected ≠ ready, or - B) Connection lifecycle is extended with discovery phases (
connecting → connected → discovering → ready), or - C) Hybrid: keep connection stream pure; readiness is a derived gate used internally + exposed as a simple
isReady/awaitable.
Architecture eval preference: keep connection recovery observability (a ReliaBLE strength) distinct, and treat readiness as an explicit gate/stream so reconnect ladders don't get conflated with GATT catalog state. Either way, pick it in the PRD so implementers don't fork models.
| - FR-10.3.1: Model discovery as an explicit, per-peripheral state machine driven entirely by delegate callbacks. Never assume synchronous availability of services or characteristics after initiating discovery. | ||
| - FR-10.3.2: Provide callbacks/streams (mirroring the asynchronous event pattern of FR-2.3) reporting discovery progress, completion, and errors. | ||
| - FR-10.3.3: Enforce a configurable discovery timeout so that a stalled or interrupted discovery surfaces as an error rather than an indefinite wait. | ||
| - FR-10.3.4: Handle interrupted or partial discovery: if a peripheral disconnects mid-discovery, treat it as a hard reset of the discovery state — discard partial results and any pending discovery bookkeeping — and re-run discovery on the next connection (see FR-10.6.1). |
There was a problem hiding this comment.
Good hard-reset on mid-discovery disconnect — keep this.
One addition: on unexpected disconnect, also define what happens to any app-visible discovery snapshot (clear immediately vs mark stale until next ready). FR-10.6.2 says discard internal CB refs; the app-facing catalog invalidation timing should be equally explicit to match the existing peripheral-snapshot refresh philosophy.
| - FR-10.4.1: Provide an API to enable and disable notifications or indications on a characteristic using the platform's subscribe mechanism. Never write the Client Characteristic Configuration Descriptor (CCCD, 0x2902) directly — CoreBluetooth manages the CCCD write internally and forbids writing it directly. | ||
| - FR-10.4.2: Confirm the subscription state-change callback before treating a subscription as active, and sequence subscription-enable before issuing any command that causes the peripheral to emit data, so that initial notifications are not lost. Delivery of the notified values is covered by FR-4.2.1 (notify-only) and FR-2.3.2 (data-received streams). | ||
| - FR-10.4.3: Expose subscription state and its changes to the integrating app. Do not rely solely on the platform's `isNotifying` flag, which can lag the actual state. | ||
| - FR-10.4.4: Account for the fact that notifications are unacknowledged at the GATT layer and may be lost or reordered above the link layer. Application-layer framing/sequencing for multi-packet notification streams is handled per FR-7 (chunking and reassembly). |
There was a problem hiding this comment.
FR-10.4 scope + reconnect re-subscribe gap.
Two issues:
-
Scope boundary — PR summary says discovery/setup only; enable/disable notify is setup-adjacent and fine, but FR-10.4.4's deep dive into lossy notifications + FR-7 handoff is command/chunking territory. Consider trimming 10.4.4 to a one-liner forward-ref so this section stays discovery/setup-focused.
-
Missing requirement (in-scope): After reconnect / restore /
didModifyServicesre-discovery, previously requested subscriptions must be re-armed (or explicitly reported as inactive for the app to re-request). Rediscovery alone does not restore notify/indicate intent. Notify-only apps (FR-4.2.1) break without this.
Also sequence re-subscribe before releasing the readiness gate for commands that depend on notifications (ties to FR-10.4.2).
| - FR-10.4.4: Account for the fact that notifications are unacknowledged at the GATT layer and may be lost or reordered above the link layer. Application-layer framing/sequencing for multi-packet notification streams is handled per FR-7 (chunking and reassembly). | ||
|
|
||
| - FR-10.5: GATT Cache Invalidation and Re-discovery: | ||
| - FR-10.5.1: Detect and surface peripheral GATT-table changes reported by the platform (the "did modify services" callback) and automatically re-run discovery for the affected services. |
There was a problem hiding this comment.
didModifyServices must re-gate work, not only re-discover.
ObjC re-suspended the peripheral queue on GATT table change, then rediscovered, then resumed. FR-10.5.1 only requires detecting the callback and re-running discovery.
Please require:
- mark peripheral not ready immediately on
didModifyServices - fail or park any in-flight discovery-dependent I/O
- re-run discovery for affected (or all declared) services
- return to ready only after completion
- surface the event on the discovery/lifecycle stream
Otherwise commands can execute against a half-invalidated catalog mid-DFU/service-change — exactly the class of bug the readiness gate exists to prevent.
|
|
||
| - FR-10.6: Reconnection and Stale-Reference Handling: | ||
| - FR-10.6.1: Re-discover services and characteristics on every new connection. Never reuse a service or characteristic reference obtained from a prior connection (see FR-1.2 for reconnection). | ||
| - FR-10.6.2: Internally discard all live CoreBluetooth service/characteristic references on disconnect and reset the discovery state. The app-facing catalog must be keyed by UUID (CBUUID), not by held CoreBluetooth objects. This is consistent with the library's existing invalidation/refresh behavior on Bluetooth power cycling. |
There was a problem hiding this comment.
State restoration path is missing.
FR-10.6 covers "every new connection" and disconnect invalidation, but ReliaBLE already has CB state restoration (restoreIdentifier, willRestoreState). Documentation.docc/Topics/Background.md already notes that when GATT lands, restoration must re-wire peripheral GATT state.
Please add something like FR-10.6.3:
- On central restore, re-associate restored
CBPeripherals, discard any prior catalog, re-run discovery for peripherals the app still intends to use, and re-apply subscription intent (see FR-10.4 comment). - Restored link ≠ ready; same readiness gate as a fresh connect.
Also: "keyed by peripheral identifier" should explicitly mean ReliaBLE's Peripheral.id model (and note FR-8.5 still incomplete) so implementers don't key only on cbIdentifier inconsistently with the rest of the library.
| 4. Command Style Protocol for Interacting with Peripherals: | ||
|
|
||
| - FR-4.1: Define a flexible command protocol where the content and functionality of commands are provided by the integrating app. | ||
| - FR-4.1: Define a flexible command protocol where the content and functionality of commands are provided by the integrating app. Commands target services and characteristics made available through discovery (see FR-10), referenced by UUID (CBUUID). |
There was a problem hiding this comment.
Good cross-ref — make the gate explicit.
"Commands target services/characteristics made available through discovery" still allows interpreting discovery as a passive catalog. Please strengthen to something like:
Commands may only execute against a peripheral that is discovery-ready for the targeted service/characteristic UUIDs (FR-10.3). Targeting an undiscovered or not-ready characteristic is an error (or must await readiness — pick one; prefer await-with-timeout for auto-discover, hard error if discovery already failed).
This is the minimal FR-4 tie-in needed so the later command-queue work inherits the gate instead of re-litigating it.
|
|
||
| - FR-1.1: Implement error detection and correction mechanisms for each BLE transaction. | ||
| - FR-1.2: Ensure automatic reconnection attempts with exponential backoff for failed connections. | ||
| - FR-1.2: Ensure automatic reconnection attempts with exponential backoff for failed connections. On reconnection, services and characteristics must be re-discovered rather than reused from the prior connection (see FR-10.6). |
There was a problem hiding this comment.
Re-discover on reconnect is correct; incomplete for in-flight work.
Within GATT scope this FR-1.2 addition is right. Please also note (one clause is enough) that re-discovery is part of returning to ready, and that any future command-layer reconnect-and-rerun (FR-4/5; post-merge) depends on this ready transition — so we don't later treat "connected again" as sufficient to resume I/O.
No need to specify the full pause/retry model here.
Incorporate PR review feedback aligning the services/characteristics requirements with the ObjC BLE architecture re-alignment. Discovery is now specified as a reliability gate, not just a catalog: - Add the "connected != ready" invariant and an enforced readiness gate (FR-10.3.6): characteristic I/O / command execution must await readiness (bounded by the discovery timeout) or fail fast if discovery failed. - Expose readiness via a feed distinct from ConnectionState (FR-10.3.2). - Fail-closed partial-discovery policy for missing declared UUIDs (FR-10.3.5); invalidate app-visible snapshot on disconnect (FR-10.3.4). - Mandatory declared-UUID set with hard error if absent (FR-10.1.2). - Subscription intent persistence / re-arm after reconnect, restoration, or didModifyServices (FR-10.4.5); trim FR-10.4.4 to a forward-ref. - didModifyServices must re-gate work, not only re-discover (FR-10.5.1). - Add state restoration handling (FR-10.6.3) and align catalog keying to the Peripheral.id identity model, noting FR-8.5 (FR-10.6.2). Tie-ins: FR-1.2 and FR-4.1 now name the readiness gate so the future command layer inherits it. Command-queue/pause-rerun mechanics remain out of scope (follow-up PRD alignment); referenced only as forward hooks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hg9mZheWnLsn8fSrB7JZ88
|
Thanks — this is a great review. Justin confirmed the direction: fold the discovery-relevant bits of the ObjC architecture re-alignment into this PR, keep it scoped to services/characteristics, and leave the full FR-4/FR-5 command-layer alignment to a follow-up. Pushed in d6dac2e. Point-by-point: Readiness as reliability invariant (§137, and lines 29/52) — Added. The §10 intro now states the "Ready" consumer contract + partial-discovery policy (§146) — FR-10.3.5 is now fail-closed: a missing declared/required UUID = discovery failure (not-ready + missing-UUID report). Consumer gating is FR-10.3.6; FR-4.1 carries the cross-ref. Declared-services API shape (§141) — FR-10.1.2 now requires the app to declare the desired UUID set at/around connect (via connect options and/or a sticky per-peripheral registration, and/or config defaults), fail-closed if absent. I kept the exact API surface at requirement altitude ("finalized in implementation") rather than picking one signature — flagging that as the one genuine open product decision for you to lock when the command layer lands. Discovery vs Snapshot invalidation on disconnect (§154) — Added to FR-10.3.4 (clear or mark stale, explicit timing). Re-subscribe after reconnect/restore/modify (§161) — New FR-10.4.5 (subscription intent persistence + re-arm, or report inactive); FR-10.4.2 sequences re-subscribe before releasing the gate for notify-dependent work; trimmed FR-10.4.4 to a forward-ref to FR-7/FR-4.
State restoration (§170) — New FR-10.6.3 ( Two notes:
Happy to iterate on wording in-thread. Generated by Claude Code |
Summary
The PRD covered scanning/connections (FR-3, FR-8) and reading/writing data (FR-4, FR-7) but skipped the GATT layer in between — service and characteristic discovery and setup, the phase that must happen after a peripheral connects and before any read/write can occur. This PR fills that gap.
Adds a new §10 — Service and Characteristic Discovery and Management to
PRD.md, appended after the Logging section with stable numbering (no existing sections renumbered, so theFR-8.5code references are untouched).Scope
Discovery and characteristic setup only. The movement of application data over characteristics stays with the command protocol (FR-4) and chunking (FR-7), referenced rather than duplicated.
New requirements (all unchecked — greenfield; the library currently has no GATT layer)
Sendablesnapshots keyed by peripheral id +CBUUID.setNotifyValue(never write CCCD 0x2902 directly), confirm-before-active + enable-before-command sequencing, don't trustisNotifying, notifications are unacknowledged.didModifyServicesre-discovery, post-DFU full re-discovery, and the documented iOS OS-level caching limitation (Service Changed 0x2A05 + bonding is a peripheral-firmware responsibility, no app-side clear API).CBService/CBCharacteristicacross connections, catalog keyed byCBUUID.Tie-ins to existing sections
Grounding
Requirement content is grounded in researched real-world iOS CoreBluetooth pitfalls: filtered vs. unfiltered discovery (battery/perf), the mandatory discovery ordering and per-service callback fan-out, the OS-level GATT cache + Service Changed problem, CCCD-via-
setNotifyValue, unacknowledged/lossy notifications, and the requirement never to hold CoreBluetooth attribute objects across connections.This is a documentation-only change (
PRD.md); no code is modified.🤖 Generated with Claude Code
https://claude.ai/code/session_01Hg9mZheWnLsn8fSrB7JZ88
Generated by Claude Code