ADR-0020: A client sends nothing the privacy policy does not name

  • Status: Accepted
  • Date: 2026-09-11
  • Scope: core (every client: web SPAs and mobile)

Context

A privacy policy lists what is collected and who receives it. Some of those flows never appear in
server code, because the client causes them on the user’s behalf. They are the easy ones to miss:

  • A third-party asset is a disclosure. When a web font loads from Google Fonts, every visitor’s
    browser sends its IP address, user agent and referrer to Google on every page load. That makes
    Google a recipient the policy would have to name, and nothing in the application code shows it.
    It is one CSS @import.
  • A photo carries more than its pixels. A phone camera writes EXIF into every photo, which
    usually includes the GPS position where it was taken, the device model and the exact time. The
    EXIF survives when the photo is uploaded from a browser as well as from the app. Uploading the
    original file hands all of it to the server, and to any model the image is forwarded to.
  • A native permission can arrive without anyone asking for it. On Android, a library’s manifest
    merge can add permissions the app never uses (storage, media, microphone). They appear on the
    store listing, and may have to be disclosed.

Expenses found all three while writing its first Privacy Policy. Both SPAs loaded their fonts from
Google. The web and mobile assistants both uploaded images, whether photographed or picked, with
their EXIF intact. The Android manifest carried storage, media and audio permissions that nothing
used.

Decision

A client makes no request, and sends no data, that the privacy policy does not name. When a
client flow would need a new disclosure, first ask whether the flow is needed. Removing a flow is
usually cheaper than disclosing it.

  1. Serve static assets from the app’s own origin. Bundle fonts, icons, stylesheets and scripts
    (for fonts, import @fontsource / @fontsource-variable packages in the entry file). Never
    hot-link them from a third-party CDN. The browser may contact a third-party origin directly only
    when the policy names it and a feature the user triggers needs it, for example a postcode
    lookup. Design-system reference files often ship with a Google Fonts @import; do not port that
    line into the app.

  2. Strip image metadata on the device before upload, and fail closed. Every path that uploads
    an image re-encodes it first, on every client. Decoding to pixels and encoding again is the
    reliable way to drop every metadata block. Two cases refuse the image and never send it as it
    is:

    • the re-encode fails;
    • the image is a type the client cannot re-encode, such as a GIF or a TIFF.

    Never fall back to the original file, since that is the one carrying the location. Files that
    are not images (a PDF, a CSV) pass through unchanged. Apply size limits to the re-encoded file,
    because that is what gets sent.

  3. Block the permissions the app does not use. Declare unused sensitive permissions as blocked
    in the app config, so a dependency cannot add them silently, and turn off library options that
    request permissions for features the app does not have.

Consequences

  • The policy’s lists of recipients and data now describe the clients as well as the server. Every
    new client dependency gets the same check: does it contact a new origin, or send something new?
  • Self-hosted fonts add a few hundred kilobytes to the bundle and are cached with it. No
    third-party request happens on first paint.
  • Re-encoding costs some image quality and some time on the device. Doing it once, at the upload’s
    own quality setting, keeps it from being a second lossy step.
  • If an image fails to re-encode, or is of a type the client cannot re-encode, the user cannot
    attach it. That is accepted: sending it anyway would break the promise.
  • Nothing checks any of this mechanically. A new @import, upload path or native module can
    reintroduce a leak without failing anything. Review, including handbook:ask-legal, is the
    control.
  • 0013-centralized-api-layer-and-server-state — first-party requests go through one API layer;
    this ADR covers the requests that do not
  • How Expenses applies it: @fontsource-variable in both SPAs, a src/utils/stripImageMetadata.ts
    in both expenses-web and expenses-app, and android.blockedPermissions in the mobile
    app.json.
  • Review: handbook:ask-legal