I still haven’t fully figured out the conflict resolution considerations, but had another thought about the fallback logic for syncing with old clients.
Note that if we transfer the local_cipher_text value to the body field on the server item with the JOPLIN_CIPHER: prefix, when E2EE is enabled, then double encryption presents an issue. If you upload the item to the server double encrypted, when you download item, it will only be decrypted by the EncryptionService after it is stored to the database, which means we cannot save the value directly to the local_cipher_text value in the db, otherwise the EncryptionService will not decrypt it.
In order to work around this, my suggestion would be for the case when is_locally_encrypted is true, in addition to adding the prefix to the body of the server item upon serialising, also skip encrypting the body value via the EncryptionService, so that the body (comprising of the prefix + local_encryption_cipher) is not double encrypted. Then when deserialising the body field of the server item, check if is_locally_encrypted is present on the body. If it is defined and set to true, or if it is undefined but the first 14 characters of the body match JOPLIN_CIPHER:, then set is_locally_encrypted to true on the deserialised item, set the body text after the first 14 characters to local_cipher_text, and set the body field to an empty string which has been encrypted by the EncryptionService (to avoid issues when decryption occurs). Then when the object reaches ItemClass.save in the synchronizer, it will be in exactly the state it needs to be. This should be the only logic required for preventing double encryption by E2EE, so no need for additional changes to Note save / load specifically for preventing it. Note that the purpose of having the is_locally_encrypted field on the server item (which should be a field which is excluded from encryption on serialise) is to avoid relying on the hacky prefix check once all clients have been updated