From afc9120178fd7d352aa15b4552760919c57288aa Mon Sep 17 00:00:00 2001 From: MP2EZ <182439403+MP2EZ@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:07:05 -0700 Subject: [PATCH] chore: INFRA-295 correct Sentry release-health session option + crash attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-scoped after a planning panel falsified the story's premise. The item assumed release-health sessions were deliberately disabled and needed enabling. They were not disabled. 1. The option key was dead. `ExternalErrorReporter` passed `autoSessionTracking: false`, but @sentry/react-native reads `enableAutoSessionTracking`. The key we set does not exist in the SDK, is forwarded verbatim to the native layer, and is ignored there — and both native SDKs default session tracking ON. So the "privacy-first, error-only" posture the code comment claimed was never in effect: sessions have most likely been transmitting from every non-__DEV__ build, including the 2026-06-15 TestFlight build. Corrected to the real option and kept ON intentionally. TypeScript cannot catch this class of bug — the RN options type carries an index signature. 2. Crash attribution was broken. `applyAllowlist` rebuilt exception.values as {type, value, stacktrace}, dropping `mechanism`; `level` was absent from ALLOWED_ERROR_FIELDS. The SDK's isHardCrash() requires mechanism.handled === false && mechanism.type === 'onerror', and beforeSend runs BEFORE envelope creation — so no JS fatal ever marked its session crashed. A crash-free-session-rate alert would have counted native crashes only and read systematically optimistic, under-firing exactly when it matters. Now preserved as explicitly type-guarded named scalars — never a spread, so the SDK's open `mechanism.data` bag cannot ride along — plus an enum guard on `level` so it can never become a free-text channel. 3. Contract test imports the SDK's REAL isHardCrash, so an upstream change to the crash-attribution rule fails the build rather than silently zeroing the metric. Runs in precommit via test:privacy. 4. Docs reconciled to reality: privacy-policy §5.1 disclosed session diagnostics and the per-install identifier (it previously promised "no device identifier", which the session `did` contradicts); DPIA v1.7 records the retrospective finding (not a breach — no wellness content, and Sentry was already an authorized sub-processor); runbook §1 corrected — its "no data source today" warning was wrong, and the interim Number-of-Errors alert it said would be superseded was never created (being-prod has zero metric alerts). Session envelopes bypass beforeSend/normalizeDepth entirely (envelope session items, not events), so the controls the original AC named never applied to them; the review was done against the fixed session schema instead. Compliance agent verdict: privacy-neutral — mechanism.type/handled/synthetic and level are all closed-vocabulary SDK-controlled values, and the app never constructs a mechanism anywhere. NOT claimed by this change (require the Sentry dashboard + a new TestFlight build; the Sentry MCP is read-only for alert rules): creating the crash-free- session-rate metric alert, and verifying Releases -> Health. Recorded as an operator checklist on the Notion item. Verification: npm run precommit exit 0 — safety 108, clinical 106, unit 568, privacy 51 tests passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- .../releaseHealthSession.contract.test.ts | 269 ++++++++++++++++++ .../services/logging/ExternalErrorReporter.ts | 71 ++++- .../post-launch-monitoring-runbook.md | 44 ++- docs/legal/dpia-sensitive-wellness-data.md | 9 +- docs/legal/privacy-policy.md | 2 +- 5 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 app/__tests__/privacy/releaseHealthSession.contract.test.ts diff --git a/app/__tests__/privacy/releaseHealthSession.contract.test.ts b/app/__tests__/privacy/releaseHealthSession.contract.test.ts new file mode 100644 index 00000000..0e286df6 --- /dev/null +++ b/app/__tests__/privacy/releaseHealthSession.contract.test.ts @@ -0,0 +1,269 @@ +/** + * PRIVACY + CORRECTNESS CONTRACT — INFRA-295 Sentry release-health sessions. + * + * TWO facts this file exists to pin, both of which were wrong before INFRA-295: + * + * 1. THE OPTION KEY. `ExternalErrorReporter.initialize()` used to pass + * `autoSessionTracking: false`. That key does not exist in + * @sentry/react-native — the SDK reads `enableAutoSessionTracking`, and + * unrecognized keys are forwarded to the native layer, which applies its own + * default of ON. So the option we believed was disabling release-health + * sessions had no effect whatsoever, and sessions were most likely already + * being transmitted from every non-__DEV__ build. A silent no-op like this is + * invisible to TypeScript (the RN options type carries an index signature), + * so it needs a test. + * + * 2. CRASH ATTRIBUTION. `applyAllowlist` rebuilt `exception.values` as + * `{type, value, stacktrace}` and dropped `mechanism`. The SDK decides whether + * a JS error counts as a hard crash in `isHardCrash()`, which requires + * `mechanism.handled === false && mechanism.type === 'onerror'` — and + * `beforeSend` runs BEFORE envelope creation. Net effect: a JS fatal error + * never marked its session crashed, so a crash-free-session-rate signal would + * have counted native crashes only and read systematically optimistic. An + * alert built on that would under-fire exactly when it mattered. + * + * We import the SDK's REAL `isHardCrash` rather than re-implementing it, so this + * contract also fails if a future @sentry/react-native bump changes the + * crash-attribution rule out from under us. + * + * Sessions themselves are NOT covered by beforeSend: a session is an envelope + * session item, not an event, so `beforeSend` / `normalizeDepth` / the FEAT-284 + * `addEventProcessor` never see one. The session payload's privacy posture is + * therefore a schema argument, documented in the DPIA — not something this file + * can assert. What this file CAN assert is that the error path feeding session + * crash-attribution stays both correct and scrubbed. + * + * Runs in `npm run precommit` via `test:privacy`. + * + * @see docs/legal/dpia-sensitive-wellness-data.md §2 (Sentry) + * @see docs/development/post-launch-monitoring-runbook.md §1 + */ + +import { isHardCrash } from '@sentry/react-native/dist/js/misc'; + +const mockInit = jest.fn(); +const mockAddEventProcessor = jest.fn(); + +jest.mock('@sentry/react-native', () => ({ + init: (...args: unknown[]) => mockInit(...args), + addEventProcessor: (...args: unknown[]) => mockAddEventProcessor(...args), + feedbackIntegration: jest.fn(() => ({ name: 'Feedback' })), +})); + +import { ExternalErrorReporter } from '@/core/services/logging/ExternalErrorReporter'; + +const TEST_DSN = 'https://examplePublicKey@o0.ingest.sentry.io/0'; + +/** Fresh singleton + initialize, returning the options object actually passed to Sentry.init. */ +async function initAndCaptureOptions(): Promise { + (ExternalErrorReporter as any).instance = undefined; + mockInit.mockClear(); + const reporter = ExternalErrorReporter.getInstance(); + await reporter.initialize(TEST_DSN); + expect(mockInit).toHaveBeenCalledTimes(1); + return mockInit.mock.calls[0][0]; +} + +/** + * An unhandled JS error shaped the way the SDK's global handler emits one. + * + * Deliberately carries identifiers (which must be STRIPPED) but NOT wellness + * markers — an event containing those is dropped wholesale by + * `containsCrisisContent`, which is a different and stronger guarantee, pinned + * separately below. Mixing the two into one fixture would mean this event never + * reaches the allowlist at all, and the crash-attribution assertions would be + * vacuous. + */ +function makeUnhandledErrorEvent(): any { + return { + level: 'fatal', + message: 'Unhandled TypeError', + environment: 'production', + platform: 'ios', + user: { id: 'anon-uid-123', email: 'leaked@example.com', ip_address: '1.2.3.4' }, + extra: { + lastRoute: 'CleanHomeScreen', + note: 'I have been feeling low all week', + }, + exception: { + values: [ + { + type: 'TypeError', + value: 'undefined is not a function', + mechanism: { type: 'onerror', handled: false, synthetic: false }, + stacktrace: { + frames: [ + { + filename: '/Users/max/dev/being/app/src/features/home/CleanHomeScreen.tsx', + function: 'render', + lineno: 42, + colno: 7, + }, + ], + }, + }, + ], + }, + }; +} + +describe('INFRA-295 — Sentry init options', () => { + it('passes the real SDK option `enableAutoSessionTracking: true`', async () => { + const options = await initAndCaptureOptions(); + expect(options.enableAutoSessionTracking).toBe(true); + }); + + it('no longer passes the dead `autoSessionTracking` key', async () => { + // Regression guard: this key is silently ignored by the SDK and forwarded to + // native. Its presence means someone reintroduced a no-op believing it did + // something. TypeScript cannot catch this — the options type is indexed. + const options = await initAndCaptureOptions(); + expect(options).not.toHaveProperty('autoSessionTracking'); + }); + + it('leaves performance tracing OFF (that is INFRA-297 scope, deliberately deferred)', async () => { + const options = await initAndCaptureOptions(); + expect(options.enableAutoPerformanceTracing).toBe(false); + expect(options.tracesSampleRate).toBeUndefined(); + }); + + it('keeps the privacy hooks wired', async () => { + const options = await initAndCaptureOptions(); + expect(typeof options.beforeSend).toBe('function'); + expect(typeof options.beforeBreadcrumb).toBe('function'); + expect(options.normalizeDepth).toBe(3); + }); +}); + +describe('INFRA-295 — crash attribution survives the scrub', () => { + it('an unhandled error still satisfies the SDK’s real isHardCrash() after beforeSend', async () => { + const options = await initAndCaptureOptions(); + const scrubbed = options.beforeSend(makeUnhandledErrorEvent()); + + expect(scrubbed).not.toBeNull(); + // The whole point: without `mechanism` surviving, this is false and every JS + // fatal is invisible to crash-free-session-rate. + expect(isHardCrash(scrubbed)).toBe(true); + }); + + it('preserves exactly the non-PII mechanism scalars', async () => { + const options = await initAndCaptureOptions(); + const scrubbed = options.beforeSend(makeUnhandledErrorEvent()); + const mechanism = scrubbed.exception.values[0].mechanism; + + expect(mechanism).toEqual({ type: 'onerror', handled: false, synthetic: false }); + }); + + it('preserves `level`, which distinguishes fatal from non-fatal', async () => { + const options = await initAndCaptureOptions(); + const scrubbed = options.beforeSend(makeUnhandledErrorEvent()); + + expect(scrubbed.level).toBe('fatal'); + }); + + it('a handled error is NOT reported as a hard crash', async () => { + const options = await initAndCaptureOptions(); + const event = makeUnhandledErrorEvent(); + event.exception.values[0].mechanism = { type: 'generic', handled: true, synthetic: false }; + + const scrubbed = options.beforeSend(event); + expect(isHardCrash(scrubbed)).toBe(false); + }); +}); + +describe('INFRA-295 — the scrub is still a scrub', () => { + it('strips identifiers and wellness data from the same event that keeps its mechanism', async () => { + const options = await initAndCaptureOptions(); + const scrubbed = options.beforeSend(makeUnhandledErrorEvent()); + const serialized = JSON.stringify(scrubbed); + + // Identity + expect(scrubbed).not.toHaveProperty('user'); + expect(serialized).not.toContain('leaked@example.com'); + expect(serialized).not.toContain('anon-uid-123'); + expect(serialized).not.toContain('1.2.3.4'); + + // Free-text context — the allowlist drops `extra` wholesale. + expect(scrubbed).not.toHaveProperty('extra'); + expect(serialized).not.toContain('feeling low'); + }); + + it('still drops an event carrying wellness markers ENTIRELY, rather than scrubbing it', async () => { + // This is the stronger, pre-existing guarantee and it must not regress: + // `containsCrisisContent` hard-drops the event before the allowlist runs. + // Pinned here because INFRA-295 touches the same path, and a mistake that + // turned a drop into a scrub would look like a passing test elsewhere. + const options = await initAndCaptureOptions(); + const event = makeUnhandledErrorEvent(); + event.extra = { phq9Score: 21, gad7Score: 18 }; + + expect(options.beforeSend(event)).toBeNull(); + }); + + it('does not let mechanism become a smuggling channel for extra keys', async () => { + const options = await initAndCaptureOptions(); + const event = makeUnhandledErrorEvent(); + event.exception.values[0].mechanism = { + type: 'onerror', + handled: false, + synthetic: false, + // A future SDK (or a caller) could attach richer context here. The + // allowlist must copy named scalars, never spread the object. + // NB: deliberately NOT a wellness marker — those are dropped wholesale by + // containsCrisisContent (pinned above), which would make this vacuous. + data: { url: 'https://being.fyi/u/anon-uid-123', route: 'CleanHomeScreen' }, + }; + + const scrubbed = options.beforeSend(event); + const mechanism = scrubbed.exception.values[0].mechanism; + + expect(mechanism).not.toHaveProperty('data'); + expect(JSON.stringify(scrubbed)).not.toContain('anon-uid-123'); + }); + + it('drops every non-allowlisted mechanism key, not just `data`', async () => { + const options = await initAndCaptureOptions(); + const event = makeUnhandledErrorEvent(); + event.exception.values[0].mechanism = { + type: 'onerror', + handled: false, + synthetic: false, + source: 'cause', + exception_id: 7, + parent_id: 3, + }; + + const mechanism = options.beforeSend(event).exception.values[0].mechanism; + expect(mechanism).toEqual({ type: 'onerror', handled: false, synthetic: false }); + }); + + it('falls back to a safe mechanism.type when it is not a string', async () => { + const options = await initAndCaptureOptions(); + const event = makeUnhandledErrorEvent(); + event.exception.values[0].mechanism = { type: { evil: 'object' }, handled: false }; + + const mechanism = options.beforeSend(event).exception.values[0].mechanism; + expect(mechanism.type).toBe('generic'); + }); + + it('drops a `level` outside Sentry’s closed enum', async () => { + // Guards a future foot-gun: captureMessage(msg, someVariable) putting + // free text where an enum belongs. + const options = await initAndCaptureOptions(); + const event = makeUnhandledErrorEvent(); + event.level = 'user said: I have been feeling low all week'; + + const scrubbed = options.beforeSend(event); + expect(scrubbed).not.toHaveProperty('level'); + expect(JSON.stringify(scrubbed)).not.toContain('feeling low'); + }); + + it('keeps every legitimate enum level', async () => { + const options = await initAndCaptureOptions(); + for (const level of ['fatal', 'error', 'warning', 'log', 'info', 'debug']) { + const event = makeUnhandledErrorEvent(); + event.level = level; + expect(options.beforeSend(event).level).toBe(level); + } + }); +}); diff --git a/app/src/core/services/logging/ExternalErrorReporter.ts b/app/src/core/services/logging/ExternalErrorReporter.ts index 3b90d6cb..1c069b42 100644 --- a/app/src/core/services/logging/ExternalErrorReporter.ts +++ b/app/src/core/services/logging/ExternalErrorReporter.ts @@ -53,6 +53,9 @@ const ALLOWED_ERROR_FIELDS = [ 'message', // Sanitized message only 'name', 'errorCode', + 'level', // INFRA-295: fatal vs error — no user content, and dropping + // it flattens every crash to the same severity in Sentry. + // Validated against ALLOWED_LEVELS in applyAllowlist. // Context (no sensitive data) 'platform', @@ -78,6 +81,12 @@ const ALLOWED_ERROR_FIELDS = [ 'memoryUsage', ] as const; +/** + * INFRA-295: Sentry's `level` is a closed enum. Anything outside it is dropped + * rather than forwarded, so the field can never become a free-text channel. + */ +const ALLOWED_LEVELS = ['fatal', 'error', 'warning', 'log', 'info', 'debug'] as const; + /** * BLOCKLIST: Fields that must NEVER be sent externally * Defense-in-depth: blocked even if somehow bypasses allowlist @@ -332,8 +341,30 @@ export class ExternalErrorReporter { }), ], - // Disable features that could leak sensitive data - autoSessionTracking: false, + // INFRA-295 — release-health session tracking. + // + // MIND THE OPTION NAME. The SDK reads `enableAutoSessionTracking`. + // The `autoSessionTracking` key this file used to pass does not + // exist in @sentry/react-native: unrecognized keys are forwarded + // verbatim to the native layer, which ignores it and applies its own + // default of ON. So the "sessions are disabled" posture the previous + // comment claimed was never real — sessions have been transmitting + // from every non-__DEV__ build. This makes the actual behaviour + // explicit and intentional rather than accidental. Dev/sim still + // no-ops because the DSN is empty there, so no env gate is needed. + // + // A session envelope is NOT an event: it never passes through + // beforeSend, normalizeDepth, or the FEAT-284 event processor. Its + // schema is fixed (sid/did/started/duration/status/errors + release + // and environment attrs) and has no field for user content, so there + // is no wellness-data path — but `did` is a per-install identifier. + // See docs/legal/dpia-sensitive-wellness-data.md and privacy-policy §5.1. + enableAutoSessionTracking: true, + + // Performance tracing stays OFF — enabling it is INFRA-297's scope + // and is deliberately deferred. Transactions bypass beforeSend the + // same way sessions do, so that work needs its own + // beforeSendTransaction before any sample rate is raised above zero. enableAutoPerformanceTracing: false, attachStacktrace: true, normalizeDepth: 3, // Limit depth to prevent deep object exposure @@ -547,6 +578,34 @@ export class ExternalErrorReporter { values: event.exception.values.map((ex: any) => ({ type: ex.type, value: this.sanitizeString(ex.value), + // INFRA-295: `mechanism` is what the SDK reads to decide whether a JS + // error is a HARD CRASH (isHardCrash() requires handled === false && + // type === 'onerror'), and beforeSend runs BEFORE envelope creation. + // Dropping it here meant no JS fatal ever marked its session crashed, + // so crash-free-session-rate silently counted native crashes only. + // Copy the three named scalars explicitly — never spread — so a richer + // `mechanism.data` from a future SDK cannot ride along as a smuggling + // channel. Pinned by __tests__/privacy/releaseHealthSession.contract.test.ts. + ...(ex.mechanism + ? { + mechanism: { + // Type-guard each scalar. `mechanism.data` / `.source` / + // `.exception_id` / `.parent_id` are deliberately NOT copied — + // `data` in particular is an open `{[key: string]: string | + // boolean}` bag the SDK documents as "arbitrary data, e.g. the + // handler name and the event target", which is not + // privacy-neutral. + type: + typeof ex.mechanism.type === 'string' ? ex.mechanism.type : 'generic', + ...(typeof ex.mechanism.handled === 'boolean' && { + handled: ex.mechanism.handled, + }), + ...(typeof ex.mechanism.synthetic === 'boolean' && { + synthetic: ex.mechanism.synthetic, + }), + }, + } + : {}), stacktrace: ex.stacktrace ? { frames: ex.stacktrace.frames?.map((frame: any) => ({ filename: this.sanitizeFilename(frame.filename), @@ -559,6 +618,14 @@ export class ExternalErrorReporter { }; } + // INFRA-295: `level` is on the allowlist above, but the bare copy would pass + // through whatever it was handed. Sentry's level is a closed six-value enum, + // so validate against it — a future `captureMessage(msg, someVariable)` + // must not be able to smuggle a free-text string out through this field. + if (filtered.level !== undefined && !ALLOWED_LEVELS.includes(filtered.level)) { + delete filtered.level; + } + // Add safe metadata filtered.platform = Platform.OS; filtered.timestamp = Date.now(); diff --git a/docs/development/post-launch-monitoring-runbook.md b/docs/development/post-launch-monitoring-runbook.md index 978b15e7..33fe127c 100644 --- a/docs/development/post-launch-monitoring-runbook.md +++ b/docs/development/post-launch-monitoring-runbook.md @@ -73,21 +73,37 @@ in the dashboard, then verify with the MCP. 5. Action: notify the founder channel (email / Slack integration). No user context in payload. 6. Name it `crash-rate >1% (INFRA-87)`. -> [!WARNING] -> **The crash-free-session-rate metric has NO data source today.** Crash-free *session* rate -> depends on release-health **session** tracking, but the app deliberately disables it: -> `ExternalErrorReporter.ts:232` sets `autoSessionTracking: false` (an intentional privacy-first, -> error-only Sentry config — see the "Disable features that could leak sensitive data" block). So -> a crash-free-session-rate alert will sit at **"no data"** until sessions are enabled. Two paths: -> - **Interim (no code change):** create a **Number of Errors** metric alert (Critical when crash -> events exceed a tuned absolute count/hour). A count, not a true 1% rate, but it fires today. -> - **True crash-rate (code change):** flip `autoSessionTracking: true`, which requires a privacy -> re-review of the session payload against the existing `beforeSend`/`normalizeDepth` controls -> (sessions add release/device/OS context) — then use crash-free-session-rate < 99%. Track as a -> follow-up; don't flip it silently. +> [!NOTE] +> **CORRECTED 2026-07-25 (INFRA-295). The warning that used to sit here was wrong** — it said +> release-health sessions had "no data source today" because the app deliberately disabled them. +> It did not. `ExternalErrorReporter.ts` passed `autoSessionTracking: false`, but +> `@sentry/react-native` reads **`enableAutoSessionTracking`**; the key we set does not exist in +> the SDK, is forwarded verbatim to the native layer, and is ignored there — and both native SDKs +> default session tracking **ON**. So sessions have most likely been transmitting from every +> non-`__DEV__` build all along, and the "intentional privacy-first, error-only config" this +> runbook credited was never in effect. +> +> INFRA-295 corrected the key to `enableAutoSessionTracking: true`, keeping sessions as an +> intentional stability signal, and recorded the retrospective privacy review in +> `docs/legal/dpia-sensitive-wellness-data.md` §9 v1.7 + `privacy-policy.md` §5.1 (session +> envelopes bypass `beforeSend` entirely — they are envelope session items, not events — so that +> control never applied to them; the posture rests on the fixed session schema having no field +> for user content, and the residual is the per-install `did` identifier). +> +> **The "interim Number of Errors alert" this section used to recommend was never created** — +> verified live 2026-07-25: `being-prod/javascript-react` has exactly one rule, the default +> onboarding issue alert (ID 2878415), and **zero** metric alerts. There is nothing to supersede. +> +> **Crash attribution was also broken and is now fixed.** `applyAllowlist` stripped `mechanism` +> from the outbound payload, and the SDK's `isHardCrash()` requires +> `mechanism.handled === false && mechanism.type === 'onerror'`. Since `beforeSend` runs *before* +> envelope creation, no JS fatal ever marked its session crashed — crash-free session rate would +> have counted **native crashes only** and read systematically optimistic. Pinned by +> `app/__tests__/privacy/releaseHealthSession.contract.test.ts`. > -> Either way, dev no-ops Sentry (empty DSN), so this only populates from TestFlight/prod builds. -> Confirm data exists in **Releases → Health** before trusting the alert. +> Dev no-ops Sentry (empty DSN), so this only populates from TestFlight/prod builds. **Still +> confirm data exists in Releases → Health before trusting the alert** — as of 2026-07-25 +> `being-prod` had zero error events in 90 days, so the pipeline is not yet observed end-to-end. > [!IMPORTANT] > **PostHog is NOT an alternative crash source — don't reach for it.** Verified 2026-06-18: the diff --git a/docs/legal/dpia-sensitive-wellness-data.md b/docs/legal/dpia-sensitive-wellness-data.md index 271389dc..c2059432 100644 --- a/docs/legal/dpia-sensitive-wellness-data.md +++ b/docs/legal/dpia-sensitive-wellness-data.md @@ -9,9 +9,9 @@ | Field | Value | |---|---| | Document title | Data Protection Impact Assessment — Sensitive Wellness Data | -| Version | 1.6 | +| Version | 1.7 | | Effective date | 2026-05-24 | -| Last amended | 2026-06-16 (INFRA-264 — see §9 change log) | +| Last amended | 2026-07-25 (INFRA-295 — see §9 change log) | | Next scheduled review | 2027-05-24 | | Owner | Palouse Labs LLC (sole proprietor) — responsibility is non-delegable | | Application | Being (Stoic Mindfulness wellness app) | @@ -36,7 +36,7 @@ - Subscription billing metadata processed via Stripe. Stripe receives payment instruments and transaction state; Stripe does not receive any wellness content. - Product analytics (PostHog) — severity-bucket aggregates and feature-engagement events, transmitted only when the user has granted analytics consent (PostHog is not initialized without it; universal opt-out suppresses transmission). PostHog receives no raw screening scores, no Q9 value, no journal content, and no quasi-identifiers. EU data residency (Frankfurt). - Crisis-detection telemetry (Supabase `analytics_events`) — a `crisis_detected` event recorded to Being's own first-party table when a PHQ-9 total ≥20, a non-zero PHQ-9 Q9, or a GAD-7 total ≥15 is detected. Recorded **regardless of analytics consent** under GDPR Art. 6(1)(d)/9(2)(c) vital interests (see §4 and the standalone `lia-crisis-telemetry.md`). Payload: `trigger_type` (category — `phq9_suicidal_ideation` / `phq9_severe_score` / `phq9_moderate_severe_score` / `gad7_severe_score`), `severity_bucket`, `intervention_surfaced`, `assessment_type`. No raw score, no Q9 value, no device identifier; `session_id` is a daily-rotated anonymous token that cannot be joined to a user identity. -- Error and crash telemetry via Sentry — payload scrubbing in place per §7. +- Error and crash telemetry via Sentry — payload scrubbing in place per §7. Sentry also receives release-health session data (session start/end, duration, crash status) carrying a per-install identifier (not linked to identity or wellness data); see §9 v1.7 for the retrospective finding. - Crisis-monitoring operational egress — the `crisis-detection-alerting` edge function emails the founder on a breach via **Resend** (aggregate, non-personal alert payload; see §9 v1.4) and fires a PII-free liveness heartbeat to **healthchecks.io** as an external dead-man's-switch (INFRA-264; an opaque HTTP ping carrying no user data, no wellness data, and no identifier — see §9 v1.6). Neither receives sensitive wellness data. **Out of scope.** Being does not engage in advertising, data sale, third-party sharing for non-service purposes, profiling for automated decisions, or training of generative models. See `docs/legal/privacy-policy.md` §5 for the public confirmation. @@ -117,6 +117,8 @@ Scored on a qualitative 3×3 likelihood × impact matrix (Low / Med / High). "Pr **Scenario 4 — Sentry telemetry leakage.** React Native error boundaries can capture local variable state into error payloads. Production mitigations: a `beforeSend` hook in `app/src/core/services/logging/ExternalErrorReporter.ts` (line 226) sanitizes every event before transmission via a dedicated wellness-data scrubbing path (line 333); sensitive-data variables are never passed into log lines or exception messages by convention. Defense-in-depth for the development environment: the dev-environment Sentry DSN is empty (configured in `app/src/core/config/env.ts`), so a developer's local debugging cannot transmit at all — this is a developer-side safeguard, not a production control. Residual: Low. This scenario warrants explicit attention at every annual review since the React Native error-boundary surface area can grow with new features. +Retrospective correction (INFRA-295, v1.7): a naming mismatch (`autoSessionTracking` vs. the SDK's actual `enableAutoSessionTracking`) meant release-health session envelopes — which carry no wellness content but, absent an explicit `Sentry.setUser` call, do carry a per-install identifier via the native layer's `did` fallback — were most likely transmitted from every production build to date. Note also that session envelopes are envelope *session items*, not events, so the `beforeSend` mitigation credited above does not apply to them; their privacy posture rests on the fixed session schema having no field for user content. See §9 v1.7 for the finding and remediation. + --- ## 7. Mitigation Measures @@ -173,6 +175,7 @@ After applying the controls inventoried in §7, residual **likelihood** for all | 1.3 | 2026-06-08 | Palouse Labs LLC | INFRA-260: identity hardening + control-status correction. (1) **Control 6 correction** — the device-hash RLS credited as a *live* isolation control in v1.0–v1.2 was SQL-editor-verified only and inert on the runtime path (the app never set the `app.device_id` GUC). INFRA-260 replaced it with a Supabase anonymous session so `auth.uid()` is non-null, rewrote all policies to key on it with `WITH CHECK` on writes, and re-verified isolation against the runtime API path (`supabase-rls-verification.md` v2.0). Control 6 is now genuinely live. (2) Control 12 extended: server-side erasure (`delete-account` → `auth.admin.deleteUser` → FK cascade) now backs the right-to-deletion. (3) Subscription receipts encrypted at rest (AES-256-GCM) + IAP transactions bound to one `auth.uid()`. **Material-change assessment:** NOT a §1 trigger — no new data category, no new processor, no local-first change, no new jurisdiction. This is a control-status correction (a credited mitigation made genuinely effective) + a security improvement, not a new processing activity. Founder self-certification. | | 1.6 | 2026-06-16 | Palouse Labs LLC | INFRA-264: external dead-man's-switch for the crisis-detection alerter. On every clean run the `crisis-detection-alerting` edge function fires a heartbeat to **healthchecks.io** — a NEW third-party sub-processor — so a total Supabase/edge outage (which would blind the in-Supabase watchdog) is caught by the resulting missed ping. The ping is an **opaque HTTP GET**: no request body, no `user_id`/`session_id`, no wellness data, no quasi-identifier — only the fact and timestamp of the request, logged by healthchecks.io for uptime monitoring. **No new sensitive-data category.** Material-change assessment recorded below. (Surfaced + verified live in prod via INFRA-278's deploy-state check, 2026-06-16, which confirmed `CRISIS_HEALTHCHECK_PING_URL` is provisioned.) | | 1.5 | 2026-06-13 | Palouse Labs LLC | INFRA-265: synthetic crisis-detection liveness probe. A new `pg_cron` job invokes the `crisis-liveness-probe` edge function every 6h, which writes a clearly-tagged **synthetic** marker row to a NEW dedicated `crisis_liveness_probe` table (PII-free synthetic ops telemetry; RLS-on/no-policies/service_role-only/90-day prune, mirroring `crisis_alert_runs`). The INFRA-219 alerter reads `MAX(probed_at)` as an authoritative dead-vs-quiet liveness signal for the ingest/cron/edge leg. **No new sub-processor** (Supabase-internal; no Resend/EAS). **No new sensitive-data category** — the marker holds only a timestamp + a `synthetic_liveness` constant + a status, never user/session id or wellness data. R2 boundary enforced structurally: a separate table the FEAT-129 views cannot reference, plus a belt-and-suspenders CHECK on `analytics_events` that rejects any synthetic-tagged row, so the synthetic signal can never enter the compliance export. Material-change assessment recorded below. | +| 1.7 | 2026-07-25 | Palouse Labs LLC | INFRA-295: Sentry release-health control-status correction (**retrospective**). (1) **Finding** — `ExternalErrorReporter.ts` set `autoSessionTracking: false` at Sentry init, but `@sentry/react-native` reads `enableAutoSessionTracking`; the option was a dead no-op forwarded verbatim to the native layer, and both native SDKs default session tracking ON. Sentry release-health session envelopes have most likely been transmitted from every non-`__DEV__` build since Sentry was activated, including the 2026-06-15 TestFlight build. (2) **Scope of exposure** — session envelopes carry no wellness content (no PHQ-9/GAD-7 responses, scores, journal text, or crisis data) and bypass `beforeSend` / `normalizeDepth` / the FEAT-284 event processor entirely, being envelope session items rather than events. However, because no `Sentry.setUser` call exists anywhere in `app/src`, the iOS native layer injects `user.id = ` (a persistent per-install UUID) into the scope, and the session envelope's `did` field carries that value to Sentry — a previously-undisclosed identifier reaching an **already-authorized** sub-processor, not disclosure to a new or unauthorized recipient. (3) **Remediation** — the init key was corrected to `enableAutoSessionTracking: true` and session tracking is **kept** as an intentional app-stability signal (crash-free session rate); `docs/legal/privacy-policy.md` §5.1 was updated to disclose session diagnostics and the per-install identifier. A companion correctness fix restored the `mechanism` scalars and `level` to the scrubbed error payload — without them the SDK's `isHardCrash()` never matched, so JS fatal errors were not marking their sessions crashed and any crash-rate signal would have been systematically optimistic. Both are pinned by `app/__tests__/privacy/releaseHealthSession.contract.test.ts`, which imports the SDK's real `isHardCrash` so an upstream change to the crash-attribution rule fails the build. (4) **Not a breach** — no wellness content was exposed; the exposed value is a device-scoped installation identifier, not "PHR identifiable health information" under 16 CFR Part 318, so the FTC Health Breach Notification Rule backstop in §8 is not triggered. **Material-change assessment:** NOT a §1 trigger — no new sensitive wellness data category (§3), no new sub-processor (Sentry already in scope), no local-first architecture change, no new jurisdiction. This is a retrospective control-status correction (documenting and closing a pre-existing configuration defect), not a new processing activity requiring pre-activity assessment. Founder self-certification. **Open operator action:** verify the App Store Connect App Privacy questionnaire declares Diagnostics (Crash Data / Performance Data) as "Not Linked to You" / "Not Used for Tracking". | | 1.4 | 2026-06-13 | Palouse Labs LLC | INFRA-219: automated crisis-detection alerting. A `pg_cron` job invokes the `crisis-detection-alerting` edge function daily; on a volume-spike or liveness/pipeline-dead breach over the FEAT-129 operator-only views it emails the founder via **Resend** — a NEW third-party sub-processor. The alert payload is aggregate-only and **non-personal**: bucketed counts, category labels, verdict statuses, and a DAY-level date; a ≥3 minimum-count floor withholds rare per-bucket rows from external transmission; it never carries user_id / session_id / distinct_sessions / raw score / Q9 / sub-day timestamp. The function reads ONLY the views, never `analytics_events`. A Supabase-internal watchdog cron escalates if the alerter stops running (shared-failure-domain residual accepted pre-launch; external dead-man's-switch is a tracked follow-up). Material-change assessment recorded below. | **INFRA-214 T5 material-change assessment (2026-06-03).** Assessed against the §1 triggers: *new derived category of sensitive wellness data* — **yes** (`crisis_detected` encodes a trigger/severity category derived from PHQ-9/GAD-7; a new processing activity within the §3 categories); *new third-party processor* — **no** (Supabase `analytics_events` is first-party, already in scope per §2/§5); *local-first architecture change* — **no** (raw PHQ-9/GAD-7 responses remain local-only; this is a server-side write of a derived category); *new jurisdiction* — **no**. **Conclusion:** the revise-trigger is met; this v1.2 amendment is the required pre-activity assessment under TDPSA §541.105(a), CPA §6-1-1309, VCDPA §59.1-580, and CTDPA §6. **Founder self-certification** suffices pre-launch (no EU/EEA base near the §10 500-user threshold); counsel review of the Art. 6(1)(d)/9(2)(c) basis is required before that threshold per §10. diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md index 7634f314..7f93227f 100644 --- a/docs/legal/privacy-policy.md +++ b/docs/legal/privacy-policy.md @@ -135,7 +135,7 @@ We use the following third-party service providers to operate our Services: - **[Supabase](https://supabase.com/privacy):** Database, authentication, and cloud storage (SOC 2 Type II certified, US data region). If you enable optional settings backup, your encrypted preference data is stored on Supabase infrastructure. - **[PostHog](https://posthog.com/privacy):** Product analytics (EU data residency, Frankfurt). See Section 5.2 for details. - **[Notion](https://www.notion.so/privacy):** Waitlist email storage for the being.fyi marketing website. When you submit your email via the pre-launch waitlist form, we store it (along with your A/B variant assignment, where applicable) in an internal Notion database. We do not transfer mental-health data, app usage, or any other personal data to Notion. -- **[Sentry](https://sentry.io/privacy/):** Crash, error, and performance diagnostics. Event payloads are scrubbed of wellness data and identifiers before transmission — no PHQ-9 / GAD-7 responses or scores, no journal content, and no device identifier are sent. +- **[Sentry](https://sentry.io/privacy/):** Crash, error, and performance diagnostics. Error event payloads are scrubbed of wellness data before transmission — no PHQ-9 / GAD-7 responses or scores, and no journal content are sent. Sentry also receives standard app-session diagnostics (session start/end, app version, crash status) used to measure app stability across releases; these session records include a random identifier generated per app installation by our mobile framework. This identifier is not linked to your account, your wellness data, or any other personal information we hold, and is not used to identify you or to track you across apps or websites. - **[Resend](https://resend.com/legal/privacy-policy):** Transactional email delivery for internal operational alerts only — for example, automated notifications to our operators about the health of the crisis-detection pipeline. These alerts contain aggregate, non-personal operational data; no user personal data is sent to Resend. - **Expo:** Mobile app framework and over-the-air updates - **Apple/Google:** App distribution and in-app purchases (no health data shared)