Our privacy policy previously contained this paragraph.

Tact operates a small server that delivers Apple push notifications to your devices. Tact apps send messages to this server. To know who to deliver notifications to, we store membership info of chats. All Tact users are identified with opaque salted+hashed iCloud user identifiers (long string of numbers and letters, not your e-mail, phone or any other private info). We have no way of connecting these identifiers to actual users. The notification server does not store any message content: it only transforms the content in memory, and then sends it to Apple push notification servers.

TL;DR: This paragraph, and the mentioned server whose internal name was tact-notifier, are now gone. The privacy of Tact is improved because we no longer have to move any message content through our systems, and we no longer have to know who to deliver notifications to. There is no longer any server component to Tact. Everything travels directly from your device to iCloud, and from iCloud to your device.

Let’s take a step back and look at how CloudKit notifications work, how we use them in Tact, and what role tact-notifier played in all of this.

Notifications in messaging apps

How does a messaging app know whether it should deliver a notification to you when someone sends a message?

What a silly question, you might say. When you are in a chat with someone, and they post something, you expect to see a notification on your phone.

… except if you have muted the chat.

… but maybe you have still chosen to receive notifications when you are mentioned, even if the chat is muted.

… but you already read the message on another device, so you expect no notification.

It gets more complicated than you initially thought. Here is a well-known diagram that Slack shared a few years ago, about how Slack decides whether to deliver a notification to a user.

Slack notification decision diagram

This is meant just as an illustration, and does not claim to be a complete or accurate description of how notifications are delivered in Slack. And this was several years ago, and Slack has evolved since then, so this might no longer be accurate.

I haven’t made this diagram for Tact, but the essence is the same. There are many factors that go into deciding whether a notification should be delivered.

Notification capabilities of CloudKit and the Apple platform

You could say that the above was the “requirements” for a notification system from the user perspective. Let’s look at what CloudKit and the Apple platform more broadly offer to meet these requirements.

CloudKit’s interface to notifications is CKNotification and its concrete subclasses, most interestingly CKQueryNotification, CKRecordZoneNotification, and CKDatabaseNotification. You create subscriptions to these types of notifications using the matching subscription classes, CKQuerySubscription, CKRecordZoneSubscription, and CKDatabaseSubscription. Whenever there is a data change in CloudKit that matches a subscription criteria, CloudKit will post a notification to your app.

One thing you’ll notice is the discrepancy of subscription availability between CloudKit database types. CKQuerySubscription is only available for public and private databases, and CKDatabaseSubscription is only available for private and shared databases. Tact uses all three databases, and chats live in both private and shared databases. I wanted to have the same kind of interface for chats living in both shared and private databases, and the only common denominator that I can use between them is the CKDatabaseSubscription which basically tells me “something changed in this database”. CKRecordZoneSubscription is also available in both private and shared databases, but I haven’t used it in Tact.

One other Apple platform tool I will throw into the mix is UNNotificationServiceExtension. This is a tool that you can use to transform received user-visible push notifications on the device. You can imagine a system that receives a CloudKit notification, downloads the changed data from CloudKit, and uses the notification service extension API to transform the notification based on the data received from CloudKit.

The one wrench in this system is that UNNotificationServiceExtension must always return a user-visible notification. This is not desirable if you received a message in a chat that you muted, and don’t actually wish to display anything to the user. Apple sort of imagined this scenario, and it offers a way to suppress notifications in the service extension. But you must apply for an entitlement to do this. I applied months ago with Tact, and have not heard back, and I don’t think I ever will.

Client vs server notifications

How does a notification end up being displayed on the user’s device? Some part of the system must originate the push and generate the user-visible notification. But note that these two things do not need to be coupled. There are at least two ways to end up with a displayed notification:

  1. The server runs the logic to determine whether a notification should be shown, and if so, generates and sends a visible push notification.
  2. The server sends a silent push notification. The client runs the logic to determine whether a notification should be shown, and if so, locally generates and displays a visible notification to the user.

If you have even a marginally complex data model in CloudKit, there is no way to run the logic on CloudKit to determine whether to send a notification, especially in shared databases. (You may be able to do it in a private database if you can construct a CKQuery that captures all the logic.) It would be cool if there was a way to run this logic on CloudKit with something like a cloud function that you write, but this does not exist.

