TallyAir

Everything overhead, on the record.

Field notes · Waygate Technologies LLC

No analytics, and a build that checks

  • privacy
  • build

TallyAir ships with no analytics. No SDK, no events, no identifiers, no crash reporter, nothing that reports on what you do with the app. That was the easy half of the decision.

The hard half is that “we don’t have analytics” is a claim with a short shelf life. It is true the day you write it and it stays true only until someone adds a dependency for an unrelated reason and gets a telemetry client for free. The privacy policy would go on saying the old thing, in public, indefinitely, and nobody would have decided to lie.

So the claim is checked rather than promised.

What was verified

Three surfaces, three different ways of being sure:

  • The app has no third-party dependencies at all. Not “no analytics dependency” — none, of any kind. There is nothing in the binary that could phone anywhere.
  • The backend has no telemetry or analytics library in its dependency graph.
  • The website loads zero third-party resources. Fonts are self-hosted out of the repository rather than pulled from a font CDN, which is the usual way a “no third parties” site quietly acquires one.

What is enforced

The website half now fails its own build if that stops being true. pnpm build runs a check that walks the built output looking for anything the browser would fetch from another origin — script and link tags, stylesheets, images, url() in CSS, @import — and exits non-zero on any host that is not on a short allowlist.

It deliberately does not simply grep for absolute URLs. Canonical tags, og:url, sitemap entries and JSON-LD @context values are all absolute URLs that no browser ever requests, and a check that cried wolf about those would be turned off inside a week. Only constructs that actually pull bytes count.

The allowlist has one entry, Buttondown, which is where the waitlist form posts. That request happens when you submit the form and at no other time — the page does not contact it on load.

Adding anything to that list is a deliberate act, in the same commit as the privacy policy paragraph it would falsify. That is the entire point: the check does not prevent adding a third party. It prevents adding one silently.

The one identifier that changed

The audit that came out of this also turned up something worth fixing. Push registration was sending Apple’s identifierForVendor to the server as an installation id.

It was doing a small, legitimate job: when iOS rotates a push token, the server needs to know the new token belongs to the same install so it can replace the stale row instead of accumulating one per rotation. The server only ever compares the value for equality. It never parses it, joins on it, or correlates it with anything.

But identifierForVendor is shared across every app published under the same developer account. For a job that needs nothing more than “a stable random value, unique to this install”, we were sending an identifier that can link a device across a publisher’s whole catalogue — and that has to be declared as a device identifier on the App Store label.

A UUID generated on first launch and kept in the Keychain does the identical job. It is not shared with any other app, not derivable from the device, and a fresh install produces a fresh value. The server needed no change at all: the column was already an opaque nullable string.

That is the shape of most privacy work, in the end. Not a dramatic refusal to collect something, just noticing that the thing you reached for by default carries more than the job requires.

← All field notes