From 6b1627313b275651a68c9a2ec556849eef10f6ce Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Mar 2026 10:08:19 -0300 Subject: [PATCH 1/6] RSH8k2: link remaining RSH3a2b references The note in RSH8k2 mentioned RSH3a2b three times but only linked the first reference. Link the other two for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- specifications/features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/features.md b/specifications/features.md index 6df4e2d5..86cbbde4 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1166,7 +1166,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8)` In platforms that support receiving push notifications, the `device` method on the `RestClient` or `RealtimeClient` interfaces returns an instance of `LocalDevice` that represents the current state of the device in respect of it being a target for push notifications. - `(RSH8k)` `LocalDevice` has the following attributes: - `(RSH8k1)` `deviceIdentityToken` string? -- populated as described in [RSH8c](#RSH8c) - - `(RSH8k2)` `deviceSecret` string -- populated as described in [RSH8b](#RSH8b). (Note: This property being non-nullable is not actually consistent with [`RSH3a2b`](#RSH3a2b); that spec point implies that `id` and `deviceSecret` both start off unset and are only set upon a `CalledActivate` event. However, since `deviceSecret` needs to have the same nullability as `id` --- since per `RSH3a2b` either both or neither should be set --- to reflect the behaviour described in the spec we would have to make `LocalDevice#id` nullable, but this is incompatible with the superclass `DeviceDetails`. In reality, our implementations of `LocalDevice` actually generate `id` and `deviceSecret` when the device is fetched, i.e. not following `RSH3a2b`. What we *should* do is either make `LocalDevice` stop inheriting from `DeviceDetails`, or change the specified behaviour for when to generate `id` and `deviceSecret` to match our implementations, or both. See spec issues [#180](https://github.com/ably/specification/issues/180) and [#25](https://github.com/ably/specification/issues/25). For now, this note exists to reduce confusion.) + - `(RSH8k2)` `deviceSecret` string -- populated as described in [RSH8b](#RSH8b). (Note: This property being non-nullable is not actually consistent with [`RSH3a2b`](#RSH3a2b); that spec point implies that `id` and `deviceSecret` both start off unset and are only set upon a `CalledActivate` event. However, since `deviceSecret` needs to have the same nullability as `id` --- since per [`RSH3a2b`](#RSH3a2b) either both or neither should be set --- to reflect the behaviour described in the spec we would have to make `LocalDevice#id` nullable, but this is incompatible with the superclass `DeviceDetails`. In reality, our implementations of `LocalDevice` actually generate `id` and `deviceSecret` when the device is fetched, i.e. not following [`RSH3a2b`](#RSH3a2b). What we *should* do is either make `LocalDevice` stop inheriting from `DeviceDetails`, or change the specified behaviour for when to generate `id` and `deviceSecret` to match our implementations, or both. See spec issues [#180](https://github.com/ably/specification/issues/180) and [#25](https://github.com/ably/specification/issues/25). For now, this note exists to reduce confusion.) - `(RSH8a)` The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of an operation involving the Activation State Machine. The `LocalDevice` `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. - `(RSH8b)` The `LocalDevice` `id` and `deviceSecret` attributes are generated, and persisted as part of the `LocalDevice` state, when required by step [`RSH3a2b`](#RSH3a2b) in the Activation State Machine. At that time, the `clientId` attribute is also initialised, if the client is identified according to [`RSA7`](#RSA7). - `(RSH8c)` Following successful registration of a `LocalDevice`, following the procedure in [`RSH3c2a`](#RSH3c2a), the now known `deviceIdentityToken` is set and persisted. From 52fb9e2f536ed3a0920a4452a9ee4caa44259aab Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Mar 2026 10:16:54 -0300 Subject: [PATCH 2/6] RSH8k2: clarify per-SDK behaviour for id/deviceSecret generation The note previously said "our implementations" all generate eagerly, but this isn't accurate. ably-java follows RSH3a2b (lazy generation on CalledActivate); ably-cocoa and ably-js generate eagerly at device fetch time. Checked against: - ably-cocoa 745e7b7 - ably-java da4c60f - ably-js 17be43e Co-Authored-By: Claude Opus 4.6 (1M context) --- specifications/features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/features.md b/specifications/features.md index 86cbbde4..b41af282 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1166,7 +1166,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8)` In platforms that support receiving push notifications, the `device` method on the `RestClient` or `RealtimeClient` interfaces returns an instance of `LocalDevice` that represents the current state of the device in respect of it being a target for push notifications. - `(RSH8k)` `LocalDevice` has the following attributes: - `(RSH8k1)` `deviceIdentityToken` string? -- populated as described in [RSH8c](#RSH8c) - - `(RSH8k2)` `deviceSecret` string -- populated as described in [RSH8b](#RSH8b). (Note: This property being non-nullable is not actually consistent with [`RSH3a2b`](#RSH3a2b); that spec point implies that `id` and `deviceSecret` both start off unset and are only set upon a `CalledActivate` event. However, since `deviceSecret` needs to have the same nullability as `id` --- since per [`RSH3a2b`](#RSH3a2b) either both or neither should be set --- to reflect the behaviour described in the spec we would have to make `LocalDevice#id` nullable, but this is incompatible with the superclass `DeviceDetails`. In reality, our implementations of `LocalDevice` actually generate `id` and `deviceSecret` when the device is fetched, i.e. not following [`RSH3a2b`](#RSH3a2b). What we *should* do is either make `LocalDevice` stop inheriting from `DeviceDetails`, or change the specified behaviour for when to generate `id` and `deviceSecret` to match our implementations, or both. See spec issues [#180](https://github.com/ably/specification/issues/180) and [#25](https://github.com/ably/specification/issues/25). For now, this note exists to reduce confusion.) + - `(RSH8k2)` `deviceSecret` string -- populated as described in [RSH8b](#RSH8b). (Note: This property being non-nullable is not actually consistent with [`RSH3a2b`](#RSH3a2b); that spec point implies that `id` and `deviceSecret` both start off unset and are only set upon a `CalledActivate` event. However, since `deviceSecret` needs to have the same nullability as `id` --- since per [`RSH3a2b`](#RSH3a2b) either both or neither should be set --- to reflect the behaviour described in the spec we would have to make `LocalDevice#id` nullable, but this is incompatible with the superclass `DeviceDetails`. In reality, ably-cocoa and ably-js actually generate `id` and `deviceSecret` when the device is fetched, i.e. not following [`RSH3a2b`](#RSH3a2b). ably-java follows [`RSH3a2b`](#RSH3a2b), allowing `id` to be `null` (this doesn't affect the public API because nullability is not a concept in Java's type system). What we *should* do is either make `LocalDevice` stop inheriting from `DeviceDetails`, or change the specified behaviour for when to generate `id` and `deviceSecret` to match our implementations, or both. See spec issues [#180](https://github.com/ably/specification/issues/180) and [#25](https://github.com/ably/specification/issues/25). For now, this note exists to reduce confusion.) - `(RSH8a)` The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of an operation involving the Activation State Machine. The `LocalDevice` `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. - `(RSH8b)` The `LocalDevice` `id` and `deviceSecret` attributes are generated, and persisted as part of the `LocalDevice` state, when required by step [`RSH3a2b`](#RSH3a2b) in the Activation State Machine. At that time, the `clientId` attribute is also initialised, if the client is identified according to [`RSA7`](#RSA7). - `(RSH8c)` Following successful registration of a `LocalDevice`, following the procedure in [`RSH3c2a`](#RSH3c2a), the now known `deviceIdentityToken` is set and persisted. From 4eda527829673aed936d98d2517bb7a813d73b9f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Mar 2026 10:27:21 -0300 Subject: [PATCH 3/6] RSH8j: move to subpoint of RSH8a RSH8j describes the failure path of RSH8a's loading step, so it belongs as a subpoint of RSH8a rather than a standalone point that reaches across into the state machine. Move it to RSH8a1, keeping the original text, and leave a replacement stub at RSH8j. Co-Authored-By: Claude Opus 4.6 (1M context) --- specifications/features.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifications/features.md b/specifications/features.md index b41af282..88b4502e 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1168,6 +1168,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8k1)` `deviceIdentityToken` string? -- populated as described in [RSH8c](#RSH8c) - `(RSH8k2)` `deviceSecret` string -- populated as described in [RSH8b](#RSH8b). (Note: This property being non-nullable is not actually consistent with [`RSH3a2b`](#RSH3a2b); that spec point implies that `id` and `deviceSecret` both start off unset and are only set upon a `CalledActivate` event. However, since `deviceSecret` needs to have the same nullability as `id` --- since per [`RSH3a2b`](#RSH3a2b) either both or neither should be set --- to reflect the behaviour described in the spec we would have to make `LocalDevice#id` nullable, but this is incompatible with the superclass `DeviceDetails`. In reality, ably-cocoa and ably-js actually generate `id` and `deviceSecret` when the device is fetched, i.e. not following [`RSH3a2b`](#RSH3a2b). ably-java follows [`RSH3a2b`](#RSH3a2b), allowing `id` to be `null` (this doesn't affect the public API because nullability is not a concept in Java's type system). What we *should* do is either make `LocalDevice` stop inheriting from `DeviceDetails`, or change the specified behaviour for when to generate `id` and `deviceSecret` to match our implementations, or both. See spec issues [#180](https://github.com/ably/specification/issues/180) and [#25](https://github.com/ably/specification/issues/25). For now, this note exists to reduce confusion.) - `(RSH8a)` The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of an operation involving the Activation State Machine. The `LocalDevice` `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. + - `(RSH8a1)` If the `LocalDevice` `id` or `deviceSecret` attributes are not able to be loaded then those LocalDevice details must be discarded and the ActivationStateMachine machine should transition to the `NotActivated` state. New `LocalDevice` `id` and `deviceSecret` attributes should be generated on the next activation event. - `(RSH8b)` The `LocalDevice` `id` and `deviceSecret` attributes are generated, and persisted as part of the `LocalDevice` state, when required by step [`RSH3a2b`](#RSH3a2b) in the Activation State Machine. At that time, the `clientId` attribute is also initialised, if the client is identified according to [`RSA7`](#RSA7). - `(RSH8c)` Following successful registration of a `LocalDevice`, following the procedure in [`RSH3c2a`](#RSH3c2a), the now known `deviceIdentityToken` is set and persisted. - `(RSH8d)` If the `LocalDevice` is created by an unidentified client (see [`RSA7`](#RSA7) ) and therefore has no `clientId` set, but the client subsequently becomes identified (as a result of [`RSA7b2`](#RSA7b2) or [`RSA7b3`](#RSA7b3) ), then the `LocalDevice` `clientId` is set and persisted. @@ -1176,7 +1177,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8g)` Whenever any change arises of the push transport details for local device (eg an FCM registration token update triggered by the platform), a `GotPushDeviceDetails` event is sent to [the state machine](#RSH3). - `(RSH8h)` If an attempt to obtain the push transport details for local device (eg an FCM registration token) fails, a `GettingPushDeviceDetailsFailed` event containing the indicated error is sent to [the state machine](#RSH3). - `(RSH8i)` Each time the library is instantiated, if the LocalDevice has push device details (eg an APNS deviceToken), and if the platform supports it, it must verify the validity of those details (eg by requesting a token from the platform and comparing that with the already-known token). If as a result there are updated details, then an update to the Ably server is triggered by sending a `GotPushDeviceDetails` event to [the state machine](#RSH3). - - `(RSH8j)` If during library initialisation the `LocalDevice` `id` or `deviceSecret` attributes are not able to be loaded then those LocalDevice details must be discarded and the ActivationStateMachine machine should transition to the `NotActivated` state. New `LocalDevice` `id` and `deviceSecret` attributes should be generated on the next activation event. + - `(RSH8j)` This clause has been replaced by [`RSH8a1`](#RSH8a1). ## Types From 1daafb9a47078a30325854bc4d59ebb9d9556f9e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Mar 2026 11:30:28 -0300 Subject: [PATCH 4/6] RSH8a1: add note documenting known issues Document the problems with this spec point: - It doesn't clear the deviceIdentityToken, so RSH3a2a will try to use a stale token against an absent or non-matching device ID - It's ambiguous whether "generate new" is a new instruction or a restatement of RSH3a2b, and if the latter, RSH3a2a runs first - It bypasses the state machine's event-driven model and doesn't specify what should happen to an in-flight activate() call - No implementations currently implement this spec point Co-Authored-By: Claude Opus 4.6 (1M context) --- specifications/features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/features.md b/specifications/features.md index 88b4502e..3db4d833 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1168,7 +1168,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8k1)` `deviceIdentityToken` string? -- populated as described in [RSH8c](#RSH8c) - `(RSH8k2)` `deviceSecret` string -- populated as described in [RSH8b](#RSH8b). (Note: This property being non-nullable is not actually consistent with [`RSH3a2b`](#RSH3a2b); that spec point implies that `id` and `deviceSecret` both start off unset and are only set upon a `CalledActivate` event. However, since `deviceSecret` needs to have the same nullability as `id` --- since per [`RSH3a2b`](#RSH3a2b) either both or neither should be set --- to reflect the behaviour described in the spec we would have to make `LocalDevice#id` nullable, but this is incompatible with the superclass `DeviceDetails`. In reality, ably-cocoa and ably-js actually generate `id` and `deviceSecret` when the device is fetched, i.e. not following [`RSH3a2b`](#RSH3a2b). ably-java follows [`RSH3a2b`](#RSH3a2b), allowing `id` to be `null` (this doesn't affect the public API because nullability is not a concept in Java's type system). What we *should* do is either make `LocalDevice` stop inheriting from `DeviceDetails`, or change the specified behaviour for when to generate `id` and `deviceSecret` to match our implementations, or both. See spec issues [#180](https://github.com/ably/specification/issues/180) and [#25](https://github.com/ably/specification/issues/25). For now, this note exists to reduce confusion.) - `(RSH8a)` The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of an operation involving the Activation State Machine. The `LocalDevice` `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. - - `(RSH8a1)` If the `LocalDevice` `id` or `deviceSecret` attributes are not able to be loaded then those LocalDevice details must be discarded and the ActivationStateMachine machine should transition to the `NotActivated` state. New `LocalDevice` `id` and `deviceSecret` attributes should be generated on the next activation event. + - `(RSH8a1)` If the `LocalDevice` `id` or `deviceSecret` attributes are not able to be loaded then those LocalDevice details must be discarded and the ActivationStateMachine machine should transition to the `NotActivated` state. New `LocalDevice` `id` and `deviceSecret` attributes should be generated on the next activation event. (Note: This spec point has several issues. First, it does not say to discard the `deviceIdentityToken`, but if the token survives then on the next `CalledActivate` event [`RSH3a2a`](#RSH3a2a) will attempt to use it to sync a registration against an absent or non-matching device ID, which will fail because the token was issued for the old device ID — so if the intention is for the device to perform a fresh registration (see [context](https://github.com/ably/docs/pull/1062#discussion_r607601935)), the transition to `NotActivated` alone does not achieve that. Second, it is unclear whether the instruction to "generate new ... attributes on the next activation event" is an explicit behaviour or simply a restatement of what [`RSH3a2b`](#RSH3a2b) already specifies; if the latter, then since [`RSH3a2a`](#RSH3a2a) comes before [`RSH3a2b`](#RSH3a2b), the new credentials will not have been generated by the time [`RSH3a2a`](#RSH3a2a) runs. Third, this spec point directly transitions the state machine state, bypassing the event-driven model that all other [`RSH8`](#RSH8) points use when interacting with the state machine; and it is not specified how a call to `activate()` that triggers the device load should behave when the load fails — the `CalledActivate` event is already in flight, but the spec does not say what should happen to it. None of our implementations currently implement this spec point; see [`RSH8k2`](#RSH8k2) for related discussion of how implementations diverge from the spec in this area.) - `(RSH8b)` The `LocalDevice` `id` and `deviceSecret` attributes are generated, and persisted as part of the `LocalDevice` state, when required by step [`RSH3a2b`](#RSH3a2b) in the Activation State Machine. At that time, the `clientId` attribute is also initialised, if the client is identified according to [`RSA7`](#RSA7). - `(RSH8c)` Following successful registration of a `LocalDevice`, following the procedure in [`RSH3c2a`](#RSH3c2a), the now known `deviceIdentityToken` is set and persisted. - `(RSH8d)` If the `LocalDevice` is created by an unidentified client (see [`RSA7`](#RSA7) ) and therefore has no `clientId` set, but the client subsequently becomes identified (as a result of [`RSA7b2`](#RSA7b2) or [`RSA7b3`](#RSA7b3) ), then the `LocalDevice` `clientId` is set and persisted. From 0c8343aa1d90283c3e4fff02bb5c8bfd5d5931da Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Mar 2026 14:36:08 -0300 Subject: [PATCH 5/6] Add RSH3h, RSH8a2: device detail loading and integrity checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RSH3h to require loading and verifying LocalDevice details at state machine init time, before processing any events. If the load fails, the state machine starts in NotActivated with all device state discarded (RSH3h1). Add RSH8a2 to document the atomicity requirement for the (id, deviceSecret, deviceIdentityToken) tuple, with allowance for split storage (e.g. platform keychain) provided the implementation can detect when the loaded tuple diverges from what was persisted. RSH8a2a provides a fallback for legacy data without an atomicity mechanism. Update RSH8a to reference RSH3h for the state machine init trigger. Replace RSH8a1 with a stub pointing to RSH3h. TODO: squash this branch's commits before merging, and consider removing the RSH8a1 and RSH8j replacement stubs entirely (since neither was in a released spec version). Note: this change does not take a position on whether implementations should or should not use secure-but-sometimes-unavailable storage (e.g. a platform keychain) for sensitive device details. The intent is to meet ably-cocoa where it is and not rule out this possibility in the future (customers have requested the ability to store device secrets securely [1]). We should still consider how to handle the case where such storage is temporarily unavailable (e.g. iOS Keychain before first unlock after reboot) — the current spec treats this as a load failure and discards the data, but a future enhancement could allow the state machine to wait for the storage to become available. [1] https://github.com/ably/ably-java/issues/593 Co-Authored-By: Claude Opus 4.6 (1M context) --- specifications/features.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/specifications/features.md b/specifications/features.md index 3db4d833..3b819a28 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1048,6 +1048,8 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info ### Activation State Machine - `(RSH3)` In platforms that support receiving push notifications, in order to connect the device's push features with Ably's, the library must perform the process described in the following abstract state machine. While this process should be implemented in whatever way better fits the concrete platform, it should be taken into account that its lifetime is that of the *app* that runs it, which outlives that of the `RestClient` instance or (typically) the process running the app. This typically forces some kind of on-disk storage to which the state machine's state must be persisted, so that it can be recovered later by new instances and processes running the app triggered by external events. + - `(RSH3h)` The persisted activation state is only valid if the `LocalDevice` details it depends on are available. When the state machine is initialised, it must load the `LocalDevice` details from persisted state and verify their integrity, before processing any events: + - `(RSH3h1)` If the `LocalDevice` details fail to load — either because the storage reports an error, or because [`RSH8a2`](#RSH8a2) reports an error — then all `LocalDevice` details must be discarded in memory, deleted from persistent storage where possible, and the state machine must start in the `NotActivated` state, regardless of the persisted activation state. - `(RSH3a)` State `NotActivated` (the initial one). - `(RSH3a1)` On event `CalledDeactivate`: - `(RSH3a1a)` This clause has been deleted. It was valid up to and including specification version `3.0.0`. @@ -1167,8 +1169,10 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8k)` `LocalDevice` has the following attributes: - `(RSH8k1)` `deviceIdentityToken` string? -- populated as described in [RSH8c](#RSH8c) - `(RSH8k2)` `deviceSecret` string -- populated as described in [RSH8b](#RSH8b). (Note: This property being non-nullable is not actually consistent with [`RSH3a2b`](#RSH3a2b); that spec point implies that `id` and `deviceSecret` both start off unset and are only set upon a `CalledActivate` event. However, since `deviceSecret` needs to have the same nullability as `id` --- since per [`RSH3a2b`](#RSH3a2b) either both or neither should be set --- to reflect the behaviour described in the spec we would have to make `LocalDevice#id` nullable, but this is incompatible with the superclass `DeviceDetails`. In reality, ably-cocoa and ably-js actually generate `id` and `deviceSecret` when the device is fetched, i.e. not following [`RSH3a2b`](#RSH3a2b). ably-java follows [`RSH3a2b`](#RSH3a2b), allowing `id` to be `null` (this doesn't affect the public API because nullability is not a concept in Java's type system). What we *should* do is either make `LocalDevice` stop inheriting from `DeviceDetails`, or change the specified behaviour for when to generate `id` and `deviceSecret` to match our implementations, or both. See spec issues [#180](https://github.com/ably/specification/issues/180) and [#25](https://github.com/ably/specification/issues/25). For now, this note exists to reduce confusion.) - - `(RSH8a)` The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of an operation involving the Activation State Machine. The `LocalDevice` `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. - - `(RSH8a1)` If the `LocalDevice` `id` or `deviceSecret` attributes are not able to be loaded then those LocalDevice details must be discarded and the ActivationStateMachine machine should transition to the `NotActivated` state. New `LocalDevice` `id` and `deviceSecret` attributes should be generated on the next activation event. (Note: This spec point has several issues. First, it does not say to discard the `deviceIdentityToken`, but if the token survives then on the next `CalledActivate` event [`RSH3a2a`](#RSH3a2a) will attempt to use it to sync a registration against an absent or non-matching device ID, which will fail because the token was issued for the old device ID — so if the intention is for the device to perform a fresh registration (see [context](https://github.com/ably/docs/pull/1062#discussion_r607601935)), the transition to `NotActivated` alone does not achieve that. Second, it is unclear whether the instruction to "generate new ... attributes on the next activation event" is an explicit behaviour or simply a restatement of what [`RSH3a2b`](#RSH3a2b) already specifies; if the latter, then since [`RSH3a2a`](#RSH3a2a) comes before [`RSH3a2b`](#RSH3a2b), the new credentials will not have been generated by the time [`RSH3a2a`](#RSH3a2a) runs. Third, this spec point directly transitions the state machine state, bypassing the event-driven model that all other [`RSH8`](#RSH8) points use when interacting with the state machine; and it is not specified how a call to `activate()` that triggers the device load should behave when the load fails — the `CalledActivate` event is already in flight, but the spec does not say what should happen to it. None of our implementations currently implement this spec point; see [`RSH8k2`](#RSH8k2) for related discussion of how implementations diverge from the spec in this area.) + - `(RSH8a)` The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of the Activation State Machine being initialised (see [`RSH3h`](#RSH3h)). The `LocalDevice` `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. + - `(RSH8a1)` This clause has been replaced by [`RSH3h`](#RSH3h). + - `(RSH8a2)` The `deviceSecret` and `deviceIdentityToken` are each bound to the device `id` for which they were issued and are not valid for any other device `id`. Thus, the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple must be persisted and loaded atomically. If an implementation chooses to store any of the sensitive components of this tuple (i.e. `deviceSecret` or `deviceIdentityToken`) in a more secure storage mechanism (e.g. a platform keychain) than the rest of the `LocalDevice` details, then it must do so in a way that provides a mechanism for checking that the tuple which is loaded is equal to that which was originally persisted. If upon loading the tuple this mechanism indicates that the loaded tuple diverges from that which was saved, it must be treated as a load failure per [`RSH3h1`](#RSH3h1). + - `(RSH8a2a)` Implementations that predate this spec point may have persisted data without an atomicity mechanism. When loading such data, the implementation must at minimum check the following invariants: `id` and `deviceSecret` must either both be present or both be absent, and `deviceIdentityToken` must only be present if `id` and `deviceSecret` are also present. A violation must be treated as a load failure per [`RSH3h1`](#RSH3h1). - `(RSH8b)` The `LocalDevice` `id` and `deviceSecret` attributes are generated, and persisted as part of the `LocalDevice` state, when required by step [`RSH3a2b`](#RSH3a2b) in the Activation State Machine. At that time, the `clientId` attribute is also initialised, if the client is identified according to [`RSA7`](#RSA7). - `(RSH8c)` Following successful registration of a `LocalDevice`, following the procedure in [`RSH3c2a`](#RSH3c2a), the now known `deviceIdentityToken` is set and persisted. - `(RSH8d)` If the `LocalDevice` is created by an unidentified client (see [`RSA7`](#RSA7) ) and therefore has no `clientId` set, but the client subsequently becomes identified (as a result of [`RSA7b2`](#RSA7b2) or [`RSA7b3`](#RSA7b3) ), then the `LocalDevice` `clientId` is set and persisted. From faa0fb88bd4e012ed862c20ad15dfbe288af46aa Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Mar 2026 16:41:04 -0300 Subject: [PATCH 6/6] Add ValidatingDeviceIdentityToken state and legacy data handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RSH3i (ValidatingDeviceIdentityToken state) for validating legacy ably-cocoa data where the deviceIdentityToken may not belong to the current device id. On CalledActivate, a non-mutating GET to /push/deviceRegistrations/:deviceId authenticated with the token confirms whether it matches the device id. Success re-persists with the atomicity mechanism; server rejection discards everything and transitions to NotActivated; transient errors remain in state. Add RSH8a2a with ably-cocoa-specific handling for legacy data: - RSH8a2a1: invariant checks (id/secret both present or absent, token only with id+secret) — violation is a load failure - RSH8a2a2: if invariants pass and all three present, indicate to RSH3h2 that the token needs server validation Update RSH3h2 to enter ValidatingDeviceIdentityToken when RSH8a2a indicates validation is needed. The context for this state is: ably-cocoa stores the deviceSecret in the Keychain keyed by device id, so if a secret is present it is guaranteed to belong to the current id. The deviceIdentityToken is stored under a fixed key in NSUserDefaults and may belong to a previous device id. The token is opaque to the client so we cannot verify the binding locally. We considered three options for handling this: 1. Validate the token with a non-mutating GET; if invalid, discard everything and re-register. Simple, no custom callback needed, but orphans the server-side registration. 2. Recreate the token by PATCHing with the deviceSecret; the server would return a fresh token. Preserves the registration, but the spec's custom registerCallback has no way to distinguish between POST/PUT/PATCH semantics, making it unclear whether the callback would do the right thing. 3. Abandon the token and use the deviceSecret for all subsequent authentication. Simple, but unknown consequences — the token exists for a reason and this would diverge from the spec's authentication model. We chose option 1. The trade-off (orphaning a registration) is acceptable: it's what already happens today with the bug, the orphaned registration is already unusable, and it only occurs once per device during migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- specifications/features.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/specifications/features.md b/specifications/features.md index 3b819a28..1431b22d 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1050,6 +1050,7 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH3)` In platforms that support receiving push notifications, in order to connect the device's push features with Ably's, the library must perform the process described in the following abstract state machine. While this process should be implemented in whatever way better fits the concrete platform, it should be taken into account that its lifetime is that of the *app* that runs it, which outlives that of the `RestClient` instance or (typically) the process running the app. This typically forces some kind of on-disk storage to which the state machine's state must be persisted, so that it can be recovered later by new instances and processes running the app triggered by external events. - `(RSH3h)` The persisted activation state is only valid if the `LocalDevice` details it depends on are available. When the state machine is initialised, it must load the `LocalDevice` details from persisted state and verify their integrity, before processing any events: - `(RSH3h1)` If the `LocalDevice` details fail to load — either because the storage reports an error, or because [`RSH8a2`](#RSH8a2) reports an error — then all `LocalDevice` details must be discarded in memory, deleted from persistent storage where possible, and the state machine must start in the `NotActivated` state, regardless of the persisted activation state. + - `(RSH3h2)` If [`RSH8a2a`](#RSH8a2a) indicates that the `deviceIdentityToken` needs to be validated against the server, the state machine must start in the `ValidatingDeviceIdentityToken` state, regardless of the persisted activation state. - `(RSH3a)` State `NotActivated` (the initial one). - `(RSH3a1)` On event `CalledDeactivate`: - `(RSH3a1a)` This clause has been deleted. It was valid up to and including specification version `3.0.0`. @@ -1135,6 +1136,12 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH3g3)` On event `DeregistrationFailed`: - `(RSH3g3a)` Makes `Push#deactivate` return or call its callback with the error. - `(RSH3g3b)` Transitions to the previous state, which is either `WaitingForNewPushDeviceDetails` or `AfterRegistrationSyncFailed` (so, in purity, `WaitingForDeregistration` are two separate states, one for each previous state). + - `(RSH3i)` State `ValidatingDeviceIdentityToken` (entered only when legacy ably-cocoa persisted data needs to be validated against the server; see [`RSH3h2`](#RSH3h2)). Implementations that have always had the atomicity mechanism of [`RSH8a2`](#RSH8a2) do not need this state. + - `(RSH3i1)` On event `CalledActivate`: + - `(RSH3i1a)` Makes an asynchronous HTTP GET request to `/push/deviceRegistrations/:deviceId`, authenticating with the `deviceIdentityToken` (via the `X-Ably-DeviceToken` header). This validates that the token is bound to the current device `id`. + - `(RSH3i1b)` If the request succeeds, the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple is re-persisted with the atomicity mechanism of [`RSH8a2`](#RSH8a2). Makes `Push#activate` return or call its callback with no error. Transitions to `WaitingForNewPushDeviceDetails`. + - `(RSH3i1c)` If the server rejects the request (e.g. 401 or 404), the `deviceIdentityToken` is invalid for this device. All `LocalDevice` details must be discarded in memory and deleted from persistent storage where possible. Makes `Push#activate` return or call its callback with the error. Transitions to `NotActivated`. + - `(RSH3i1d)` If the request fails due to a transient error (e.g. network failure), the state machine remains in `ValidatingDeviceIdentityToken`. - `(RSH4)` When an event is fired and a transition from the current state is not defined for such event, the event is put into a queue. Then, whenever a transition happens, an event is dequeued from the queue. If a transition from the new current state is defined for the dequeued event, such transition happens. If not, the event is put back in its place in the queue. E. g. we're `WaitingForDeregistration`, and an event `CalledActivate` happens. This event will be put in the queue, since there's no transition defined for it. Then, an event `Deregistered` arrives, causing a transition to `NotActivated`. Now we peek the next item on the queue: `CalledActivate`. Because `NotActivated` transitions on `CalledActivate`, the event is consumed and the machine transitions. - `(RSH5)` Event handling is atomic and sequential: while an event is being handled, the next one should be handled only after the current one has caused a state transition or has been put into the pending events queue. @@ -1172,7 +1179,9 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH8a)` The `LocalDevice` is initialised when first required, either as a result of a call to `RestClient#device` or `RealtimeClient#device`, or as a result of the Activation State Machine being initialised (see [`RSH3h`](#RSH3h)). The `LocalDevice` `id`, `clientId`, `deviceSecret` and `deviceIdentityToken` attributes are populated, together with any `recipient`-related attributes, to the extent that they exist, from the persisted state. - `(RSH8a1)` This clause has been replaced by [`RSH3h`](#RSH3h). - `(RSH8a2)` The `deviceSecret` and `deviceIdentityToken` are each bound to the device `id` for which they were issued and are not valid for any other device `id`. Thus, the (`id`, `deviceSecret`, `deviceIdentityToken`) tuple must be persisted and loaded atomically. If an implementation chooses to store any of the sensitive components of this tuple (i.e. `deviceSecret` or `deviceIdentityToken`) in a more secure storage mechanism (e.g. a platform keychain) than the rest of the `LocalDevice` details, then it must do so in a way that provides a mechanism for checking that the tuple which is loaded is equal to that which was originally persisted. If upon loading the tuple this mechanism indicates that the loaded tuple diverges from that which was saved, it must be treated as a load failure per [`RSH3h1`](#RSH3h1). - - `(RSH8a2a)` Implementations that predate this spec point may have persisted data without an atomicity mechanism. When loading such data, the implementation must at minimum check the following invariants: `id` and `deviceSecret` must either both be present or both be absent, and `deviceIdentityToken` must only be present if `id` and `deviceSecret` are also present. A violation must be treated as a load failure per [`RSH3h1`](#RSH3h1). + - `(RSH8a2a)` The only implementation that predates [`RSH8a2`](#RSH8a2) and for which there may exist legacy persisted data that does not provide the atomicity guarantee is ably-cocoa. It guarantees that if a `deviceSecret` is present then it belongs to the current `id`, but it does not make the same guarantee about the persisted `deviceIdentityToken`, which may have been issued for a previous device `id`. The following spec points describe how to handle legacy ably-cocoa persisted data that does not provide the atomicity guarantee. + - `(RSH8a2a1)` When loading such data, the implementation must check the following invariants: `id` and `deviceSecret` must either both be present or both be absent, and `deviceIdentityToken` must only be present if `id` and `deviceSecret` are also present. A violation must be treated as a load failure per [`RSH3h1`](#RSH3h1). + - `(RSH8a2a2)` If these invariants hold and `id`, `deviceSecret`, and `deviceIdentityToken` are all present, it must indicate to [`RSH3h2`](#RSH3h2) that the `deviceIdentityToken` needs to be validated against the server. - `(RSH8b)` The `LocalDevice` `id` and `deviceSecret` attributes are generated, and persisted as part of the `LocalDevice` state, when required by step [`RSH3a2b`](#RSH3a2b) in the Activation State Machine. At that time, the `clientId` attribute is also initialised, if the client is identified according to [`RSA7`](#RSA7). - `(RSH8c)` Following successful registration of a `LocalDevice`, following the procedure in [`RSH3c2a`](#RSH3c2a), the now known `deviceIdentityToken` is set and persisted. - `(RSH8d)` If the `LocalDevice` is created by an unidentified client (see [`RSA7`](#RSA7) ) and therefore has no `clientId` set, but the client subsequently becomes identified (as a result of [`RSA7b2`](#RSA7b2) or [`RSA7b3`](#RSA7b3) ), then the `LocalDevice` `clientId` is set and persisted.