tact-notifier was born as a stopgap to allow us to run the notification logic on the server side. It kept track of user chat memberships and device tokens, and also user’s state (which users had muted which chats). When you sent a message with Tact, the client actually sent it to two places: CloudKit for persistence, and tact-notifier for notification delivery. tact-notifier examined the state of each user that was in the chat where the message was sent, and generated the push notifications for the users that needed them. It also ran slightly similar logic for reactions. (For messages, you want everyone but the sender to receive a notification. For reactions, you want only the sender of the message being reacted to, to receive a notification about it.)

Downsides of tact-notifier

What was so bad about tact-notifier, and why am I happy that it’s gone now? Couldn’t I keep running it?

There were several downsides to tact-notifier.

It broke the privacy promise. Tact says that we as the Tact team don’t have any access to user data, and everything moves directly between your device and iCloud. tact-notifer broke this promise, as your private data traveled through our server. Sure, we didn’t look at it, but just the fact that the data moved through our system at all, always made me uncomfortable. I am glad this is no longer the case.

It was another thing to operate and maintain. The overhead wasn’t too much, but it was still something that had to be done. It feels much better now to have a system that literally has no server components for Tact’s core messaging functions which we would have to maintain.

Correctness was not guaranteed. Chat membership info in tact-notifier was relying on API calls consistently arriving from clients. This was mostly the case, but there was no hard technical guarantee that tact-notifier had the same view of chat memberships as CloudKit and the clients did. There is no CloudKit server API to private or shared CloudKit databases, so tact-notifier can’t consult the authoritative source of truth.

The API was insecure. Let me say upfront that there were no privacy risks and ways to exfiltrate info from tact-notifier, since all the client-facing API calls were write-only and didn’t return any information. But the API was still insecure and unauthenticated. If you had sniffed the protocol, you would have figured out how to send rogue notifications as yourself or other people to chats where you are a member, of even to other chats if you got lucky and somehow guessed the chat UUIDs. The only reason why we had no security incidents around this is that Tact is a small and obscure app and no one bothered to target it. This was not sustainable in the long run, and I always thought one of two things will need to happen: either we shut down the server (as we now did), or we properly secure the API.

How would you secure an API like this? One way would be to authenticate all the user requests with signatures. The system could generate private keys for all users which are kept in their private CloudKit databases, and publish the corresponding public key to public CloudKit database, which tact-notifier could retrieve and use to verify the requests signed by the users. This would have been additional complexity in the system, and I’m glad I didn’t have to implement it.

A new direction with silent notifications

So this is where I was earlier in 2024. I needed to run the logic somewhere either on client or server side for delivering the notifications. There is no way to run it in CloudKit. I could have run it in the client with a notification service extension, but I didn’t have the entitlement to mute unwanted notifications. tact-notifier was insecure and unsustainable. What to do?

I had a lab with friendly Apple CloudKit engineers during WWDC 2024 where I discussed all this, and asked what they would recommend. They suggested an avenue that I sort of knew about, but had previously dismissed: why not send silent notifications from CloudKit, and generate the user-visible notifications on the client if needed, based on the user’s state that is fully available on the client?

Architecturally, this is neat and fits into the Tact vision, where things travel directly between the user’s device and iCloud. Having full state available on the client means that the app can reliably compute and present the notifications.

The one wart in this approach is that Apple does not guarantee reliable silent background notification delivery. Their documentation has this quite ominous warning:

The system treats background notifications as low priority: you can use them to refresh your app’s content, but the system doesn’t guarantee their delivery. In addition, the system may throttle the delivery of background notifications if the total number becomes excessive. The number of background notifications allowed by the system depends on current conditions, but don’t try to send more than two or three per hour.

I still coded it up and we tested it in Tact for a while. Not least because the Apple engineers in the lab said that several Apple apps use the same approach. (But they asked me to not quote them on that, so I won’t, this is a loose paraphrase.)

The good news is that background silent notification delivery is one of the rare areas where Apple is underpromising and overdelivering. Background silent notifications arrive quite fast and reliably and aren’t really throttled to just a few per hour. The delivery is not 100%, but it’s still quite reliable.

So, this is what Tact does now. It subscribes to silent CloudKit database change notifications. When those arrive, it downloads new data from CloudKit for the given database, and when there are new messages or reactions for the current user that match the delivery criteria (e.g the chat is not muted), the client generates local visible notifications.

Shutting down tact-notifier

Here is a bonus video to end this post. How I shut down tact-notifier.