Free/Busy Is Enough
- privacy
- product
- consent
- craft
The commit message opens with a rule. The product must never read, display, or store the title, organizer, or attendees of a user's calendar events. Then the commit goes on to fix the places where it did.
The schema had a column for the meeting title. The conflict screen showed it. That was not the plan. An events query is the obvious way to find a conflict, and an events query returns titles.
SalahSync books the five daily prayers into Google Calendar, in 15-minute blocks inside the prescribed windows. A partner and I built it. A calendar is where a week actually lives: the clinic visit, the interview, the meeting that nobody wants on a feed. A prayer app that reads titles knows when you pray and what you would rather not say.
Asking for a scope and not misusing it
The easy answer to this is a promise. Request the calendar permission, read what is convenient, and say in the privacy policy that you will be careful.
I do not trust that frame, and I did not trust it on our own product. A permission is a capability for the whole class of data. A promise not to use it is a policy, and policies drift. We had just found ours doing it: the privacy policy said one thing and the query returned another.
The calendar API gives you a different tool. A free/busy query returns time ranges and nothing else. No title, no organizer, no attendees, no event id. It cannot return what we should never hold, so we never have to be good about not holding it.
So conflict detection moved to free/busy. The conflict screen now says "Busy" with a time range and nothing more. The migration nulled every stored title and organizer, then dropped the columns, and keyed each conflict on its interval, because a free/busy answer has no event id to key on.
One wrinkle the API handed us
Free/busy reports our own prayer events as busy too. A prayer block would flag itself as a conflict. The fix was a tolerance window, matching busy intervals against the times we ourselves had scheduled and ignoring those.
That detail is a small one, and it is the sort of thing that goes wrong when you remove information. The data is thinner. Every place that leaned on the missing field has to be found and reconsidered, or the product quietly gets worse in a way nobody can name.
Then the richer path, behind one switch
Reading titles does make the product better in one specific place. When a meeting collides with a prayer, a good suggestion needs to know whether it is a one-person focus block or a ten-person review. Free/busy cannot say. And on the travel side, knowing a user is somewhere else needs a location.
So we built that, and we built it behind exactly one consent. A single toggle on the user record, off by default, governs both the conflict suggestions and the travel detection. Not two independent flags. One question gets one answer, so a user always knows what they agreed to.
The dialog lists what is read: event titles, attendee count, whether someone else organized it, and locations only to detect travel. Enabling it stores a timestamp and the version of the consent copy the person agreed to. The consent check fails closed. A missing row, a database error, a thrown exception all count as no. The result is cached for at most sixty seconds, and the cache is cleared the moment a user changes the setting.
Turning it off is a purge, not a flag flip. Pending travel suggestions are deleted. Stored suggestions on conflicts are nulled. The decision records derived from consented content are deleted. A later commit added the last piece: trips the user had already accepted are reverted, and their prayers rescheduled back to home times.
One file may read
A switch that code is free to ignore protects nobody. So exactly one file in the backend is allowed to read event content for events we did not create. Every function it exports asserts consent before it touches the calendar API. The fields it asks for are fixed per feature, and the travel feature's list has no title in it. A caller cannot pass its own list. The time window is capped, one day for conflict suggestions and fifteen for travel.
A security test enforces all of it by scanning the source. It finds every call that lists events and checks that each is scoped to our own events, with that one file the only exception. It checks that no caller passes a field list. It checks that conflict detection never touches a summary or an attendee.
It is the dullest test in the repo and I think the most important. A future change can break the promise by accident, and the test will say so before anyone ships it.
The default got dumber, and that was the price
The cost was smaller than this story makes it sound, and it was not zero.
The default path got dumber. We had a meeting-importance score built on attendees and title keywords. It went, because that data was never derivable from free/busy to begin with. What is left is the length of the busy block. The richer suggestions are for paid tiers who opt in. Everyone else sees a busy block and a time range.
Google's verification asked for a written justification for each sensitive scope. We rewrote ours so that it claimed only what the code did. In the process we found it had described the product as an add-on, which it is not. A document that has to survive a reviewer makes you read your own system.
And the guarantee has a soft spot, which I will name. Our event scope can technically list anything. The free/busy path cannot leak a title by construction. The rest rests on a test, not on Google. A test behind the promise is far better than the promise alone, but the guarantee still rests on the test.
Take the least the job needs, and put the rest behind a consent the product can prove it honored.

