Privacy (Engineering Doctrine)

This is the engineering-facing description of SenseBridge’s data handling. For the legal-facing version, see legal/PRIVACY_POLICY.md (informational, requires attorney review before public launch).

The core guarantee: no server, nothing to breach

SenseBridge’s MVP has no backend and no central data store. This is an architectural fact, not a policy promise — see docs/ARCHITECTURE.md. Most classic privacy attack surfaces (server breach, cross-user data leakage, third-party data brokering) simply do not exist because there is no server for them to exist on.

What happens to user content

Reading history (opt-in, off by default)

The one place this app stores user content on purpose, and the most sensitive content it ever produces — a prescription label, a bank letter, a medical result. ReadingHistoryStore exists so a listener can hear a document again without re-photographing it, and it is built so that convenience costs as little as it can:

Biometric data (facial enrollment — deferred, designed now)

Facial recognition is not in the MVP, but because getting this wrong later is expensive, the storage model is designed now:

This design is deliberately both the ethical choice and the legally safest one — biometric law (Illinois BIPA, Texas CUBI, GDPR Article 9, CCPA/CPRA among others) is a real, fast-moving exposure. See legal/PRIVACY_POLICY.md for the full legal notes. None of this is legal advice — get counsel before shipping any facial-enrollment feature.

Crash reporting (opt-in, off by default)

Added 2026-07-31 by owner decision, deliberately reversing this project’s earlier “no crash reporting, on doctrine” position. It is the only thing in SenseBridge that can send anything off the device, and it is built so that it cannot happen by accident.

Two independent gates, both of which must pass:

  1. A DSN was configured at build time (app/Config/Sentry.local.xcconfig, gitignored). A fresh clone has none, so a contributor’s build reports nowhere no matter what they tap.
  2. The user switched on Settings → Diagnostics → Send crash reports, which defaults to false and stays false through an upgrade — a settings blob written before the field existed decodes to false, because a missing key is silence, not agreement.

What is sent, when both gates pass: a crash or main-thread-hang report — stack frames, exception type and message, device model, OS version, app version. What is never sent: camera frames, recognized text, audio, depth data, location, IP address, device name, or any breadcrumb trail of what the user did. Screenshots, view hierarchies, network tracking, performance tracing, and method swizzling are disabled explicitly rather than left at their defaults, and CrashReporting.scrub strips user, request, serverName, breadcrumbs, the device name, and locale/timezone context before anything leaves. CrashReportingTests covers both the consent default and the scrub.

The exception message is not scrubbed, because it is what makes a crash diagnosable. That is only safe because the logging rule above already forbids recognized text, images, and audio from any message the app constructs. If that rule is ever relaxed, CrashReporting.scrub is where the consequence lands.

Session tracking stays on, which is what produces the crash-free-rate figure. It sends one event per launch containing no user data — and only ever after the user has opted in.

Every third party that receives anything, what they get, where it lands, and how long they keep it is listed in legal/SUBPROCESSORS.md. Adding one is a change to the privacy promise, not an implementation detail, so it updates that file and the policy in the same change.

The website

website/ is a separate surface with its own notice at /privacy, published in English, Spanish, and Vietnamese. Same posture, one step stricter: with no consent the Sentry SDK is never downloaded, because the browser bundle reaches it only through a dynamic import gated on a stored consent value (website/src/scripts/monitoring-consent.ts). A visitor who has not opted in pays 431 bytes gzipped for the consent bootstrap and fetches none of the ~27kB SDK. There is no consent banner, because nothing is stored or sent until the visitor uses the switch, so there is nothing to ask for on arrival.

A Global Privacy Control signal from the browser is honoured as a hard override: it outranks a previously stored consent, the switch is not offered, and the page says why. website/scripts/check-consent.js drives a real browser and asserts all of this against the built site, so the claim is tested rather than asserted.

Local and Cloud reasoning backends (opt-in, off by default)

Added 2026-08-11. Like crash reporting, this is deliberately one of the very few things in SenseBridge that can send anything off the device, and it is built so that it cannot happen by accident.

Two independent gates, both of which must pass, for either backend:

  1. The user explicitly switched Settings → Reasoning backend away from On-Device — the default and the fallback. Nothing leaves the device until this happens.
  2. For Cloud, the user additionally acknowledged that specific provider’s own terms of service via a real Toggle (never a bare tap target), re-shown every time they switch providers, since each provider has its own terms. For Local, the equivalent acknowledgment covers sending data to the address the user themselves entered.

What is sent, when both gates pass: recognized object labels only — PerceptionRecord.detectedObjectLabelsForNetwork() is the one function that builds the network request payload, and it carries labels and nothing else. What is never sent: camera images, audio, depth data, or location, on either backend, under any circumstances.

Where it goes:

Credentials: any API key or self-hosted token the user supplies is written only to Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly, excluded from iCloud Keychain sync and encrypted backup), never UserDefaults, never a log. Switching away from a network backend does not delete a saved credential — removal is the user’s own explicit choice, from a persistent “Remove” control in Settings, so turning a backend off and back on doesn’t force re-entering a key.

What the app does if a network backend stops responding: two consecutive failures fall back to on-device and announce it once; a later success announces recovery once. It never fails silently, and it never repeats the same announcement every cycle.

Every third party that receives anything, what they get, where it lands, and how long they keep it belongs in legal/SUBPROCESSORS.md — Anthropic, OpenAI, and NVIDIA are pending addition there and in legal/PRIVACY_POLICY.md as of this writing; a self-hosted Local endpoint is the user’s own infrastructure, not a subprocessor. legal/ edits need Kevin’s explicit approval, so this doc names the gap rather than the file silently landing out of sync with what the app does.

Secrets

The MVP has essentially no secrets: no backend, no API keys. CI (GitHub Actions) uses repository secrets only for release-signing credentials, never committed to the repository.

Sentry adds one real credential and one non-credential, and the difference matters. A DSN is a write-only ingest endpoint: it ships inside the app bundle and the browser bundle by necessity, and it cannot read anything back out of a Sentry project — it is kept out of git only so a fork does not report into this project’s issue stream. A SENTRY_AUTH_TOKEN is a genuine credential, used at build time to upload source maps; it is environment-only, never PUBLIC_-prefixed, and never committed. Both, and where to obtain each, are documented in TODO.md under “Sentry — environment variables and how to get each one”.


Need help? See SUPPORT.md.