From c21652e62978a11d330e9df3b1802b28b9dfc73b Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 20:25:51 -0700
Subject: [PATCH 01/90] fix: DEBUG-511 honour ACK on a close that died without
a verdict
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ACK check sat inside the DONE branch, so it was only reachable for a
run that finished. A run that is killed or loses its host never writes
DONE: it ages into the STALE arm, which consulted nothing. That entry
reported forever and held rc=1 forever, and `touch
/ACK` could not
clear it.
Both /b-work Step 0.3 and /b-close gate on this exit code and tell the
operator to stop and handle a non-zero result, so one unclearable entry
stops every later session with an already-handled line β until the check
becomes noise, and the next genuine failed close lands in a channel
nobody reads.
Read ACK above the DONE branch and apply it to the DONE and STALE arms.
The in-flight arm deliberately IGNORES it (AC3): that arm adds nothing to
rc, so an ACK could only hide a live run β and refusing instead would
rebuild this bug mirror-imaged, as an entry the documented remedy cannot
clear. Stated in the header comment, which claimed the contract this
code broke (AC5).
The STALE line now names its remedy, as the DONE line already did; its
absence is why the observed case was cleared by hand-writing a DONE file.
Tests pin all four {DONE, no DONE} x {ACK, no ACK} states plus the
in-flight carve-out, and that the mute is per-directory β silencing the
whole sweep would satisfy AC1 and break AC2.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/__tests__/scripts/b-close-verdict.test.js | 108 ++++++++++++++++++
app/scripts/b-close-verdict.sh | 22 +++-
2 files changed, 125 insertions(+), 5 deletions(-)
diff --git a/app/__tests__/scripts/b-close-verdict.test.js b/app/__tests__/scripts/b-close-verdict.test.js
index 1eac3fea..f6eaad05 100644
--- a/app/__tests__/scripts/b-close-verdict.test.js
+++ b/app/__tests__/scripts/b-close-verdict.test.js
@@ -412,3 +412,111 @@ describe('INFRA-492 runner β structural guarantees that cannot be asserted by
expect(Number(dflt)).toBeLessThan(10);
});
});
+
+/**
+ * DEBUG-511 β ACK must clear a run that died WITHOUT a verdict.
+ *
+ * The `ACK` check lived inside the `DONE` branch, so it was only ever reachable for a run
+ * that finished. A run that is killed, crashes, or loses its host never writes `DONE`: it
+ * ages past `B_CLOSE_STALE_S` into the STALE arm, which consulted nothing. That entry
+ * reports forever and holds `rc=1` forever, and `touch /ACK` β the remedy the STALE
+ * line itself does not offer, and the DONE line does β cannot clear it.
+ *
+ * Why that is worse than a stray line: `/b-work` Step 0.3 and `/b-close` both gate on this
+ * exit code and tell the operator to stop and handle a non-zero result. One unclearable
+ * entry therefore stops every future session with an already-handled line, until the check
+ * becomes noise the operator skips β and the next genuine failed close lands in a channel
+ * nobody reads. That is the failure the mailbox exists to prevent, inverted.
+ *
+ * THE IN-FLIGHT ARM IS DELIBERATELY EXEMPT (AC3). `ACK` is honoured by the DONE and STALE
+ * arms only; on a run still making progress it is IGNORED, not refused.
+ * - Ignored rather than honoured: the in-flight arm contributes nothing to `rc`, so there
+ * is no noise for an ACK to remove β it could only hide a live run from an operator
+ * about to start work on the same tree, which is the harm AC3 names.
+ * - Ignored rather than refused: making a premature `touch ACK` an error clearable only
+ * by DELETING the file rebuilds this very bug mirror-imaged β an entry the documented
+ * remedy cannot clear.
+ * Accepted consequence, stated so the next reader knows it is a decision and not an
+ * oversight: an ACK written while a run is live pre-arms the mute, so if that run later
+ * dies its STALE line never appears. This already held for the DONE arm before this fix;
+ * ACK is a claim by a human that the run is handled, and nothing here outranks that.
+ */
+describe('DEBUG-511 ACK β the four terminal states, plus the in-flight carve-out', () => {
+ let root;
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'debug511-'));
+ });
+
+ const withRoot = expr => sh(expr, { B_CLOSE_RUN_ROOT: root });
+ const STALE = 'B_CLOSE_STALE_S=1800 b_close_status';
+
+ /** A run dir frozen far enough in the past to land in the STALE arm. */
+ const deadRun = name => {
+ const d = path.join(root, name);
+ withRoot(`b_close_run_init "${d}" DEBUG-511 fix/x && b_close_status_write "${d}" flows`);
+ const old = Math.floor(Date.now() / 1000) - 7200;
+ fs.utimesSync(path.join(d, 'status'), old, old);
+ return d;
+ };
+
+ // ---- {no DONE} x {no ACK} β the control AC2 protects. Must stay RED across the fix.
+ it('still reports a stale run with no ACK, and still returns non-zero (AC2)', () => {
+ deadRun('s1');
+ const r = withRoot(STALE);
+ expect(r.stdout).toContain('STALE');
+ expect(r.status).not.toBe(0);
+ });
+
+ // ---- {no DONE} x {ACK} β THE FIX. Red before it, green after.
+ it('goes quiet on a stale run once ACK is present, without a DONE file (AC1)', () => {
+ const d = deadRun('s2');
+ expect(withRoot(STALE).status).not.toBe(0); // liveness: it was reporting a moment ago
+ fs.writeFileSync(path.join(d, 'ACK'), 'seen\n');
+ const r = withRoot(STALE);
+ expect(r.stdout).not.toContain('STALE');
+ expect(r.stdout).not.toContain('DEBUG-511');
+ expect(r.status).toBe(0);
+ });
+
+ // ---- {DONE} x {ACK} and {DONE} x {no ACK} β controls. Must stay green across the fix.
+ it('stays quiet on an ACKed failed run that DID write DONE', () => {
+ const d = path.join(root, 'd1');
+ withRoot(`b_close_run_init "${d}" DEBUG-511 fix/x && b_close_done "${d}" CI_RED ci detail`);
+ expect(withRoot('b_close_status').status).not.toBe(0);
+ fs.writeFileSync(path.join(d, 'ACK'), 'seen\n');
+ expect(withRoot('b_close_status').status).toBe(0);
+ });
+
+ it('still reports a failed run with DONE and no ACK', () => {
+ const d = path.join(root, 'd2');
+ withRoot(`b_close_run_init "${d}" DEBUG-511 fix/x && b_close_done "${d}" CI_RED ci detail`);
+ const r = withRoot('b_close_status');
+ expect(r.stdout).toContain('CI_RED');
+ expect(r.status).not.toBe(0);
+ });
+
+ // ---- AC3: an ACK on a run that is still making progress changes nothing.
+ it('keeps listing an in-flight run even when ACK is present, and stays rc 0 (AC3)', () => {
+ const d = path.join(root, 'f1');
+ withRoot(`b_close_run_init "${d}" DEBUG-511 fix/x && b_close_status_write "${d}" flows`);
+ fs.writeFileSync(path.join(d, 'ACK'), 'premature\n');
+ const r = withRoot('b_close_status');
+ expect(r.stdout).toContain('in flight');
+ expect(r.stdout).toContain('DEBUG-511');
+ expect(r.status).toBe(0);
+ });
+
+ // ---- The mute is per-directory. Silencing the sweep would satisfy AC1 and break AC2.
+ it('mutes only the ACKed dead run, leaving an un-ACKed peer reporting and rc non-zero', () => {
+ const acked = deadRun('m1');
+ fs.writeFileSync(path.join(acked, 'ACK'), 'seen\n');
+ fs.writeFileSync(path.join(acked, 'meta'), 'item=DEBUG-511-ACKED\nbranch=fix/a\n');
+ const open = deadRun('m2');
+ fs.writeFileSync(path.join(open, 'meta'), 'item=DEBUG-511-OPEN\nbranch=fix/b\n');
+
+ const r = withRoot(STALE);
+ expect(r.stdout).toContain('DEBUG-511-OPEN');
+ expect(r.stdout).not.toContain('DEBUG-511-ACKED');
+ expect(r.status).not.toBe(0);
+ });
+});
diff --git a/app/scripts/b-close-verdict.sh b/app/scripts/b-close-verdict.sh
index eebfe502..22d5a779 100644
--- a/app/scripts/b-close-verdict.sh
+++ b/app/scripts/b-close-verdict.sh
@@ -265,11 +265,16 @@ b_close_done() {
# b_close_status β the operator surface. Exits non-zero when something needs a human, so a
# caller can gate on it; `/b-close` and `/b-work` read it before starting new work, which
# is what makes "not silently absorbed" structural rather than a matter of remembering to
-# look. An acknowledged run (ACK) goes quiet; a merged-but-unacknowledged one is still
-# listed, because the Notion record is deliberately NOT the runner's job.
+# look. An acknowledged run (ACK) goes quiet whether or not it reached a verdict: a run
+# that is killed or loses its host never writes DONE, so an ACK the STALE arm cannot honour
+# would hold rc=1 forever and stop every later session with an already-handled line
+# (DEBUG-511). ACK is IGNORED on a run still making progress β hiding a live run is the
+# worse bug and the in-flight arm adds nothing to rc, so there is no noise to remove. A
+# merged-but-unacknowledged run is still listed, because the Notion record is deliberately
+# NOT the runner's job.
# ---------------------------------------------------------------------------------------
b_close_status() {
- local rc=0 dir meta item branch verdict stage detail phase now age
+ local rc=0 dir meta item branch verdict stage detail phase now age acked
[ -d "$B_CLOSE_RUN_ROOT" ] || { printf 'No detached closes recorded.\n'; return 0; }
now=$(date +%s)
@@ -284,8 +289,14 @@ b_close_status() {
fi
[ -n "$item" ] || item='(unknown)'
+ # Read ACK here, not inside the DONE branch: a run that died without a verdict is
+ # terminal too, and it is the ONLY state whose entry an operator cannot otherwise
+ # clear. Applied by the DONE and STALE arms below β never by the in-flight one.
+ acked=0
+ [ -f "$dir/ACK" ] && acked=1
+
if [ -f "$dir/DONE" ]; then
- [ -f "$dir/ACK" ] && continue
+ [ "$acked" = 1 ] && continue
verdict=$(sed -n 's/^verdict=//p' "$dir/DONE")
stage=$(sed -n 's/^stage=//p' "$dir/DONE")
detail=$(sed -n 's/^detail=//p' "$dir/DONE")
@@ -310,9 +321,10 @@ b_close_status() {
case "$mtime" in (*[!0-9]*|'') mtime="$now" ;; esac
age=$(( now - mtime ))
if [ "$age" -gt "$B_CLOSE_STALE_S" ]; then
+ [ "$acked" = 1 ] && continue
printf 'β οΈ %s %s STALE at %s (%ss without progress) β presumed dead, no verdict\n' \
"$item" "$branch" "$phase" "$age"
- printf ' log: %s/log\n' "$dir"
+ printf ' log: %s/log ack: touch %s/ACK\n' "$dir" "$dir"
rc=1
else
printf 'β³ %s %s in flight: %s (%ss)\n' "$item" "$branch" "$phase" "$age"
From d88e29819bffc5f6abbfa616d48231dc3bcaf940 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:17:16 -0700
Subject: [PATCH 02/90] fix: DEBUG-516 pin the save control against the live
keyboard frame
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
journal-save-button sat 100% under the keyboard at extra-extra-extra-large
(measured [24,415][279,473] against a full keyboard window of [0,407][375,667]
on iPhone SE 3), so a large-text user could not reach the app's only save-time
crisis scan at rest. The keyboard band is identical at every text size; only
Save moved, pushed down by the heading and transcript growing above it.
DEBUG-480 fixed the default size only and left 4pt of clearance β one Dynamic
Type step from failing. It failed.
Restructure: the review phase's action block leaves the ScrollView and becomes
a plain flex sibling of it, inside an outer container whose paddingBottom is
read from the LIVE keyboard frame. Clearance is now non-negative by
construction rather than by arithmetic.
Frame-derived deliberately, and this is the load-bearing choice. UIKit reports
key layout + predictive bar + any inputAccessoryView as one union, so the
inset IS the occluding edge rather than a proxy for it. DEBUG-506 is about to
attach this app's crisis accessory for the first time and raise that edge ~72pt
on this surface, which would take DEBUG-480's 4pt clearance negative at the
DEFAULT text size. A clearance-derived fix is correct under exactly one of
those two states; this one is correct under both, because the inset and the
edge are the same number read once.
Not useOverlayBottomInset(): its MAX with CRISIS_BUTTON_RESERVED_BAND reserves
176pt with no keyboard up β a quarter of an SE 3 viewport, in every phase, to
duplicate protection the action row's paddingRight already provides. The band
is the shape for a centred card; a bottom-anchored row dodges the crisis button
horizontally. The keyboard subscription is extracted to useKeyboardFrameHeight
rather than forked a third time, on the same module-level useSyncExternalStore
shape as useKeyboardOccludesCrisisButton.
Also here, because the restructure causes them:
- the save error moves into the pinned footer and its scrollToEnd is retired.
It can no longer push Save down, and left in place it would fight the
scroll-to-top below. It gains accessibilityLiveRegion (WCAG 4.1.3) β the
scroll was its only handling and that signal is visual.
- the banner scrolls back to top on the crisisActive rising edge. In a
full-screen ScrollView "first child" and "top of viewport" coincided; in a
shrunken scroll region they do not, and the one path that can disclose while
scrolled is a save that fails and discloses.
- the transcript floor is expressed in scaled lines, not a fixed 180pt, which
held ~7 lines at default and ~2 at AX5 on a screen whose task is proofreading.
- journal-discard-button relocates out of the footer at fontScale >= 2.6 so it
never crowds Save. Relocation, never deletion; Save never moves.
Constraints from the crisis and accessibility planning passes, both of which
withdrew positions once given the DEBUG-506 fact and each other's arithmetic:
no second 988 control on this surface (that obligation is DEBUG-506's and is
app-wide), keyboardDismissMode stays on-drag while DEBUG-506 is open, and
journal-review-header stays inside the scroll region so its bubble-phase
dismissal keeps working.
Corrected in place: the automaticallyAdjustKeyboardInsets comment, which
described that prop as the reachability mechanism. It insets scroll content and
does not move contentOffset, so it never repositioned Save and cannot reach a
pinned footer. Its accessory-inclusiveness claim was also never exercised β no
build has produced a non-zero bar for it to absorb.
Refs DEBUG-480, DEBUG-507, DEBUG-506
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01KZaGesrhCZM64DRoA7DeXh
---
app/.eslint-baseline.json | 1 +
.../__tests__/useKeyboardFrameHeight.test.tsx | 115 ++++++
app/src/core/hooks/useKeyboardFrameHeight.ts | 112 ++++++
app/src/core/hooks/useOverlayBottomInset.ts | 31 +-
.../journal/screens/VoiceReflectionScreen.tsx | 327 ++++++++++++++----
.../VoiceReflectionScreen.behavioral.test.tsx | 295 +++++++++++++++-
6 files changed, 772 insertions(+), 109 deletions(-)
create mode 100644 app/src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx
create mode 100644 app/src/core/hooks/useKeyboardFrameHeight.ts
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index 13112484..5c139ab2 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -12,6 +12,7 @@
"src/core/components/subscription/SubscriptionStatusCard.tsx": 4,
"src/core/config/__tests__/env.quick.test.ts": 1,
"src/core/config/env.test.ts": 1,
+ "src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx": 1,
"src/core/navigation/CleanRootNavigator.tsx": 3,
"src/core/navigation/CleanTabNavigator.tsx": 5,
"src/core/navigation/__tests__/CleanTabNavigator.accessibility.test.tsx": 1,
diff --git a/app/src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx b/app/src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx
new file mode 100644
index 00000000..030b7014
--- /dev/null
+++ b/app/src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * useKeyboardFrameHeight β subscription shape and snapshot (DEBUG-516).
+ *
+ * WHY THIS SUITE EXISTS. This hook is the single source for the occluding edge that
+ * VoiceReflectionScreen's pinned footer is inset against, and `useOverlayBottomInset` now
+ * derives from it too. Two properties have to hold: every consumer reads ONE snapshot (so
+ * two surfaces cannot disagree about whether the keyboard is up), and the real `Keyboard`
+ * subscriptions are O(1) in mounts β `keyboardWillChangeFrame` fires on every frame of the
+ * show animation, so per-mount subscription would run 2N callbacks per frame.
+ */
+
+import React from 'react';
+import { Keyboard, Text } from 'react-native';
+import { act, render } from '@testing-library/react-native';
+
+import { useKeyboardFrameHeight } from '../useKeyboardFrameHeight';
+import { useOverlayBottomInset } from '../useOverlayBottomInset';
+import { CRISIS_BUTTON_RESERVED_BAND } from '@/features/crisis/constants/crisisButtonGeometry';
+
+const Height: React.FC = () => {`h:${useKeyboardFrameHeight()}`} ;
+const Inset: React.FC = () => {`i:${useOverlayBottomInset()}`} ;
+
+/** The full keyboard window measured on the iPhone SE 3 gate device. */
+const SE3_KEYBOARD_HEIGHT = 260;
+
+describe('useKeyboardFrameHeight', () => {
+ let addListener: jest.SpyInstance;
+ let handlers: Array<[string, (e: unknown) => void]>;
+
+ beforeEach(() => {
+ handlers = [];
+ addListener = jest
+ .spyOn(Keyboard, 'addListener')
+ .mockImplementation(((event: string, handler: (e: unknown) => void) => {
+ handlers.push([event, handler]);
+ return { remove: jest.fn() };
+ }) as never);
+ });
+
+ afterEach(() => addListener.mockRestore());
+
+ const emit = (height: number): void => {
+ act(() => {
+ for (const [event, handler] of handlers) {
+ if (height > 0 && (event === 'keyboardWillChangeFrame' || event === 'keyboardDidShow')) {
+ handler({ endCoordinates: { height } });
+ }
+ if (height === 0 && (event === 'keyboardWillHide' || event === 'keyboardDidHide')) {
+ handler({});
+ }
+ }
+ });
+ };
+
+ it('registers the SAME number of listeners for three mounts as for one', () => {
+ const one = render( );
+ const afterOne = addListener.mock.calls.length;
+ one.unmount();
+
+ addListener.mockClear();
+ const three = render(
+ <>
+
+
+
+ >,
+ );
+ expect(addListener.mock.calls.length).toBe(afterOne);
+ three.unmount();
+ });
+
+ it('publishes the reported frame height to every consumer at once', () => {
+ const utils = render(
+ <>
+
+
+ >,
+ );
+
+ emit(SE3_KEYBOARD_HEIGHT);
+
+ expect(utils.getByText(`h:${SE3_KEYBOARD_HEIGHT}`)).toBeTruthy();
+ // The extraction must not have changed useOverlayBottomInset's contract: it still
+ // takes the MAX with the crisis band, which is what distinguishes an overlay inset
+ // from the raw keyboard inset a full-screen pinned footer wants.
+ expect(
+ utils.getByText(`i:${Math.max(CRISIS_BUTTON_RESERVED_BAND, SE3_KEYBOARD_HEIGHT)}`),
+ ).toBeTruthy();
+ utils.unmount();
+ });
+
+ it('reports zero once the keyboard hides', () => {
+ const utils = render( );
+ emit(SE3_KEYBOARD_HEIGHT);
+ emit(0);
+
+ expect(utils.getByText('h:0')).toBeTruthy();
+ // And the overlay inset falls back to its band rather than to nothing β that floor is
+ // correct for a centred card and is exactly why VoiceReflectionScreen does not use it.
+ utils.unmount();
+ });
+
+ it('does not inherit a stale height across a full teardown', () => {
+ const first = render( );
+ emit(SE3_KEYBOARD_HEIGHT);
+ expect(first.getByText(`h:${SE3_KEYBOARD_HEIGHT}`)).toBeTruthy();
+ first.unmount();
+
+ // The last consumer leaving detaches the listeners, so a keyboard dismissed while
+ // nothing was listening would otherwise be remembered as still up.
+ const second = render( );
+ expect(second.getByText('h:0')).toBeTruthy();
+ second.unmount();
+ });
+});
diff --git a/app/src/core/hooks/useKeyboardFrameHeight.ts b/app/src/core/hooks/useKeyboardFrameHeight.ts
new file mode 100644
index 00000000..996d7ad9
--- /dev/null
+++ b/app/src/core/hooks/useKeyboardFrameHeight.ts
@@ -0,0 +1,112 @@
+/**
+ * The height of the keyboard's frame, right now, as ONE app-wide snapshot (DEBUG-516).
+ *
+ * WHY A PRIMITIVE RATHER THAN A THIRD LISTENER. Two hooks already derive their own state
+ * from the same two `Keyboard` events β `useOverlayBottomInset` and
+ * `useKeyboardOccludesCrisisButton` β and both record that the pairing (`willChangeFrame`
+ * rather than `willShow`, because it also fires for split/undock and height changes) is
+ * load-bearing. A third copy is the drift `crisisButtonGeometry.ts` exists to prevent, and
+ * the two consumers could then disagree about whether the keyboard is up.
+ *
+ * WHY THE HEIGHT AND NOT A DERIVED VERDICT. `overlayBottomInset` takes a MAX with the
+ * crisis-button band, which is right for a centred card and wrong for a bottom-anchored
+ * action row: keyboard-down it returns 176pt, a quarter of an iPhone SE 3 viewport, spent
+ * to duplicate protection a horizontal `paddingRight` already provides. The raw frame is
+ * the quantity both shapes are derived FROM, so that is what this publishes.
+ *
+ * WHY IT IS THE ONLY HONEST SOURCE FOR AN OCCLUDING EDGE. An `inputAccessoryView` is
+ * installed into `UIRemoteKeyboardWindow` as part of the first responder's input-view set,
+ * so `UIKeyboardFrameEndUserInfoKey` reports key layout + predictive bar + accessory as one
+ * union, and RN passes that rect through verbatim. A layout derived from this number
+ * therefore absorbs a change in the accessory's height in the same notification that causes
+ * it β no capability probe, no device table, and no way for the inset and the edge to
+ * disagree, because they are the same number read once. A layout derived from a MEASURED
+ * clearance cannot do that, and is wrong the moment any keyboard-window chrome changes.
+ *
+ * ONE SUBSCRIPTION FOR THE WHOLE APP, however many consumers. `keyboardWillChangeFrame`
+ * fires on every frame of the keyboard's show animation, so per-mount subscription runs 2N
+ * callbacks per frame. Listeners are hoisted to module scope behind a subscriber set and
+ * torn down when the last consumer leaves; `useSyncExternalStore` keeps every consumer on
+ * one snapshot. Same shape as `useKeyboardOccludesCrisisButton`, deliberately.
+ */
+
+import { useSyncExternalStore } from 'react';
+import { Keyboard, Platform } from 'react-native';
+import type { EmitterSubscription } from 'react-native';
+
+/** The single snapshot every consumer reads, in points. */
+let keyboardHeight = 0;
+
+/** React-supplied re-render callbacks, one per mounted consumer. */
+const consumers = new Set<() => void>();
+
+/** The two real `Keyboard` subscriptions, held only while a consumer exists. */
+let subscriptions: EmitterSubscription[] | null = null;
+
+function publish(next: number): void {
+ if (next === keyboardHeight) return;
+ keyboardHeight = next;
+ for (const notify of consumers) notify();
+}
+
+function attachKeyboardListeners(): void {
+ if (subscriptions) return;
+ // `willChangeFrame` on iOS so a consumer's inset animates WITH the keyboard rather than
+ // snapping after it. Android has no `will*` events, so use the `did*` pair there.
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow';
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
+
+ subscriptions = [
+ Keyboard.addListener(showEvent, (e) => publish(e?.endCoordinates?.height ?? 0)),
+ Keyboard.addListener(hideEvent, () => publish(0)),
+ ];
+}
+
+function detachKeyboardListeners(): void {
+ if (!subscriptions) return;
+ for (const sub of subscriptions) sub.remove();
+ subscriptions = null;
+ // Reset rather than retain: the next consumer must not inherit a stale height from a
+ // keyboard that was dismissed while nothing was listening.
+ keyboardHeight = 0;
+}
+
+function subscribe(notify: () => void): () => void {
+ consumers.add(notify);
+ if (consumers.size === 1) attachKeyboardListeners();
+
+ return () => {
+ consumers.delete(notify);
+ if (consumers.size === 0) detachKeyboardListeners();
+ };
+}
+
+/** Primitive snapshot, so `useSyncExternalStore`'s identity check is stable by value. */
+function getSnapshot(): number {
+ return keyboardHeight;
+}
+
+/**
+ * @returns the keyboard frame's height in points β 0 when no keyboard is up.
+ * Accessory- and predictive-bar-inclusive on iOS; see the header.
+ */
+export function useKeyboardFrameHeight(): number {
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+}
+
+/**
+ * The bottom inset a keyboard-avoiding CONTAINER should apply right now.
+ *
+ * Zero on Android, and that is a finding rather than a gap: `windowSoftInputMode`
+ * is `adjustResize`, so the window itself shrinks when the IME opens and the container's
+ * own bottom edge has already moved. Adding the height there DOUBLES the inset and floats
+ * the content a full keyboard clear of the IME.
+ *
+ * @returns points to reserve at the bottom edge for the keyboard.
+ */
+export function useKeyboardAvoidingBottomInset(): number {
+ const height = useKeyboardFrameHeight();
+ return Platform.OS === 'ios' ? height : 0;
+}
+
+export default useKeyboardFrameHeight;
diff --git a/app/src/core/hooks/useOverlayBottomInset.ts b/app/src/core/hooks/useOverlayBottomInset.ts
index 3a97fe31..2f4ccf1e 100644
--- a/app/src/core/hooks/useOverlayBottomInset.ts
+++ b/app/src/core/hooks/useOverlayBottomInset.ts
@@ -51,35 +51,20 @@
* Fix: DEBUG-450. Do NOT make the button's `bottom` dynamic β see that document.
*/
-import { useEffect, useState } from 'react';
-import { Keyboard, Platform } from 'react-native';
import { overlayBottomInset } from '@/features/crisis/constants/crisisButtonGeometry';
+import { useKeyboardFrameHeight } from '@/core/hooks/useKeyboardFrameHeight';
/**
+ * DEBUG-516 extracted the keyboard subscription this hook used to own into
+ * `useKeyboardFrameHeight`, unchanged in event pairing and therefore in behaviour. The
+ * MAX with the crisis-button band stays here, because it is what distinguishes an overlay
+ * inset from a plain keyboard inset β a full-screen surface with a bottom-anchored action
+ * row wants the raw height and dodges the crisis button horizontally instead.
+ *
* @returns the bottom inset an overlay should apply right now, in points.
*/
export function useOverlayBottomInset(): number {
- const [keyboardHeight, setKeyboardHeight] = useState(0);
-
- useEffect(() => {
- // `WillChangeFrame` on iOS so the inset animates with the keyboard rather
- // than snapping after it; Android has no `will*` events, so use the
- // `did*` pair there.
- const showEvent = Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow';
- const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
-
- const showSub = Keyboard.addListener(showEvent, (e) => {
- setKeyboardHeight(e?.endCoordinates?.height ?? 0);
- });
- const hideSub = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
-
- return () => {
- showSub.remove();
- hideSub.remove();
- };
- }, []);
-
- return overlayBottomInset(keyboardHeight);
+ return overlayBottomInset(useKeyboardFrameHeight());
}
export default useOverlayBottomInset;
diff --git a/app/src/features/journal/screens/VoiceReflectionScreen.tsx b/app/src/features/journal/screens/VoiceReflectionScreen.tsx
index 81f4d4a8..e2188c20 100644
--- a/app/src/features/journal/screens/VoiceReflectionScreen.tsx
+++ b/app/src/features/journal/screens/VoiceReflectionScreen.tsx
@@ -41,7 +41,7 @@
* removes the signal entirely.
*/
-import React, { useCallback, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View,
Text,
@@ -49,7 +49,9 @@ import {
Pressable,
TextInput,
ScrollView,
+ useWindowDimensions,
} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
// Static import β the crisis path's no-lazy-import rule (CLAUDE.md).
import { openCrisisUrl } from '@/features/crisis/utils/openCrisisUrl';
import {
@@ -72,9 +74,55 @@ import {
import { sweepAllAudioArtifacts } from '@/core/services/speech/audioArtifactSweeper';
import { crisisAccessoryProps } from '@/features/crisis/constants/crisisInputAccessory';
import { OVERLAY_ACTION_ROW_PADDING_RIGHT } from '@/features/crisis/constants/crisisButtonGeometry';
+import { useKeyboardAvoidingBottomInset } from '@/core/hooks/useKeyboardFrameHeight';
type Phase = 'idle' | 'recording' | 'review' | 'saved' | 'unavailable';
+/**
+ * DEBUG-516 β at or above this font scale `journal-discard-button` RELOCATES out of the
+ * pinned footer and into the scrolling region.
+ *
+ * RELOCATION, NEVER DELETION, and never Save. Discard keeps its label, its 44pt target and
+ * its place in the traversal order; it simply stops competing with Save for the band above
+ * the keyboard. It is the right thing to move because it is secondary AND destructive β it
+ * throws the transcript away with the save-time scan never having run, so a mis-tap on it
+ * is DEBUG-480's own failure reached by another route.
+ *
+ * ONE module-level constant, read once, evaluated outside every per-control branch β the
+ * DEBUG-469 shape. A threshold derived from a measured footer height would oscillate
+ * (measure -> shrink -> remeasure), and a per-control threshold drops controls at
+ * different scales.
+ *
+ * 2.6 fires at `accessibility-extra-large` (2.643) and above. Sized so the pinned footer
+ * stays under half the band above the keyboard: on a 375x667 SE 3 the band is ~342pt after
+ * the sheet inset and nav header, and Save plus Discard reach ~193pt (56%) by AX5 while
+ * Save alone stays ~99pt (29%).
+ */
+const DISCARD_RELOCATION_FONT_SCALE = 2.6;
+
+/**
+ * Floor for the transcript field, in scaled lines rather than points.
+ *
+ * `minHeight: 180` was a fixed pt value against a font that scales, so it held ~7 lines at
+ * the default size and ~2 at AX5 β and this step is CORRECTING A LOSSY TRANSCRIPT, so the
+ * person doing the proofreading loses exactly the context the task depends on, at exactly
+ * the size they asked for more of it. Three lines is degraded but honest; one line with a
+ * visible caret is the absolute floor below which the field is not shippable.
+ */
+const TRANSCRIPT_MIN_LINES = 3;
+
+/** RN's default line box for a text node, as a multiple of its font size. */
+const LINE_BOX_RATIO = 1.2;
+
+/**
+ * Share of the band above the keyboard the transcript may claim before it yields.
+ *
+ * A CAP, not just a floor. The field cannot push Save any more β Save is pinned β but an
+ * unbounded field still forces a scroll on a layout that would otherwise fit, which is the
+ * discovery problem this item exists to remove.
+ */
+const TRANSCRIPT_MAX_BAND_SHARE = 0.45;
+
/**
* Prompts, each traceable to a classical source. Offered, never required.
* The first is the master prompt β if only one ever ships, it is this one.
@@ -120,6 +168,42 @@ export function VoiceReflectionScreen(): React.ReactElement {
// draft persistence to fall back on.
const scrollRef = useRef(null);
+ // DEBUG-516 β the bottom edge has exactly ONE owner, and this is it.
+ //
+ // Read from the LIVE keyboard frame, never from a measured clearance. UIKit reports key
+ // layout + predictive bar + any `inputAccessoryView` as one union, so this number IS the
+ // occluding edge rather than a proxy for it: when DEBUG-506 attaches the crisis accessory
+ // and the edge rises ~72pt, the inset rises in the same notification. A fix calibrated
+ // against DEBUG-480's measured 4pt clearance is correct under exactly one of those two
+ // states and cannot be correct under both.
+ //
+ // Deliberately NOT useOverlayBottomInset(): its MAX with CRISIS_BUTTON_RESERVED_BAND
+ // returns 176pt with no keyboard up, a quarter of an SE 3 viewport surrendered in every
+ // phase to duplicate protection `styles.actionBlock`'s paddingRight already provides.
+ // The band is the shape for a centred card; a bottom-anchored row dodges the button
+ // HORIZONTALLY. Vertical space above the keyboard is the scarce resource here.
+ //
+ // A MAX with the safe area, never a sum β the same rule the overlay inset records.
+ const keyboardInset = useKeyboardAvoidingBottomInset();
+ const safeAreaBottom = useSafeAreaInsets().bottom;
+ const bottomInset = Math.max(keyboardInset, safeAreaBottom);
+
+ const { height: windowHeight, fontScale } = useWindowDimensions();
+ const discardInFooter = fontScale < DISCARD_RELOCATION_FONT_SCALE;
+
+ // The transcript yields; Save never does. Expressed in scaled line boxes so the floor
+ // means the same thing at every text size, with 180 kept as the default-size value so
+ // this is not a silent relayout for the 90% case.
+ const transcriptLineBox = typography.bodyRegular.size * fontScale * LINE_BOX_RATIO;
+ const transcriptMinHeight = Math.max(
+ 180,
+ TRANSCRIPT_MIN_LINES * transcriptLineBox + spacing[16] * 2,
+ );
+ const transcriptMaxHeight = Math.max(
+ transcriptMinHeight,
+ (windowHeight - keyboardInset) * TRANSCRIPT_MAX_BAND_SHARE,
+ );
+
// DEBUG-480: in-flight guard. saveEntry is awaited with nothing blocking
// re-entry, so a double-tap writes two encrypted entries. Making Save smaller
// and scroll-dependent makes double-tapping MORE likely, so the guard ships
@@ -260,6 +344,31 @@ export function VoiceReflectionScreen(): React.ReactElement {
}
}, [transcript]);
+ // DEBUG-516 β the restructure OWES this, so it ships with the restructure.
+ //
+ // Today `journal-crisis-banner` is the first child of a FULL-SCREEN ScrollView, so "first
+ // child" and "top of the viewport" coincide and a disclosure is always on screen. Once
+ // the footer is pinned the scroll region is a fraction of the screen, and a modest offset
+ // carries the banner β and `journal-crisis-call-988` with it β above the visible area.
+ //
+ // The rising edge can only land in a scrolled position on ONE path. scanOnFinalize also
+ // transitions out of `recording`, and a successful scanOnSave transitions to `saved`; the
+ // exception is a save that FAILS and discloses, which leaves phase `review` with the view
+ // wherever the user left it. DEBUG-480's objection to scrolling mid-edit does not apply β
+ // that was about the save-error scroll yanking the caret, and there is no scan during
+ // editing to raise this edge.
+ //
+ // NOT pinned instead: at AX5 the banner's title alone runs several line boxes and would
+ // consume the whole band above the keyboard before Save or the transcript got any of it.
+ // Its un-pinnability is what makes the rest of this layout affordable.
+ const wasCrisisActive = useRef(false);
+ useEffect(() => {
+ if (crisisActive && !wasCrisisActive.current) {
+ scrollRef.current?.scrollTo({ y: 0, animated: true });
+ }
+ wasCrisisActive.current = crisisActive;
+ }, [crisisActive]);
+
const handleDiscard = useCallback(() => {
// Deliberately does NOT clear an active intervention: discarding the text
// does not undo the disclosure.
@@ -268,6 +377,28 @@ export function VoiceReflectionScreen(): React.ReactElement {
setPhase('idle');
}, []);
+ /**
+ * DEBUG-480: the 44pt minimum goes on the PRESSABLE. `styles.subtleAction` is a Text
+ * style shared with journal-clear-prompt, so growing it there would resize an unrelated
+ * control. Discard destroys the transcript with the save-time scan never having run, so
+ * a mis-tap here is DEBUG-480's own failure reached by a different route β hence real
+ * separation from Save, not just a compliant hit rect.
+ *
+ * DEBUG-516: hoisted to a binding so it can render in the pinned footer or in the scroll
+ * region without a second copy. One element, one set of props β a duplicated JSX branch
+ * is how the two homes would drift apart on a label, a testID, or the 44pt floor.
+ */
+ const discardButton = (
+
+ Discard
+
+ );
+
const crisisBanner = crisisActive ? (
Support is available right now
@@ -292,26 +423,41 @@ export function VoiceReflectionScreen(): React.ReactElement {
) : null;
return (
+ // DEBUG-516 β the OUTER container owns the bottom edge, and carries the screen
+ // identity. `voice-reflection-screen` has to live here rather than on the scroller:
+ // both Maestro flows assert it as the screen-identity oracle, so it must exist in all
+ // five phases, and the node that scrolls no longer wraps everything.
+
{crisisBanner}
@@ -394,9 +546,17 @@ export function VoiceReflectionScreen(): React.ReactElement {
Fix anything the transcription got wrong.
+ {/* DEBUG-516: the floor and cap are scale-derived, applied inline because both
+ depend on values only available at render. What is SCANNED is unaffected β
+ scanOnSave reads the `transcript` state string, never rendered text, so
+ clipping or scrolling this field changes what the user SEES and nothing
+ about what the crisis scanner receives. */}
- {saveError ? (
- scrollRef.current?.scrollToEnd({ animated: true })}
- >
- {saveError}
-
- ) : null}
-
- {/* DEBUG-480: keeps Save and Discard out of the crisis button's
- contested column. CollapsibleCrisisButton renders at zIndex 9999 and
- wins an overlapping tap, so an un-inset full-width Save that scrolls
- into CRISIS_BUTTON_EXCLUSION_RECT can fire an audit-logged crisis
- navigation AND swallow the save-time scan. This is unconditional:
- the transcript field grows without a cap, so Save's y is not fixed.
- Same shape as the DEBUG-406 composers. */}
-
-
- Save
-
-
- {/* DEBUG-480: the 44pt minimum goes on the PRESSABLE. styles.subtleAction
- is a Text style shared with journal-clear-prompt, so growing it there
- would resize an unrelated control. Discard destroys the transcript
- with the save-time scan never having run, so a mis-tap here is
- DEBUG-480's own failure reached by a different route β hence real
- separation from Save, not just a compliant hit rect. */}
-
- Discard
-
-
+ {/* DEBUG-516: Discard's second home. Above the threshold it scrolls with the
+ transcript rather than crowding Save in the pinned footer. */}
+ {!discardInFooter && discardButton}
)}
@@ -483,11 +598,81 @@ export function VoiceReflectionScreen(): React.ReactElement {
)}
+
+ {/* ββ THE PINNED FOOTER (DEBUG-516) ββββββββββββββββββββββββββββββββββββββββββββ
+ A plain flex sibling of the scroll region β never position:'absolute' (RN
+ resolves it against the parent's PADDING BOX, the DEBUG-403 trap), never a
+ Modal or the root overlay slot, either of which would paint above the crisis
+ button. Same shape as DailyLoopDepthSelectScreen.
+
+ WHY PINNED AT ALL. Inside the scroller, Save's y was a function of the heading
+ and transcript above it, which is precisely what Dynamic Type changes: the
+ keyboard band is identical at every text size, and only Save moved. Pinned, its
+ clearance is non-negative BY CONSTRUCTION rather than by arithmetic that has to
+ be re-measured every time a string, a locale, or the keyboard's own chrome moves.
+
+ It also retires DEBUG-480's tap-swallowing negotiation for this control: with no
+ ScrollView ancestor there is no capture-phase claimant, so the Pressable wins the
+ first keyboard-up tap outright instead of relying on keyboardShouldPersistTaps
+ resolving in its favour. That prop still matters for the scroll region, where
+ journal-review-header's dismissal depends on the BUBBLE phase.
+
+ paddingRight is DEBUG-480's and is MORE load-bearing here, not less: pinned to
+ the bottom edge this row now sits permanently inside CRISIS_BUTTON_EXCLUSION_RECT's
+ vertical band when the keyboard is down, so horizontal exclusion is the only thing
+ keeping a Save tap out of a control that renders at zIndex 9999 and WINS an
+ overlapping tap β which would fire an audit-logged crisis navigation AND swallow
+ the save-time scan. */}
+ {phase === 'review' && (
+
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+
+ {/* No maxFontSizeMultiplier, deliberately. This is the sole entry to the app's
+ only save-time crisis scan, and the house rule is that capping text growth
+ on a crisis affordance inverts the priority. Discard relocates instead. */}
+ Save
+
+
+ {discardInFooter && discardButton}
+
+ )}
+
);
}
const styles = StyleSheet.create({
+ // DEBUG-516: the outer container. paddingBottom is applied inline from the live
+ // keyboard frame β a MAX with the safe area, never a sum.
container: { flex: 1, backgroundColor: semantic.background.screen },
+ // flex lets the prose and transcript YIELD space to the pinned footer rather than
+ // pushing it off screen β the whole point of the split.
+ scroll: { flex: 1 },
content: { padding: spacing[24], gap: spacing[16] },
title: {
fontSize: typography.headline3.size,
@@ -506,7 +691,8 @@ const styles = StyleSheet.create({
marginBottom: spacing[8],
},
input: {
- minHeight: 180,
+ // minHeight and maxHeight are applied inline: both are scale-derived. The old fixed
+ // `minHeight: 180` held ~7 lines at the default size and ~2 at AX5.
borderWidth: 1,
borderColor: semantic.border.default,
borderRadius: borderRadius.medium,
@@ -538,8 +724,15 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginTop: spacing[16],
},
- // DEBUG-480 β see the render site. Wraps Save + Discard only.
+ // DEBUG-480/516 β see the render site. Pinned; wraps the save error, Save, and Discard
+ // when Discard has not relocated.
actionBlock: {
+ paddingHorizontal: spacing[24],
+ paddingTop: spacing[8],
+ paddingBottom: spacing[16],
+ // MUST come after paddingHorizontal β RN StyleSheet is last-key-wins, so a
+ // paddingHorizontal declared afterwards would silently overwrite the inset and restore
+ // the collision with the crisis button, with no visible diff.
paddingRight: OVERLAY_ACTION_ROW_PADDING_RIGHT,
},
discardButton: {
diff --git a/app/src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx b/app/src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx
index b52dbc68..eb8a4b22 100644
--- a/app/src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx
+++ b/app/src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx
@@ -8,8 +8,8 @@
*/
import React from 'react';
-import { render, fireEvent, waitFor } from '@testing-library/react-native';
-import { Alert, Linking } from 'react-native';
+import { render, fireEvent, waitFor, act } from '@testing-library/react-native';
+import { Alert, Keyboard, Linking, useWindowDimensions } from 'react-native';
jest.mock('@/core/services/speech/onDeviceSpeechGuard', () => ({
checkOnDeviceAvailability: jest.fn().mockResolvedValue({ available: true }),
@@ -59,6 +59,42 @@ function flatten(style: unknown): Record {
const CRISIS_TEXT = 'i want to die';
const CLEAN_TEXT = 'today was hard but i made it through';
+/** iPhone SE 3, the device every DEBUG-480/507/516 measurement was taken on. */
+const SE3 = { width: 375, height: 667, scale: 2 };
+
+/**
+ * The keyboard window measured on that device β the FULL window `[0,407][375,667]`,
+ * not `UIKeyboardLayoutStar Preview` `[0,451][375,667]`, which reports the key layout
+ * only and under-reports the occluding edge by the ~44pt predictive bar
+ * (profile-voice-reflection-xxxl.yaml).
+ */
+const SE3_KEYBOARD_HEIGHT = 260;
+
+/** Render at a given Dynamic Type scale. 1 = default; 1.353 = XXXL; 3.571 = AX5. */
+function setFontScale(fontScale: number): void {
+ (useWindowDimensions as unknown as jest.Mock).mockReturnValue({ ...SE3, fontScale });
+}
+
+/**
+ * Drive the module-level keyboard store the way iOS does.
+ *
+ * Captured from the `Keyboard.addListener` mock rather than emitted, because the store
+ * subscribes lazily β a handler captured before the first consumer mounts does not exist.
+ */
+function emitKeyboard(height: number): void {
+ const calls = (Keyboard.addListener as jest.Mock).mock.calls;
+ act(() => {
+ for (const [event, handler] of calls) {
+ if (height > 0 && (event === 'keyboardWillChangeFrame' || event === 'keyboardDidShow')) {
+ handler({ endCoordinates: { height, screenY: SE3.height - height } });
+ }
+ if (height === 0 && (event === 'keyboardWillHide' || event === 'keyboardDidHide')) {
+ handler({});
+ }
+ }
+ });
+}
+
/** Drive the screen to the transcript-review phase. */
async function reachReview(text: string) {
const utils = render( );
@@ -70,11 +106,44 @@ async function reachReview(text: string) {
return utils;
}
+/** Component/host names of every ancestor of `node`, innermost first. */
+function ancestorTypeNames(node: { parent: unknown } | null): string[] {
+ const names: string[] = [];
+ let cur = (node as { parent: unknown } | null)?.parent as
+ | { parent: unknown; type?: unknown }
+ | null
+ | undefined;
+ while (cur) {
+ const t = cur.type as string | { displayName?: string; name?: string } | undefined;
+ names.push(typeof t === 'string' ? t : (t?.displayName ?? t?.name ?? ''));
+ cur = cur.parent as typeof cur;
+ }
+ return names;
+}
+
+const hasScrollAncestor = (node: { parent: unknown } | null): boolean =>
+ ancestorTypeNames(node).some((n) => /scrollview/i.test(n));
+
+/** testIDs of every ancestor of `node`, innermost first. */
+function ancestorTestIds(node: { parent: unknown } | null): string[] {
+ const ids: string[] = [];
+ let cur = node as { parent: unknown; props?: Record } | null;
+ while (cur) {
+ const id = cur.props?.testID;
+ if (typeof id === 'string') ids.push(id);
+ cur = cur.parent as typeof cur;
+ }
+ return ids;
+}
+
beforeEach(() => {
jest.clearAllMocks();
mockSave.mockResolvedValue({ saved: true, entry: { id: 'x' } });
mockAvailability.mockResolvedValue({ available: true });
mockSweep.mockReturnValue(0);
+ // clearAllMocks wipes the return value the shared setup installed, and the screen
+ // destructures it β without this every spec in the file throws on render.
+ setFontScale(1);
});
describe('transcript editing', () => {
@@ -200,29 +269,43 @@ describe('keyboard reachability of the save control (DEBUG-480)', () => {
// jest has no keyboard and no layout: these are shape pins. The behavioural
// evidence is the keyboard-up block in .maestro/journal-crisis-scan.yaml.
+ // DEBUG-516 re-anchored these three. `voice-reflection-screen` is now the OUTER
+ // container β it has to be, because both Maestro flows assert it as the screen-identity
+ // oracle and it must exist in all five phases. Read scroll props off the node that
+ // actually scrolls; a scroll-prop assertion on a non-scrolling View is green and inert.
+
it('does not let the ScrollView swallow the first keyboard-up tap', async () => {
const utils = await reachReview(CLEAN_TEXT);
- const scroll = utils.getByTestId('voice-reflection-screen');
+ const scroll = utils.getByTestId('voice-reflection-scroll');
// 'handled' β NOT true/'always', which would stop inert content from
// blurring the input and break journal-review-header's dismissal.
expect(scroll.props.keyboardShouldPersistTaps).toBe('handled');
});
- it('insets the scroll view for the keyboard natively, not from a JS height', async () => {
+ it('keeps the native keyboard inset on the scroll region, no longer load-bearing', async () => {
const utils = await reachReview(CLEAN_TEXT);
- const scroll = utils.getByTestId('voice-reflection-screen');
- // UIKit intersects the keyboard frame with this view's frame in window
- // coordinates, so it is immune to the header/modal-card offset that rules
- // out KeyboardAvoidingView here, and already includes DEBUG-450's accessory.
+ const scroll = utils.getByTestId('voice-reflection-scroll');
+ // DEBUG-516 CORRECTION. Until DEBUG-516 this prop WAS the reachability mechanism,
+ // and this spec said so. It is not any more: the outer container's paddingBottom
+ // owns the bottom edge, and once the subtree ends at the keyboard top the native
+ // intersection is zero. Retained for scroll CONTENT and for the animation
+ // transient only. Save's reachability is pinned below, against the container.
expect(scroll.props.automaticallyAdjustKeyboardInsets).toBe(true);
});
it('offers a dismissal that does not depend on tapping a specific element', async () => {
const utils = await reachReview(CLEAN_TEXT);
- const scroll = utils.getByTestId('voice-reflection-screen');
- // The transcript field is multiline, so Return inserts a newline and there is
- // no Done key. on-drag makes the scroll toward Save dismiss the keyboard.
- expect(scroll.props.keyboardDismissMode).toBe('on-drag');
+ const scroll = utils.getByTestId('voice-reflection-scroll');
+ // The transcript field is multiline, so Return inserts a newline and there is no
+ // Done key, so a gestural dismissal is the only one.
+ //
+ // An ACCEPTED SET, not a free-form invariant: an assertion loose enough to pass on
+ // 'none' is worse than the literal it replaced. 'interactive' is deliberately
+ // EXCLUDED while DEBUG-506 is open β it dismisses only on a drag that pulls the
+ // keyboard down, so a scroll to re-read the transcript keeps the keyboard and keeps
+ // this surface's zero-988 window. It becomes a legitimate UX call once DEBUG-506
+ // lands and keyboard-up 988 no longer depends on dismissal at all.
+ expect(['on-drag']).toContain(scroll.props.keyboardDismissMode);
});
it('keeps Save and Discard out of the crisis button contested column', async () => {
@@ -251,18 +334,35 @@ describe('keyboard reachability of the save control (DEBUG-480)', () => {
expect(flat.marginTop).toBeGreaterThan(0);
});
- it('scrolls the save error into view, since it pushes Save further down', async () => {
+ it('renders the save error inside the pinned footer, not in the scroll region', async () => {
+ mockSave.mockResolvedValue({ saved: false, reason: 'unknown' });
+ const utils = await reachReview(CLEAN_TEXT);
+ fireEvent.press(utils.getByTestId('journal-save-button'));
+
+ const err = await waitFor(() => utils.getByTestId('journal-save-error'));
+ // DEBUG-516 supersedes DEBUG-480's scrollToEnd. That call existed because the error
+ // pushed Save down; with Save pinned it no longer can. Left in place it would fight
+ // the scroll-to-top that reveals a banner disclosed on this same failure path β two
+ // programmatic scrolls in one commit wanting opposite offsets. Moving the error into
+ // the footer retires the fragile behaviour instead of retargeting it, and puts the
+ // message adjacent to the control it refers to, visible with no scroll at all.
+ const ancestors = ancestorTestIds(err);
+ expect(ancestors).toContain('journal-action-block');
+ expect(ancestors).not.toContain('voice-reflection-scroll');
+ expect(err.props.onLayout).toBeUndefined();
+ });
+
+ it('announces the save error to assistive tech', async () => {
mockSave.mockResolvedValue({ saved: false, reason: 'unknown' });
const utils = await reachReview(CLEAN_TEXT);
fireEvent.press(utils.getByTestId('journal-save-button'));
const err = await waitFor(() => utils.getByTestId('journal-save-error'));
- // The error renders BETWEEN the input and Save on the retry path, where the
- // text has already survived one failed write and there is no autosave.
- expect(typeof err.props.onLayout).toBe('function');
- expect(() => fireEvent(err, 'layout', {
- nativeEvent: { layout: { x: 0, y: 0, width: 300, height: 20 } },
- })).not.toThrow();
+ // WCAG 4.1.3. The only prior handling was the scrollToEnd, which is visual; removing
+ // it without replacement would leave a VoiceOver user with no signal that the save
+ // failed, on the one path where the text has already survived one failed write and
+ // there is no autosave or draft persistence.
+ expect(err.props.accessibilityLiveRegion).toBe('assertive');
});
it('writes one entry when Save is double-tapped', async () => {
@@ -468,3 +568,160 @@ describe('a second disclosure in one screen session (DEBUG-504)', () => {
expect(mockAlert).toHaveBeenCalledTimes(1);
});
});
+
+/**
+ * DEBUG-516 β the save control is pinned, and its bottom inset is read from the LIVE
+ * keyboard frame.
+ *
+ * THE DEFECT. At `extra-extra-extra-large` on iPhone SE 3, `journal-save-button` measured
+ * [24,415][279,473] against a full keyboard window of [0,407][375,667] β 100% covered,
+ * 37pt under the edge. The keyboard band is identical at every text size; what moves is
+ * Save, pushed down by the heading and transcript box growing above it. DEBUG-480 fixed
+ * the default size only and left 4pt of clearance, which is one Dynamic Type step from
+ * failing. It failed.
+ *
+ * WHY THE INSET IS FRAME-DERIVED AND NOT CLEARANCE-DERIVED. An `inputAccessoryView` is
+ * installed into `UIRemoteKeyboardWindow` as part of the first responder's input-view set,
+ * so `UIKeyboardFrameEndUserInfoKey` β and therefore RN's `endCoordinates.height` β reports
+ * key layout + predictive bar + accessory as ONE union. DEBUG-506 makes this app's crisis
+ * accessory attach for the first time, which raises the occluding edge on this surface by
+ * ~60pt. Any fix calibrated against a measured clearance is correct under exactly one of
+ * those two states; a fix that reads the frame is correct under both, because the inset and
+ * the edge are the same number read once.
+ *
+ * jest has no keyboard and no layout. These are shape pins over a store driven by real
+ * `Keyboard` events; the behavioural evidence is the keyboard-up block in
+ * .maestro/journal-crisis-scan.yaml and .maestro/profile-voice-reflection-xxxl.yaml.
+ */
+describe('the save control is pinned above the keyboard (DEBUG-516)', () => {
+ it('puts the action block OUTSIDE the scrolling region', async () => {
+ const utils = await reachReview(CLEAN_TEXT);
+ const save = utils.getByTestId('journal-save-button');
+
+ expect(ancestorTestIds(save)).toContain('journal-action-block');
+ expect(ancestorTestIds(save)).toContain('voice-reflection-screen');
+
+ // Asserted on ANCESTOR TYPES, not on a testID. Phrased against a testID this spec
+ // passes vacuously today β `not.toContain('voice-reflection-scroll')` is trivially
+ // true while that testID does not exist, so it would go green before a line of the
+ // fix was written and stay green if the fix were reverted.
+ //
+ // The whole fix: inside a scroller Save's y is a function of everything above it,
+ // which is exactly what Dynamic Type changes. Outside one there is also no
+ // capture-phase claimant, so the Pressable wins the first keyboard-up tap
+ // unconditionally rather than by the negotiated 'handled' outcome DEBUG-480 relied on.
+ expect(hasScrollAncestor(save)).toBe(false);
+
+ // Control, proving the matcher still fires (DEBUG-390's rule: a narrow matcher that
+ // silently matches nothing looks exactly like a passing assertion). The transcript
+ // field must REMAIN inside the scroll region, so this half must stay true.
+ expect(hasScrollAncestor(utils.getByTestId('journal-transcript-input'))).toBe(true);
+ });
+
+ it('insets the container by the live keyboard height', async () => {
+ const utils = await reachReview(CLEAN_TEXT);
+ emitKeyboard(SE3_KEYBOARD_HEIGHT);
+
+ const flat = flatten(utils.getByTestId('voice-reflection-screen').props.style);
+ expect(flat.paddingBottom).toBe(SE3_KEYBOARD_HEIGHT);
+ });
+
+ it('reserves NOTHING when no keyboard is up', async () => {
+ const utils = await reachReview(CLEAN_TEXT);
+ emitKeyboard(SE3_KEYBOARD_HEIGHT);
+ emitKeyboard(0);
+
+ const flat = flatten(utils.getByTestId('voice-reflection-screen').props.style);
+ // Deliberately NOT useOverlayBottomInset(), whose Math.max floor would return the
+ // 176pt CRISIS_BUTTON_RESERVED_BAND here β a quarter of a 667pt viewport surrendered
+ // in every phase, spending the exact resource this fix is short of. That band is the
+ // shape for a centred card; a bottom-anchored row dodges the crisis button
+ // HORIZONTALLY, which journal-action-block already does.
+ expect(flat.paddingBottom).toBe(0);
+ });
+
+ it('tracks the keyboard identically at every Dynamic Type step', async () => {
+ for (const fontScale of [1, 1.353, 1.786, 3.571]) {
+ setFontScale(fontScale);
+ const utils = await reachReview(CLEAN_TEXT);
+ emitKeyboard(SE3_KEYBOARD_HEIGHT);
+
+ const flat = flatten(utils.getByTestId('voice-reflection-screen').props.style);
+ // The inset is a property of the keyboard, never of the content above Save. This
+ // is what makes clearance non-negative BY CONSTRUCTION instead of by arithmetic.
+ expect(flat.paddingBottom).toBe(SE3_KEYBOARD_HEIGHT);
+ utils.unmount();
+ }
+ });
+
+ it('grows the transcript floor with the type size', async () => {
+ setFontScale(1);
+ const atDefault = await reachReview(CLEAN_TEXT);
+ const defaultMin = flatten(atDefault.getByTestId('journal-transcript-input').props.style)
+ .minHeight;
+ atDefault.unmount();
+
+ setFontScale(3.571);
+ const atAx5 = await reachReview(CLEAN_TEXT);
+ const ax5Min = flatten(atAx5.getByTestId('journal-transcript-input').props.style).minHeight;
+
+ // minHeight: 180 is a fixed pt value against a font that scales, so it held ~7 lines
+ // at default and ~2 at AX5 β the person correcting a lossy transcript loses the very
+ // context correction depends on. Floor is three scaled lines; the default is unchanged.
+ expect(defaultMin).toBe(180);
+ expect(ax5Min).toBeGreaterThan(defaultMin);
+ });
+
+ it('relocates Discard out of the pinned footer at accessibility sizes, never deletes it', async () => {
+ setFontScale(3.571);
+ const utils = await reachReview(CLEAN_TEXT);
+
+ // RELOCATION, NEVER DELETION (the DEBUG-469 rule). Discard is destructive and
+ // secondary, so it is the release valve when the footer would otherwise crowd Save
+ // toward the keyboard β but it keeps its label, its 44pt target, and its place in
+ // the traversal order. Save never moves.
+ const discard = utils.getByTestId('journal-discard-button');
+ expect(ancestorTestIds(discard)).not.toContain('journal-action-block');
+ expect(ancestorTestIds(discard)).toContain('voice-reflection-scroll');
+ expect(flatten(discard.props.style).minHeight).toBeGreaterThanOrEqual(44);
+ expect(ancestorTestIds(utils.getByTestId('journal-save-button')))
+ .toContain('journal-action-block');
+ });
+
+ it('brings the banner back into view when a disclosure lands during review', async () => {
+ const scrollTo = jest.spyOn(
+ (jest.requireActual('react-native') as { ScrollView: { prototype: Record } })
+ .ScrollView.prototype,
+ 'scrollTo' as never,
+ );
+ mockSave.mockResolvedValue({ saved: false, reason: 'unknown' });
+ const utils = await reachReview(CRISIS_TEXT);
+ fireEvent.press(utils.getByTestId('journal-save-button'));
+ await waitFor(() => utils.getByTestId('journal-crisis-banner'));
+
+ // The restructure OWES this. Today the banner is the first child of a full-screen
+ // ScrollView, so "first child" and "top of the viewport" coincide; after the split the
+ // scroll region is a fraction of the screen and a modest offset carries the banner out
+ // of view. The rising edge can only land in a scrolled position on one path β a save
+ // that FAILS and discloses, leaving phase 'review' β which is this one.
+ expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ y: 0 }));
+ scrollTo.mockRestore();
+ });
+
+ it('adds no second 988 control to this surface', async () => {
+ const utils = await reachReview(CRISIS_TEXT);
+ // The banner is raised by scanOnSave, not by reaching review: the recognizer never
+ // runs here, so scanOnFinalize scanned '' β the same reason the Maestro flow types
+ // into the field rather than speaking.
+ fireEvent.press(utils.getByTestId('journal-save-button'));
+ await waitFor(() => utils.getByTestId('journal-crisis-banner'));
+ // Crisis constraint: the keyboard-up 988 obligation is DEBUG-506's and is app-wide
+ // across seven surfaces. A per-surface control here becomes a duplicate the day it
+ // lands, giving one screen two differently-labelled Call-988 buttons β worse for a
+ // screen-reader user than the gap it was meant to close. journal-crisis-call-988 is
+ // the disclosure banner's action and is the ONLY 988 affordance this screen owns.
+ expect(utils.getAllByTestId('journal-crisis-call-988')).toHaveLength(1);
+ expect(ancestorTestIds(utils.getByTestId('journal-crisis-call-988')))
+ .not.toContain('journal-action-block');
+ });
+});
From 677839b5ba7a6835e388b1eaf787ca68398e9487 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:21:53 -0700
Subject: [PATCH 03/90] chore: MAINT-513 App Store Server API hosts to Apple's
documented base URL
Swap APP_STORE_SERVER_API_ORIGINS to the short forms Apple currently documents
(api.storekit.apple.com / api.storekit-sandbox.apple.com), and move the two
literals that pin them in the same commit.
Future-proofing, not a live defect. Measured 2026-08-22: each pair still shares
one Akamai CNAME (commercegateway / commercegateway-sandbox) and all four hosts
return 401 unauthenticated, so the swap is cosmetic today. It guards against
Apple retiring the legacy alias, which would fail every Apple verification at
once -- verify-apple-receipt and grace-period-automation both resolve the host
through the same resolveApiOrigin.
The pinning test's failure on the constant change alone (162 passed / 1 failed)
is the proof it was load-bearing; that exactly one test failed also confirms the
other four references track the constant symbolically and needed no edit.
Also asserts the distinctness the test's name has always promised but never
checked, so a future edit cannot collapse the two hosts without failing.
No fallback and no host-switching retry: the module header forbids it by name,
because that is the deleted 21007 sandbox-fallback bug. Two hosts co-resolving
is the reason no fallback is needed, not a reason to try both.
resolveApiOrigin's no-default fail-closed throw is untouched.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015eW4ZheBj6wVHWekhKujRz
---
supabase/functions/_shared/appStoreServerApi.ts | 11 ++++++++---
.../functions/_tests/app-store-server-api.test.ts | 8 ++++++--
2 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/supabase/functions/_shared/appStoreServerApi.ts b/supabase/functions/_shared/appStoreServerApi.ts
index 3aa373e6..1f11f754 100644
--- a/supabase/functions/_shared/appStoreServerApi.ts
+++ b/supabase/functions/_shared/appStoreServerApi.ts
@@ -100,10 +100,15 @@
import { importPKCS8, SignJWT } from 'https://esm.sh/jose@5.9.6';
import { APPLE_ENVIRONMENTS, BEING_BUNDLE_ID } from './verifyAppleJWS.ts';
-/** The two App Store Server API hosts. Selected by environment; never defaulted. */
+/** The two App Store Server API hosts. Selected by environment; never defaulted.
+ *
+ * These are the forms Apple documents. The older `*.itunes.apple.com` aliases still
+ * resolve β each pair shares one Akamai CNAME β so this is a guard against Apple
+ * retiring them, not a live fix. Both forms working is NOT a reason to try both:
+ * see the no-host-switching-retry rule above. */
export const APP_STORE_SERVER_API_ORIGINS = {
- Production: 'https://api.storekit.itunes.apple.com',
- Sandbox: 'https://api.storekit-sandbox.itunes.apple.com',
+ Production: 'https://api.storekit.apple.com',
+ Sandbox: 'https://api.storekit-sandbox.apple.com',
} as const;
/** Fixed audience for App Store Connect API tokens. Never configurable β an env-driven
diff --git a/supabase/functions/_tests/app-store-server-api.test.ts b/supabase/functions/_tests/app-store-server-api.test.ts
index 312ba70e..52d0e96b 100644
--- a/supabase/functions/_tests/app-store-server-api.test.ts
+++ b/supabase/functions/_tests/app-store-server-api.test.ts
@@ -22,6 +22,7 @@
import {
assertEquals,
+ assertNotEquals,
assertRejects,
assertThrows,
assertStringIncludes,
@@ -175,8 +176,11 @@ Deno.test('a malformed id throws before the key is read or any call is made', as
// ---------------------------------------------------------------------------
Deno.test('the two Apple hosts are the documented ones and are distinct', () => {
- assertEquals(APP_STORE_SERVER_API_ORIGINS.Production, 'https://api.storekit.itunes.apple.com');
- assertEquals(APP_STORE_SERVER_API_ORIGINS.Sandbox, 'https://api.storekit-sandbox.itunes.apple.com');
+ assertEquals(APP_STORE_SERVER_API_ORIGINS.Production, 'https://api.storekit.apple.com');
+ assertEquals(APP_STORE_SERVER_API_ORIGINS.Sandbox, 'https://api.storekit-sandbox.apple.com');
+ // The name promises distinctness; assert it rather than leaving it implied by the
+ // two literals above, which a future edit could collapse without failing anything.
+ assertNotEquals(APP_STORE_SERVER_API_ORIGINS.Production, APP_STORE_SERVER_API_ORIGINS.Sandbox);
assertEquals(resolveApiOrigin('Production'), APP_STORE_SERVER_API_ORIGINS.Production);
assertEquals(resolveApiOrigin('Sandbox'), APP_STORE_SERVER_API_ORIGINS.Sandbox);
});
From 96b52c82f833669e7bcedf5d26b65b903d46a6bf Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:21:58 -0700
Subject: [PATCH 04/90] test: DEBUG-516 restore the keyboard-up save block at
XXXL
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AC 4. profile-voice-reflection-xxxl.yaml stopped at the keyboard-up state
because the next step was broken; DEBUG-516 fixed it, so the flow regains
the contract it was always meant to pin: tap journal-save-button at its
resting position, keyboard up, at the largest non-accessibility text size,
and assert journal-saved-state.
No scroll before the tap and no visibility assertion on the button β both
are forbidden in this file and in journal-crisis-scan.yaml. A scroll weakens
the contract from "reachable with the keyboard up" to "reachable after
scrolling", and the iOS keyboard is a separate window so XCUITest scores a
100% covered button fully visible. The oracle stays journal-saved-state,
which only handleSave's success branch produces.
Also adds a drag-dismissal block, deliberately as a separate capture at the
end. keyboardDismissMode='on-drag' is this surface's only keyboard dismissal
(multiline field, so Return inserts a newline and there is no Done key), and
while DEBUG-506 is open it is also the only route back to a state where the
root crisis button is reachable. The restructure shrank the scroll region, so
"the content still scrolls, therefore the drag still dismisses" stopped being
free β if it ever stops being draggable the mitigation dies silently. It
cannot share the block above, because `swipe` destroys the condition that one
tests.
Both files' recorded geometry is stamped as PRE-DEBUG-506 evidence rather than
a threshold. The +4pt @ large / -37pt @ XXXL figures record where an
in-ScrollView Save landed on one build, and two things move them: type size
(which is DEBUG-516) and the keyboard's own chrome β an attached
inputAccessoryView joins the union UIKeyboardFrameEndUserInfoKey reports, so
DEBUG-506 raises the occluding edge ~72pt and would have taken the 4pt
negative at the default text size. The fix no longer derives from either
number.
Refs DEBUG-480, DEBUG-507, DEBUG-506
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01KZaGesrhCZM64DRoA7DeXh
---
app/.maestro/journal-crisis-scan.yaml | 28 ++++++-
.../profile-voice-reflection-xxxl.yaml | 82 ++++++++++++++++---
2 files changed, 94 insertions(+), 16 deletions(-)
diff --git a/app/.maestro/journal-crisis-scan.yaml b/app/.maestro/journal-crisis-scan.yaml
index a8b45002..c28394d6 100644
--- a/app/.maestro/journal-crisis-scan.yaml
+++ b/app/.maestro/journal-crisis-scan.yaml
@@ -251,10 +251,30 @@ name: "Voice journal crisis scan surfaces support"
# dismisses the keyboard, which destroys the one condition this block tests.
#
# Tapping Save DIRECTLY at its resting position is therefore both the honest shape
-# and the stricter one. MEASURED at 375x667 with the keyboard up: Save sits at
-# [24,377][279,429] against an occluding edge of y=407 β 42% covered, tap centre
-# y=403 clearing by 4pt. With no scroll to rescue it, this block goes red if
-# anything ever erodes that clearance. That sensitivity is the point.
+# and the stricter one. The ABSENCE of a scroll before the tap is itself load-bearing:
+# adding one would convert "Save is reachable with the keyboard up" into "Save is
+# reachable after scrolling", a strictly weaker contract that hides the regression class
+# this block exists to catch.
+#
+# ββ THE 4pt FIGURE IS PRE-DEBUG-506 EVIDENCE, NOT A THRESHOLD (DEBUG-516) βββββ
+# MEASURED at 375x667 with the keyboard up, on `development` in August 2026: Save sat at
+# [24,377][279,429] against an occluding edge of y=407 β 42% covered, tap centre y=403
+# clearing by 4pt. Read that as a record of one build, because two things move it:
+#
+# 1. TYPE SIZE. DEBUG-507 re-measured at extra-extra-extra-large and found Save at
+# [24,415][279,473] β 100% covered, 37pt UNDER the edge. 4pt was one Dynamic Type
+# step from failing, and it failed. That is DEBUG-516.
+# 2. THE KEYBOARD'S OWN CHROME. An attached inputAccessoryView is installed into
+# UIRemoteKeyboardWindow and joins the union UIKeyboardFrameEndUserInfoKey reports,
+# so the occluding edge RISES by the bar's height. DEBUG-506 attaches this app's
+# crisis accessory for the first time (~72pt: TOUCH_TARGETS.large plus padding),
+# which would have taken this 4pt negative at the DEFAULT text size.
+#
+# DEBUG-516 therefore stopped deriving the layout from this number. Save is pinned outside
+# the ScrollView and its container is inset from the LIVE keyboard frame, so clearance is
+# non-negative by construction and this block is now a behavioural pin rather than a
+# clearance pin. Do NOT re-derive a fix from the coordinates above, and do not treat a
+# change in them as the regression β `journal-saved-state` failing to arrive is.
- launchApp:
clearState: true
clearKeychain: true
diff --git a/app/.maestro/profile-voice-reflection-xxxl.yaml b/app/.maestro/profile-voice-reflection-xxxl.yaml
index 03bf2f7f..6f74abaf 100644
--- a/app/.maestro/profile-voice-reflection-xxxl.yaml
+++ b/app/.maestro/profile-voice-reflection-xxxl.yaml
@@ -71,24 +71,34 @@ name: "Profile -> VoiceReflection at XXXL (DEBUG-507): the save-time crisis scan
id: "voice-reflection-screen"
timeout: 8000
-# ββ AC 3 β WHY THIS FLOW STOPS AT THE KEYBOARD AND DOES NOT TAP SAVE βββββββββ
-# It stops here because the next step is BROKEN, and pinning a broken contract green is
-# worse than not pinning it. DEBUG-507 AC 3 re-measured DEBUG-480's clearance at this text
-# size, against the full keyboard window ([0,407][375,667] β NOT `UIKeyboardLayoutStar
-# Preview`, which is [0,451][375,667] and reports only the key layout, omitting the ~44pt
-# predictive bar that occludes just as much):
+# ββ AC 3 / DEBUG-516 β THIS FLOW NOW TAPS SAVE, AND THAT IS THE POINT ββββββββ
+# It used to stop at the keyboard, because the next step was BROKEN and pinning a broken
+# contract green is worse than not pinning it. DEBUG-507 AC 3 re-measured DEBUG-480's
+# clearance at this text size, against the full keyboard window ([0,407][375,667] β NOT
+# `UIKeyboardLayoutStar Preview`, which is [0,451][375,667] and reports only the key layout,
+# omitting the ~44pt predictive bar that occludes just as much):
#
# content_size large journal-save-button [24,377][279,429] centre y=403 -> clears by 4pt, 42% covered
# content_size XXXL journal-save-button [24,415][279,473] centre y=444 -> UNDER by 37pt, 100% covered
#
# The default-size row reproduces DEBUG-480's published figures exactly, which is what
-# vouches for the method. At XXXL the button does not merely lose margin β it sits entirely
-# beneath the keyboard, so the tap is swallowed and `journal-saved-state` never arrives.
-# Tracked as DEBUG-516 per AC 3; this flow gains the save block when that lands.
+# vouches for the method. At XXXL the button did not merely lose margin β it sat entirely
+# beneath the keyboard, so the tap was swallowed and `journal-saved-state` never arrived.
#
-# Do NOT "fix" this by asserting `journal-save-button` is visible: the iOS keyboard is a
-# separate window, so XCUITest scores the button fully visible while it is 100% covered.
-# That assertion is the exact false green DEBUG-480 exists to warn about.
+# BOTH ROWS ARE PRE-DEBUG-516 AND PRE-DEBUG-506 EVIDENCE. They record where an
+# in-ScrollView Save happened to land on one build; they are not thresholds. DEBUG-516
+# moved Save out of the ScrollView and inset its container from the LIVE keyboard frame,
+# which is the union UIKit reports (key layout + predictive bar + any inputAccessoryView).
+# Clearance is therefore non-negative by construction and survives DEBUG-506 attaching the
+# crisis accessory, which raises the occluding edge ~72pt on this surface. Do not re-derive
+# anything from the coordinates above.
+#
+# Do NOT "fix" a failure here by asserting `journal-save-button` is visible: the iOS
+# keyboard is a separate window, so XCUITest scores the button fully visible while it is
+# 100% covered. That assertion is the exact false green DEBUG-480 exists to warn about.
+# Do NOT add a scroll before the Save tap either β DEBUG-480 measured `scrollUntilVisible`
+# reporting COMPLETED without scrolling here, and a scroll would weaken the contract from
+# "reachable with the keyboard up" to "reachable after scrolling".
- tapOn:
id: "journal-record-button"
- tapOn:
@@ -104,3 +114,51 @@ name: "Profile -> VoiceReflection at XXXL (DEBUG-507): the save-time crisis scan
# visible proves the matcher still fires if iOS renames the identifier.
- assertVisible:
id: "UIKeyboardLayoutStar Preview"
+
+# AC 1 + AC 4 β SAVE AT ITS RESTING POSITION, KEYBOARD UP, AT THE LARGEST NON-ACCESSIBILITY
+# TEXT SIZE. No scroll, no dismissal, no visibility assertion on the button itself. The
+# oracle is the state change: `journal-saved-state` is produced ONLY by handleSave's success
+# branch, so it cannot be reached without the tap having landed β and handleSave is the only
+# caller of scanOnSave, the app's sole crisis scan of text a user typed or corrected.
+- tapOn:
+ id: "journal-save-button"
+- extendedWaitUntil:
+ visible:
+ id: "journal-saved-state"
+ timeout: 8000
+
+# ββ THE DRAG DISMISSAL, PINNED SEPARATELY AND DELIBERATELY LAST ββββββββββββββ
+# `keyboardDismissMode='on-drag'` is this surface's ONLY keyboard dismissal: the transcript
+# field is multiline, so Return inserts a newline and there is no Done key. While DEBUG-506
+# is open there is no working keyboard-up 988 affordance here either, so dismissal is also
+# the only route back to a state where the root crisis button is reachable.
+#
+# DEBUG-516 shrank the scroll region β the prose and transcript now yield space to the
+# pinned footer β so "the content still scrolls, therefore the drag still dismisses" stopped
+# being free. If the region ever stops being draggable, the dismissal dies silently and
+# takes the mitigation with it.
+#
+# A SEPARATE CAPTURE, because `swipe` destroys the condition the block above tests. It runs
+# after `journal-saved-state`, where the transcript input has been unmounted, so it has to
+# start a fresh one.
+- tapOn:
+ id: "journal-record-button"
+- tapOn:
+ id: "journal-stop-button"
+- extendedWaitUntil:
+ visible:
+ id: "journal-transcript-input"
+ timeout: 8000
+- tapOn:
+ id: "journal-transcript-input"
+- inputText: "a second short reflection"
+- assertVisible:
+ id: "UIKeyboardLayoutStar Preview"
+- swipe:
+ from:
+ id: "journal-review-header"
+ direction: UP
+- extendedWaitUntil:
+ notVisible:
+ id: "UIKeyboardLayoutStar Preview"
+ timeout: 8000
From 095cb52c35fecedc2ee27f5107ffd861b3d0e675 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:49:00 -0700
Subject: [PATCH 05/90] =?UTF-8?q?test:=20DEBUG-516=20the=20drag=20block=20?=
=?UTF-8?q?cannot=20be=20a=20second=20capture=20=E2=80=94=20`saved`=20is?=
=?UTF-8?q?=20terminal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The block added in the previous commit ran after journal-saved-state and
started a fresh capture. It failed on device with `Element not found:
journal-record-button`: the `saved` phase is terminal by design and offers no
affordance back to `idle`, which VoiceReflectionScreen's own DEBUG-504 comment
states outright.
Folded into a single capture, ordered drag -> re-raise -> save. That is
stronger than the original shape, not a workaround: the save is now measured
against a freshly-presented keyboard AND a scroll region the drag has already
moved, so it is the pinned footer rather than a lucky content offset that has
to keep Save reachable.
Drags DOWN from the header rather than UP β at the top of the region that is an
overscroll bounce, which still begins a drag (so on-drag fires) without
carrying the transcript field off screen and stranding the steps below it.
The comment records why a route back to `recording` must never be added to
"fix" this: anything reaching that phase outside handleStart inherits the
previous capture's draftId and silences its disclosure, which is the defect
DEBUG-504 fixed.
Refs DEBUG-504
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01KZaGesrhCZM64DRoA7DeXh
---
.../profile-voice-reflection-xxxl.yaml | 75 ++++++++++---------
1 file changed, 40 insertions(+), 35 deletions(-)
diff --git a/app/.maestro/profile-voice-reflection-xxxl.yaml b/app/.maestro/profile-voice-reflection-xxxl.yaml
index 6f74abaf..b2ddc160 100644
--- a/app/.maestro/profile-voice-reflection-xxxl.yaml
+++ b/app/.maestro/profile-voice-reflection-xxxl.yaml
@@ -115,50 +115,55 @@ name: "Profile -> VoiceReflection at XXXL (DEBUG-507): the save-time crisis scan
- assertVisible:
id: "UIKeyboardLayoutStar Preview"
-# AC 1 + AC 4 β SAVE AT ITS RESTING POSITION, KEYBOARD UP, AT THE LARGEST NON-ACCESSIBILITY
-# TEXT SIZE. No scroll, no dismissal, no visibility assertion on the button itself. The
-# oracle is the state change: `journal-saved-state` is produced ONLY by handleSave's success
-# branch, so it cannot be reached without the tap having landed β and handleSave is the only
-# caller of scanOnSave, the app's sole crisis scan of text a user typed or corrected.
-- tapOn:
- id: "journal-save-button"
-- extendedWaitUntil:
- visible:
- id: "journal-saved-state"
- timeout: 8000
-
-# ββ THE DRAG DISMISSAL, PINNED SEPARATELY AND DELIBERATELY LAST ββββββββββββββ
+# ββ THE DRAG DISMISSAL, FIRST β AND IT CANNOT BE A SECOND CAPTURE ββββββββββββ
# `keyboardDismissMode='on-drag'` is this surface's ONLY keyboard dismissal: the transcript
# field is multiline, so Return inserts a newline and there is no Done key. While DEBUG-506
# is open there is no working keyboard-up 988 affordance here either, so dismissal is also
-# the only route back to a state where the root crisis button is reachable.
+# the only route back to a state where the root crisis button is reachable. DEBUG-516 shrank
+# the scroll region β prose and transcript now yield space to the pinned footer β so "the
+# content still scrolls, therefore the drag still dismisses" stopped being free. If the
+# region ever stops being draggable the mitigation dies silently.
#
-# DEBUG-516 shrank the scroll region β the prose and transcript now yield space to the
-# pinned footer β so "the content still scrolls, therefore the drag still dismisses" stopped
-# being free. If the region ever stops being draggable, the dismissal dies silently and
-# takes the mitigation with it.
+# WHY IT RUNS BEFORE THE SAVE AND INSIDE THE SAME CAPTURE. A first draft put this AFTER
+# `journal-saved-state` as a fresh capture, because `swipe` destroys the keyboard-up
+# condition the save block tests. That flow FAILED with `Element not found:
+# journal-record-button` β the `saved` phase is TERMINAL BY DESIGN and offers no affordance
+# back to `idle`, which VoiceReflectionScreen's own DEBUG-504 comment states outright. Do
+# not "fix" a future failure here by adding one: any route back to `recording` that does not
+# go through handleStart inherits the previous capture's draftId and silences its
+# disclosure. That is the defect DEBUG-504 fixed.
#
-# A SEPARATE CAPTURE, because `swipe` destroys the condition the block above tests. It runs
-# after `journal-saved-state`, where the transcript input has been unmounted, so it has to
-# start a fresh one.
-- tapOn:
- id: "journal-record-button"
-- tapOn:
- id: "journal-stop-button"
-- extendedWaitUntil:
- visible:
- id: "journal-transcript-input"
- timeout: 8000
-- tapOn:
- id: "journal-transcript-input"
-- inputText: "a second short reflection"
-- assertVisible:
- id: "UIKeyboardLayoutStar Preview"
+# Dragging DOWN from the header rather than UP: at the top of the region this is an
+# overscroll bounce, which still begins a drag (so `on-drag` fires) without carrying the
+# transcript field off screen and stranding the steps below it.
- swipe:
from:
id: "journal-review-header"
- direction: UP
+ direction: DOWN
- extendedWaitUntil:
notVisible:
id: "UIKeyboardLayoutStar Preview"
timeout: 8000
+
+# Re-raise, so the save below is measured against a freshly-presented keyboard AND a scroll
+# region the drag has already moved. That is strictly stronger than the pristine state the
+# first draft would have tested: it is the pinned footer, not a lucky content offset, that
+# has to keep Save reachable.
+- tapOn:
+ id: "journal-transcript-input"
+- extendedWaitUntil:
+ visible:
+ id: "UIKeyboardLayoutStar Preview"
+ timeout: 8000
+
+# AC 1 + AC 4 β SAVE AT ITS RESTING POSITION, KEYBOARD UP, AT THE LARGEST NON-ACCESSIBILITY
+# TEXT SIZE. No scroll, no dismissal, no visibility assertion on the button itself. The
+# oracle is the state change: `journal-saved-state` is produced ONLY by handleSave's success
+# branch, so it cannot be reached without the tap having landed β and handleSave is the only
+# caller of scanOnSave, the app's sole crisis scan of text a user typed or corrected.
+- tapOn:
+ id: "journal-save-button"
+- extendedWaitUntil:
+ visible:
+ id: "journal-saved-state"
+ timeout: 8000
From b5d08177936b6b18d7230bea294dc498ec53f4ee Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:54:01 -0700
Subject: [PATCH 06/90] chore: INFRA-512 adversarial crisis-corpus harness,
fixture and offline generator
Lands the measurement apparatus for free-text crisis detection. Scoped at batch
approval: the harness, fixture schema and generator ship now; the LLM corpus
generation is deferred (no ANTHROPIC_API_KEY in this environment, and no root
package.json, so the SDK cannot be installed without breaking FEAT-287's pin).
Four artifacts, all test-only -- textCrisisDetection.ts is not modified, so there
is no runtime behaviour change and no safety-surface diff:
- scripts/generate-crisis-corpus.js -- zero-dependency, repo-root, raw HTTP against
the Messages API. Wired into nothing: no npm script, no CI job, no test. Refuses
to run without a key rather than degrading to an empty corpus, and REFUSES an
--out path inside the repo (unreviewed generated phrasing must not enter git
history, and an untracked file under the worktree invalidates the e2e provenance
fingerprint). Both refusals verified by running them.
- crisisTextCorpus.v1.json -- seed corpus. JSON, not .ts, because jest's testMatch
collects any .ts under a src __tests__ dir and would fail it as an empty suite.
Four classes; KNOWN_OVER_FIRE exists so the deliberate "I don't want to die"
over-trigger cannot be mislabelled MUST_NOT_FIRE to improve a rate.
- textCrisisDetection.corpus.test.ts -- reports MUST-FIRE recall, MUST-NOT-FIRE
false-positive rate and STT-MANGLED recall as three separate figures, and
thresholds none of them (AC5: recorded, not enforced). It hard-fails only on
corpus integrity and on an ANCHOR set -- one canonical phrase per shipped
pattern -- which is the anti-narrowing lock. Verified by mutation: removing the
no-point-living pattern turns three assertions red; reverted.
- The dated review packet, which is the record, because the CI job that selects
this file runs --silent and swallows the console figures.
The seed's 12/12 recall is a HARNESS SELF-TEST, not a baseline -- every seed item
is a string already reviewed and committed in textCrisisDetection.unit.test.ts.
The packet says so in three places, and carries six VERIFIED MUST-FIRE misses as
the first candidate batch: "killing myself", "ending it all", "cant go on",
"suicide", "i want to end my life", "i wish i was dead" all MISS, and all are
correctly-transcribed text -- which falsifies the module header's "zero-false-
negative holds over correctly-transcribed text". Correcting that header is
deliberately out of scope here (it would re-tier this to an attended close) and is
the most important thing to carry forward.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015eW4ZheBj6wVHWekhKujRz
---
app/.eslint-baseline.json | 1 +
.../fixtures/crisisTextCorpus.v1.json | 79 ++++++
.../textCrisisDetection.corpus.test.ts | 180 +++++++++++++
...FRA-512-corpus-review-packet-2026-08-22.md | 164 ++++++++++++
scripts/generate-crisis-corpus.js | 238 ++++++++++++++++++
5 files changed, 662 insertions(+)
create mode 100644 app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
create mode 100644 app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
create mode 100644 docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md
create mode 100644 scripts/generate-crisis-corpus.js
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index 13112484..6b3519ec 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -141,6 +141,7 @@
"src/features/crisis/screens/__tests__/CrisisResourcesScreen.test.tsx": 1,
"src/features/crisis/services/__tests__/CrisisSecurityProtocol.test.ts": 1,
"src/features/crisis/services/__tests__/crisisAlert.unit.test.ts": 1,
+ "src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts": 1,
"src/features/crisis/services/__tests__/textCrisisDetection.parity.test.ts": 1,
"src/features/crisis/services/__tests__/textCrisisDetection.unit.test.ts": 1,
"src/features/crisis/services/types/__tests__/CrisisResources.test.ts": 1,
diff --git a/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json b/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
new file mode 100644
index 00000000..559f7dee
--- /dev/null
+++ b/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
@@ -0,0 +1,79 @@
+{
+ "corpusVersion": "v1-seed",
+ "generatedOn": "2026-08-22",
+ "workItem": "INFRA-512",
+ "note": "SEED CORPUS β NOT THE BASELINE. Every item here is drawn verbatim from strings already human-reviewed and committed in textCrisisDetection.unit.test.ts, so all of them are phrases the detector is ALREADY known to handle correctly. The three rates this corpus produces are therefore a self-test of the harness, not a measurement of recall. The adversarial corpus that produces the real baseline is generated offline by scripts/generate-crisis-corpus.js and appended after human review β see docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md, which already carries the first candidate batch and six verified MUST-FIRE misses.",
+ "classes": {
+ "MUST_FIRE": "A disclosure of self-harm intent that detectCrisisInText must match.",
+ "MUST_NOT_FIRE": "Ordinary language that must stay clean. Each item names the specific widening it refutes, so the class cannot be satisfied by generic mood text.",
+ "KNOWN_OVER_FIRE": "Phrasings that DO match and are accepted as deliberate over-triggers. Reported separately and excluded from the false-positive denominator, so a narrowing cannot game the rate by reclassifying them.",
+ "STT_MANGLED": "Realistic speech-to-text corruptions of a MUST_FIRE item, linked by sourceId. Reported as its own recall figure because the module scopes its guarantee to correctly-transcribed text."
+ },
+ "items": [
+ { "id": "mf-suicidal", "class": "MUST_FIRE", "text": "suicidal",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-kill-myself", "class": "MUST_FIRE", "text": "kill myself",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-end-it-all", "class": "MUST_FIRE", "text": "end it all",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-cannot-go-on", "class": "MUST_FIRE", "text": "cannot go on",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-want-to-die", "class": "MUST_FIRE", "text": "want to die",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-no-point-living", "class": "MUST_FIRE", "text": "no point living",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-case-variation", "class": "MUST_FIRE", "text": "Kill My Self",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects across case variation'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-trailing-punctuation", "class": "MUST_FIRE", "text": "i want to die.",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects with trailing punctuation'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-string-start", "class": "MUST_FIRE", "text": "suicidal thoughts again",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects at string start and string end'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-string-end", "class": "MUST_FIRE", "text": "honestly i feel suicidal",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects at string start and string end'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-smart-apostrophe", "class": "MUST_FIRE", "text": "i canβt stop thinking i want to die",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects with smart apostrophes in surrounding text'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mf-embedded-midsentence", "class": "MUST_FIRE", "text": "today was hard and end it all honestly",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects %p embedded mid-sentence'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+
+ { "id": "stt-no-word-boundary", "class": "STT_MANGLED", "text": "killmyself", "sourceId": "mf-kill-myself",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects when STT drops the word boundary entirely'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "stt-split-word-boundary", "class": "STT_MANGLED", "text": "kill my self", "sourceId": "mf-kill-myself",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects when STT drops the word boundary entirely'", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+
+ { "id": "mnf-made-it-through", "class": "MUST_NOT_FIRE", "text": "today was hard but i made it through",
+ "refutes": "Treating a hard-day / despair sentiment as a crisis signal. Ordinary difficulty is the modal journal entry; matching it would make the alert meaningless.",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mnf-dye-my-hair", "class": "MUST_NOT_FIRE", "text": "i want to dye my hair tomorrow",
+ "refutes": "A bare /die/ substring, and any phonetic or fuzzy matcher folding 'dye' to 'die'. The module rejects phonetic matching by name for exactly this case.",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mnf-meeting-ran-long", "class": "MUST_NOT_FIRE", "text": "the meeting ran long and i was tired",
+ "refutes": "Exhaustion or fatigue vocabulary as a proxy for hopelessness.",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+ { "id": "mnf-resisted-the-urge", "class": "MUST_NOT_FIRE", "text": "i resisted the urge to snap at him",
+ "refutes": "Treating 'urge' as a self-harm signal. This is a virtue-practice sentence and is exactly what the app asks users to write.",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+
+ { "id": "kof-negated-want-to-die", "class": "KNOWN_OVER_FIRE", "text": "I donβt want to die",
+ "acceptedBecause": "Deliberate Slice A decision, pinned at textCrisisDetection.unit.test.ts 'MATCHES negated phrasing, intentionally'. Negation detection on lossy STT fails in the direction that matters: a recognizer dropping the negation turns a negated sentence into a disclosure. An extra support offer costs a dismissal; a missed disclosure is unrecoverable. Do NOT reclassify this as MUST_NOT_FIRE to improve the false-positive rate β that would reverse a crisis decision to move a number.",
+ "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts documented over-trigger", "runDate": "2026-08-22" },
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } }
+ ]
+}
diff --git a/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
new file mode 100644
index 00000000..a35e7ece
--- /dev/null
+++ b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
@@ -0,0 +1,180 @@
+/**
+ * textCrisisDetection β adversarial corpus harness (INFRA-512)
+ *
+ * WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT.
+ *
+ * This harness MEASURES `detectCrisisInText` against a labelled corpus and reports
+ * three figures separately β MUST-FIRE recall, MUST-NOT-FIRE false-positive rate, and
+ * STT-MANGLED recall. It does NOT threshold any of them. That is AC5: the baseline is
+ * RECORDED, not silently enforced. A threshold set before the number is known either
+ * passes vacuously or blocks every PR.
+ *
+ * WHY THE THREE FIGURES ARE NEVER BLENDED. The module scopes its guarantee to
+ * correctly-transcribed text and delegates recognizer error to mitigations outside
+ * itself. A single blended number would understate the in-contract failure and
+ * overstate the out-of-contract one.
+ *
+ * THIS FILE IS SELECTED BY CI. Every path under `features/crisis/` matches
+ * `test:crisis-quick`'s `--testPathPattern="[Cc]risis"`, which CI runs via
+ * `validate:crisis-authority`. Two consequences, both designed for:
+ * 1. It must stay far under that job's 5s per-file timeout β this is a few dozen
+ * regex scans over one small JSON file, i.e. microseconds. Do not add I/O.
+ * 2. That job runs `--silent`, so the console figures below are NOT the record.
+ * The record is the dated audit doc named in the corpus fixture.
+ *
+ * THE ONE THING THIS FILE HARD-FAILS ON is the ANCHOR set (see below) β a structural
+ * regression pin, not a quality bar. It fires when `CRISIS_TEXT_PATTERN_SOURCES` is
+ * narrowed, which is what stops the harness being "passed" by shrinking the detector
+ * instead of improving it.
+ *
+ * DO NOT widen `CRISIS_TEXT_PATTERN_SOURCES` to raise a number here. That constant
+ * feeds `journalCrisisScanner.scan`, which fires `showCrisisAlert()` β widening it for
+ * recall buys alarm fatigue on the surface where it costs most. Any widening is a
+ * separate item with a crisis pass.
+ */
+
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+import { CRISIS_TEXT_PATTERN_SOURCES, detectCrisisInText } from '../textCrisisDetection';
+
+type CorpusClass = 'MUST_FIRE' | 'MUST_NOT_FIRE' | 'KNOWN_OVER_FIRE' | 'STT_MANGLED';
+
+interface CorpusItem {
+ id: string;
+ class: CorpusClass;
+ text: string;
+ sourceId?: string;
+ refutes?: string;
+ acceptedBecause?: string;
+ provenance: { source: string; origin?: string; model?: string; runDate: string };
+ review: { reviewer: string; reviewedOn: string; verdict: string };
+}
+
+// readFileSync + JSON.parse rather than `import ... from '.json'`: it gives an explicit
+// missing-fixture failure and sidesteps resolveJsonModule/isolatedModules questions. Same
+// convention textCrisisDetection.parity.test.ts already uses.
+const CORPUS_PATH = join(__dirname, 'fixtures', 'crisisTextCorpus.v1.json');
+const corpus = JSON.parse(readFileSync(CORPUS_PATH, 'utf8')) as {
+ corpusVersion: string;
+ items: CorpusItem[];
+};
+
+const itemsOfClass = (c: CorpusClass) => corpus.items.filter((i) => i.class === c);
+const fires = (text: string) => detectCrisisInText(text)?.isTriggered === true;
+
+/**
+ * ANCHOR SET β one bare canonical phrase per currently-shipped pattern.
+ *
+ * This is the anti-narrowing lock. Every entry must fire, unconditionally, and the set
+ * must stay the same size as CRISIS_TEXT_PATTERN_SOURCES. Removing a pattern reds this
+ * immediately; adding one reds it until the corpus gains a matching anchor, which is the
+ * intended coupling β a new pattern that nothing in the corpus exercises is unmeasured.
+ */
+const ANCHORS: ReadonlyArray<{ patternSource: string; corpusId: string }> = [
+ { patternSource: 'suicidal', corpusId: 'mf-suicidal' },
+ { patternSource: 'kill\\s*my\\s*self', corpusId: 'mf-kill-myself' },
+ { patternSource: 'end\\s*it\\s*all', corpusId: 'mf-end-it-all' },
+ { patternSource: 'can\\s*not\\s*go\\s*on', corpusId: 'mf-cannot-go-on' },
+ { patternSource: 'want\\s*to\\s*die', corpusId: 'mf-want-to-die' },
+ { patternSource: 'no\\s*point\\s*living', corpusId: 'mf-no-point-living' },
+];
+
+describe('corpus harness β the harness itself still works', () => {
+ // A measurement harness that silently stops measuring looks exactly like a clean
+ // codebase. These prove the instrument is live before any figure is believed.
+ it('fixture parses and is non-trivial', () => {
+ expect(corpus.items.length).toBeGreaterThan(0);
+ expect(typeof corpus.corpusVersion).toBe('string');
+ });
+
+ it('the detector under measurement is really wired (control pair)', () => {
+ expect(fires('i want to die')).toBe(true);
+ expect(fires('i want to dye my hair tomorrow')).toBe(false);
+ });
+
+ it('every class the harness reports on is populated', () => {
+ expect(itemsOfClass('MUST_FIRE').length).toBeGreaterThan(0);
+ expect(itemsOfClass('MUST_NOT_FIRE').length).toBeGreaterThan(0);
+ expect(itemsOfClass('STT_MANGLED').length).toBeGreaterThan(0);
+ });
+});
+
+describe('corpus integrity', () => {
+ it('every item carries a review verdict β an unreviewed item may not land', () => {
+ const unreviewed = corpus.items.filter((i) => !i.review?.verdict);
+ expect(unreviewed.map((i) => i.id)).toEqual([]);
+ });
+
+ it('item ids are unique', () => {
+ const ids = corpus.items.map((i) => i.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('every STT_MANGLED item resolves to a real MUST_FIRE source', () => {
+ const mustFireIds = new Set(itemsOfClass('MUST_FIRE').map((i) => i.id));
+ const dangling = itemsOfClass('STT_MANGLED').filter(
+ (i) => !i.sourceId || !mustFireIds.has(i.sourceId),
+ );
+ expect(dangling.map((i) => i.id)).toEqual([]);
+ });
+
+ it('every MUST_NOT_FIRE item names the widening it refutes', () => {
+ // Without this, the class fills with generic clean prose that refutes nothing and
+ // the false-positive rate stops being adversarial.
+ const unannotated = itemsOfClass('MUST_NOT_FIRE').filter((i) => !i.refutes);
+ expect(unannotated.map((i) => i.id)).toEqual([]);
+ });
+});
+
+describe('anti-narrowing anchor set', () => {
+ it('has exactly one anchor per shipped pattern', () => {
+ expect(ANCHORS.length).toBe(CRISIS_TEXT_PATTERN_SOURCES.length);
+ });
+
+ it('every anchor names a pattern that is still shipped', () => {
+ const shipped = new Set(CRISIS_TEXT_PATTERN_SOURCES);
+ const orphaned = ANCHORS.filter((a) => !shipped.has(a.patternSource));
+ expect(orphaned.map((a) => a.patternSource)).toEqual([]);
+ });
+
+ it.each(ANCHORS)('anchor $corpusId fires unconditionally', ({ corpusId }) => {
+ const item = corpus.items.find((i) => i.id === corpusId);
+ expect(item).toBeDefined();
+ expect(fires(item!.text)).toBe(true);
+ });
+});
+
+describe('measurement β reported, never thresholded (AC5)', () => {
+ it('reports MUST-FIRE recall, MUST-NOT-FIRE false-positive rate, and STT-MANGLED recall separately', () => {
+ const mustFire = itemsOfClass('MUST_FIRE');
+ const mustNotFire = itemsOfClass('MUST_NOT_FIRE');
+ const sttMangled = itemsOfClass('STT_MANGLED');
+ const knownOverFire = itemsOfClass('KNOWN_OVER_FIRE');
+
+ const mustFireHits = mustFire.filter((i) => fires(i.text));
+ const mustNotFireHits = mustNotFire.filter((i) => fires(i.text));
+ const sttHits = sttMangled.filter((i) => fires(i.text));
+
+ const pct = (n: number, d: number) => (d === 0 ? 'n/a' : `${((n / d) * 100).toFixed(1)}%`);
+
+ // KNOWN_OVER_FIRE is excluded from the false-positive denominator on purpose: it is
+ // accepted behaviour, and folding it in would let a narrowing "improve" the rate by
+ // reversing a crisis decision.
+ // eslint-disable-next-line no-console
+ console.log(
+ [
+ `\n INFRA-512 corpus ${corpus.corpusVersion}`,
+ ` MUST-FIRE recall: ${mustFireHits.length}/${mustFire.length} ${pct(mustFireHits.length, mustFire.length)}`,
+ ` MUST-NOT-FIRE false-positive: ${mustNotFireHits.length}/${mustNotFire.length} ${pct(mustNotFireHits.length, mustNotFire.length)}`,
+ ` STT-MANGLED recall: ${sttHits.length}/${sttMangled.length} ${pct(sttHits.length, sttMangled.length)}`,
+ ` KNOWN_OVER_FIRE (accepted): ${knownOverFire.filter((i) => fires(i.text)).length}/${knownOverFire.length}`,
+ ` MUST-FIRE misses: ${mustFire.filter((i) => !fires(i.text)).map((i) => i.id).join(', ') || 'none'}`,
+ ].join('\n'),
+ );
+
+ // The only assertion is that the measurement ran over a non-empty corpus. No rate is
+ // asserted β see AC5 and this file's header.
+ expect(mustFire.length + mustNotFire.length + sttMangled.length).toBeGreaterThan(0);
+ });
+});
diff --git a/docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md b/docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md
new file mode 100644
index 00000000..9a69e154
--- /dev/null
+++ b/docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md
@@ -0,0 +1,164 @@
+# INFRA-512 β Adversarial crisis-corpus review packet
+
+**Date:** 2026-08-22 Β· **Status:** harness landed; corpus generation deferred
+**Scope decision:** founder, at batch approval β land the harness, fixture schema and
+generator; do **not** make the LLM call this run.
+
+---
+
+## 1. What landed, and what it is not
+
+| Artifact | Path |
+|---|---|
+| Generator (offline, one-off, wired into nothing) | `scripts/generate-crisis-corpus.js` |
+| Seed corpus fixture | `app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json` |
+| Measurement harness | `app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts` |
+| This record | `docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md` |
+
+**The seed corpus is not the baseline, and its numbers must not be quoted as one.** Every
+item in it is drawn verbatim from strings already human-reviewed and committed in
+`textCrisisDetection.unit.test.ts` β i.e. phrases the detector is *already known* to handle
+correctly. Measured on the seed:
+
+```
+INFRA-512 corpus v1-seed
+ MUST-FIRE recall: 12/12 100.0%
+ MUST-NOT-FIRE false-positive: 0/4 0.0%
+ STT-MANGLED recall: 2/2 100.0%
+ KNOWN_OVER_FIRE (accepted): 1/1
+ MUST-FIRE misses: none
+```
+
+Those figures are a **self-test of the harness**, confirming it is wired to the real
+detector and reports three separate rates. They say nothing about recall against
+adversarial input. Section 3 is what the real baseline will look like.
+
+## 2. Why generation was deferred
+
+`ANTHROPIC_API_KEY` is not present in this environment (`printenv | grep -ci anthropic` β 0;
+absent from `~/.zshenv`, `~/.zshrc`, `~/.profile`), and there is no root `package.json`, so
+the SDK cannot be installed anywhere that would not break the FEAT-287 pin. The generator
+therefore fails loudly on a missing key rather than degrading to an empty corpus β a
+silently empty fixture is exactly what would make the harness report a meaningless 100%.
+
+## 3. VERIFIED MUST-FIRE misses β first candidate batch, awaiting review
+
+These are **not** in the fixture. They were derived and verified during the INFRA-512
+planning pass by executing the six compiled patterns from `CRISIS_TEXT_PATTERN_SOURCES`
+directly against each string. They are recorded here so the finding is not lost, and they
+are the natural first batch to review into the corpus.
+
+| Candidate text | Result | Why it misses |
+|---|---|---|
+| `i keep thinking about killing myself` | **MISS** | pattern is `kill\s*my\s*self`; the gerund puts `ing` where `my` must be |
+| `i have been thinking about ending it all` | **MISS** | same morphology break on `end\s*it\s*all` |
+| `i cant go on anymore` | **MISS** | the normalizer folds smart apostrophes but never expands contractions; pattern is `can\s*not\s*go\s*on` |
+| `suicide` | **MISS** | only `suicidal` is a pattern |
+| `i want to end my life` | **MISS** | no pattern covers this phrasing at all |
+| `i wish i was dead` | **MISS** | no pattern covers this phrasing at all |
+
+Controls run in the same pass, all **FIRE** as expected: `i can not go on`, `i feel
+suicidal`, `i want to die`, `there is no point living`. The matcher was therefore live β
+these are real negatives, not a broken harness.
+
+### 3a. The module's stated guarantee is falsified by the first three rows
+
+`textCrisisDetection.ts`'s header says:
+
+> THE GUARANTEE IS SCOPED. Zero-false-negative holds over correctly-transcribed text.
+
+Every string above is correctly-transcribed text expressing self-harm intent. The sentence
+is not true as written. **Correcting it was deliberately excluded from this run** β the
+founder chose the test-only option, and editing `textCrisisDetection.ts` (a non-test file
+under `features/crisis/`) would re-tier the item to a human-attended simulator close.
+
+**This is the single most important item to carry forward.** A repo that ships a
+measurement contradicting an adjacent guarantee is worse than one that ships neither. The
+follow-up should rewrite the header to a claim that is true β most of the misses are
+*morphological variants of already-approved phrases*, which is a materially different
+(and lower alarm-fatigue) remediation tier than adding new vocabulary β and qualify
+`CLAUDE.md`'s unqualified "Crisis detection: zero false negatives" line, which currently
+reads as covering this path.
+
+## 4. Disposition enum for MUST-FIRE misses
+
+Every miss triaged into the corpus takes exactly one:
+
+- `detected` β no longer a miss.
+- `accepted-miss-mitigated-elsewhere` β names the compensating control it rests on (the
+ always-reachable root crisis affordance; the low-confidence-transcript support line).
+- `pattern-candidate-deferred-to-crisis-pass` β a widening is plausible but needs its own
+ item and a `crisis` ruling.
+- `out-of-scope-stt-layer` β recognizer error, outside this module's stated contract.
+
+**Widening `CRISIS_TEXT_PATTERN_SOURCES` is never the automatic remedy.** That constant
+feeds `journalCrisisScanner.scan`, which fires `showCrisisAlert()` β widening it to move a
+number buys alarm fatigue on a journaling surface, which degrades the response to true
+positives. Any widening is a separate item with a crisis pass.
+
+## 5. Merge protocol for reviewed items
+
+1. `node scripts/generate-crisis-corpus.js --class --count N` β writes
+ `verdict: "PENDING"` candidates to a path **outside** the repo (the script refuses a
+ destination inside it).
+2. Review every item by hand. The model drafts; the reviewer decides the label. Do not
+ bulk-accept β Β§6 is proof the correct label is not self-evident.
+3. Append accepted items to the fixture with `review.reviewer`, `review.reviewedOn` and
+ `review.verdict` filled in. The harness fails on any item lacking a verdict.
+4. Re-run `npm run test:crisis-detection` and record the three figures in a dated successor
+ to this document.
+
+## 6. Two traps for whoever reviews the corpus
+
+**Do not label negations `MUST_NOT_FIRE`.** `textCrisisDetection.unit.test.ts` pins that
+`"I don't want to die"` **must** trigger, as a deliberate Slice A decision: a recognizer
+that drops the negation turns a negated sentence into a disclosure, so trusting negation
+converts a transcription error into a missed crisis. Negated phrasing is the most obvious
+"near-miss" a generator will produce, and mislabelling it would put standing pressure on a
+crisis decision in order to improve a false-positive rate. That is what the
+`KNOWN_OVER_FIRE` class exists to absorb β it is reported separately and excluded from the
+false-positive denominator.
+
+**`MUST_NOT_FIRE` items must be adversarially adjacent.** Each must name, in `refutes`, the
+specific naive widening it rules out. Generic calm journaling contributes nothing to a
+false-positive rate and lets the class be padded.
+
+## 7. Open questions not decided this run
+
+- **Should the harness gain a drift pin** β failing when measured rates diverge from a
+ committed baseline? It would be explicit rather than silent (mirroring
+ `performance-baselines.json` and `ci-uncovered-tests.json`), but it means an unrelated
+ detector change turns CI red until the baseline is re-recorded. Not implemented; AC5 says
+ "recorded, not enforced", and inventing an enforcement policy the founder did not choose
+ would exceed the item.
+- **STT-MANGLED has no ground truth in this repo.** Nothing captures real recognizer error
+ distributions, and nothing can β transcripts are wellness data and the analytics boundary
+ test exists to keep them out of every sink. The recognizer is Apple's on-device
+ `SFSpeechRecognizer`, so "realistic" means "an LLM's guess at its error modes". Label the
+ STT-MANGLED figure as indicative, never as a measurement of on-device behaviour.
+- **`premeditationSafetyService.ts` keeps a private duplicate keyword array** that imports
+ nothing from the shared constant. Any future widening must state whether premeditation is
+ in scope, or the two vocabularies diverge further in the direction the parity guard does
+ not cover.
+
+## 8. What the harness enforces, and what it deliberately does not
+
+**Hard-fails on:** a malformed or empty fixture; any item missing a review verdict; a
+duplicate id; an `STT_MANGLED` item whose `sourceId` does not resolve; a `MUST_NOT_FIRE`
+item that names no `refutes`; and the **anchor set** β one canonical bare phrase per shipped
+pattern, which must all fire and must stay the same size as `CRISIS_TEXT_PATTERN_SOURCES`.
+
+The anchor set is the anti-narrowing lock: it makes the harness impossible to "pass" by
+shrinking the detector rather than improving it. It was verified by mutation on 2026-08-22
+β removing the `no\s*point\s*living` pattern turned three assertions red
+(`has exactly one anchor per shipped pattern`, `every anchor names a pattern that is still
+shipped`, `anchor mf-no-point-living fires unconditionally`), and the mutation was reverted.
+
+**Asserts no threshold on any of the three rates.** That is AC5. A threshold set before the
+number is known either passes vacuously or blocks every PR.
+
+**Note on CI selection:** this file *is* selected by CI. Every path under `features/crisis/`
+matches `test:crisis-quick`'s `--testPathPattern="[Cc]risis"`, which CI runs via
+`validate:crisis-authority` (`ci.yml:210`) with `--silent --testTimeout=5000`. Measured at
+0.706s under those exact flags. Because `--silent` swallows console output, **the console
+figures are not the record β this document is.**
diff --git a/scripts/generate-crisis-corpus.js b/scripts/generate-crisis-corpus.js
new file mode 100644
index 00000000..01ba5ea7
--- /dev/null
+++ b/scripts/generate-crisis-corpus.js
@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+/**
+ * INFRA-512 β offline adversarial corpus generator for free-text crisis detection.
+ *
+ * WHY THIS LIVES AT THE REPO ROOT AND HAS ZERO DEPENDENCIES.
+ * `app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts` pins that no
+ * `@anthropic-ai/*` package may enter `app/package.json`, and there is no root
+ * package.json β repo-root `scripts/` is this repo's established dependency-free zone
+ * (legal-registry.js, supabase-deploy-drift.js, β¦). So this calls the Messages API over
+ * raw HTTP with Node's global fetch rather than the official SDK. That is a constraint of
+ * the work item, not a style preference: installing the SDK anywhere reachable from the
+ * app would break the pin, and inventing a root manifest would put an SDK at a path the
+ * CI npm-audit gate never scans.
+ *
+ * THIS IS A ONE-OFF, ATTENDED SCRIPT. It is deliberately wired into NOTHING β no npm
+ * script, no CI job, no test. It makes a network call and costs money; a test or a gate
+ * must never invoke it.
+ *
+ * IT NEVER WRITES INTO THE REPO. Two independent reasons, both hard:
+ * 1. Unreviewed generated self-harm phrasing must not enter git history. INFRA-512 AC3
+ * requires every corpus item be human-reviewed BEFORE it lands.
+ * 2. `app/scripts/e2e-provenance.js` fingerprints untracked file contents repo-wide, so
+ * a candidate file dropped in the worktree reads as MISMATCH on the next gate verify
+ * and costs a full rebuild.
+ * The refusal below is enforced, not documented.
+ *
+ * WORKFLOW
+ * 1. node scripts/generate-crisis-corpus.js --class MUST_FIRE --count 40
+ * 2. Review every candidate by hand. The reviewer decides the label; the model drafts it.
+ * 3. Append accepted items to
+ * app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
+ * with review.reviewer / review.reviewedOn / review.verdict filled in.
+ * 4. Re-run `npm run test:crisis-detection` and record the three figures in the audit doc.
+ *
+ * DO NOT let a bad recall number drive a widening of CRISIS_TEXT_PATTERN_SOURCES. That
+ * constant feeds journalCrisisScanner.scan β showCrisisAlert(); widening it for recall
+ * buys alarm fatigue on the surface where it costs most. Any widening is its own item
+ * with a crisis pass.
+ */
+
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const API_URL = 'https://api.anthropic.com/v1/messages';
+const MODEL = 'claude-opus-5';
+const ANTHROPIC_VERSION = '2023-06-01';
+const PROMPT_VERSION = 'infra-512-v1';
+
+const CLASS_BRIEFS = {
+ MUST_FIRE:
+ 'Paraphrases and indirect expressions of suicidal ideation or self-harm intent that a ' +
+ 'person might plausibly write in a private reflective journal. Vary the grammar: ' +
+ 'gerunds, contractions, first person past and present, hedged and indirect phrasings. ' +
+ 'Express intent or ideation ONLY. Do not include means, methods, dosages, timing, or ' +
+ 'anything instructional β the detector is a substring matcher and none of that is ' +
+ 'relevant to it.',
+ MUST_NOT_FIRE:
+ 'Ordinary language that must NOT be flagged, chosen adversarially: near-misses that ' +
+ 'share vocabulary with a crisis disclosure but carry no self-harm meaning ("this ' +
+ 'deadline is killing me", "I could die of embarrassment", "my phone died"). Each item ' +
+ 'must name, in its `refutes` field, the specific naive widening it would refute. ' +
+ 'Generic calm journaling is NOT useful here and will be rejected.',
+ STT_MANGLED:
+ 'Realistic speech-to-text corruptions of a crisis disclosure: dropped or fused word ' +
+ 'boundaries, homophone substitutions, dropped function words, and missing negation. ' +
+ 'These are what an on-device recognizer plausibly emits for a spoken disclosure. Put ' +
+ 'the clean sentence you mangled in the `sourceText` field.',
+};
+
+function usage(msg) {
+ if (msg) console.error(`\nERROR: ${msg}`);
+ console.error(`
+Usage: node scripts/generate-crisis-corpus.js --class [--count N] [--out FILE]
+
+ --class One of: ${Object.keys(CLASS_BRIEFS).join(', ')}
+ --count Candidates to request (default 30)
+ --out Destination file. MUST be outside this repository.
+ Default: ${path.join(os.tmpdir(), 'infra-512-candidates-.json')}
+
+Requires ANTHROPIC_API_KEY in the environment. Never run this from a test or from CI.
+`);
+ process.exit(msg ? 1 : 0);
+}
+
+function parseArgs(argv) {
+ const out = { count: 30 };
+ for (let i = 0; i < argv.length; i += 1) {
+ const a = argv[i];
+ if (a === '--help' || a === '-h') usage();
+ else if (a === '--class') out.cls = argv[++i];
+ else if (a === '--count') out.count = Number(argv[++i]);
+ else if (a === '--out') out.out = argv[++i];
+ else usage(`unrecognised argument: ${a}`);
+ }
+ return out;
+}
+
+/** Fail closed if the destination is anywhere inside this repository. */
+function assertOutsideRepo(dest) {
+ const repoRoot = path.resolve(__dirname, '..');
+ const resolved = path.resolve(dest);
+ const rel = path.relative(repoRoot, resolved);
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
+ console.error(
+ `\nREFUSED: --out resolves inside the repository (${resolved}).\n\n` +
+ 'Unreviewed generated crisis phrasing must not enter git history (AC3), and an\n' +
+ 'untracked file under the worktree invalidates the e2e provenance fingerprint,\n' +
+ 'costing a full gate rebuild. Write to a path outside the repo and review there.\n',
+ );
+ process.exit(2);
+ }
+}
+
+function buildPrompt(cls, count) {
+ return [
+ 'You are helping build a labelled EVALUATION CORPUS that measures the recall of a',
+ "mental-wellness app's free-text crisis detector. The corpus is a test fixture. It is",
+ 'never shown to a user and never used to generate user-facing content. Its purpose is',
+ 'to find cases where the detector FAILS to notice a disclosure, so those gaps can be',
+ 'measured and reasoned about rather than assumed away.',
+ '',
+ `Produce exactly ${count} distinct candidate items for the class ${cls}.`,
+ '',
+ CLASS_BRIEFS[cls],
+ '',
+ 'Return ONE JSON object and nothing else β no prose, no markdown fence:',
+ '{"items":[{"text":"β¦","rationale":"β¦"' +
+ (cls === 'MUST_NOT_FIRE' ? ',"refutes":"β¦"' : '') +
+ (cls === 'STT_MANGLED' ? ',"sourceText":"β¦"' : '') +
+ '}]}',
+ '',
+ 'Every item must be a plausible thing a real person would write or say. No duplicates.',
+ 'Do not include real names or any identifying detail.',
+ ].join('\n');
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (!args.cls || !CLASS_BRIEFS[args.cls]) usage('--class is required and must be a known class');
+ if (!Number.isFinite(args.count) || args.count < 1) usage('--count must be a positive number');
+
+ const apiKey = process.env.ANTHROPIC_API_KEY;
+ if (!apiKey) {
+ console.error(
+ '\nERROR: ANTHROPIC_API_KEY is not set.\n\n' +
+ 'This script cannot run unattended and deliberately does not degrade to an empty\n' +
+ 'corpus β a silently empty fixture would make the harness report a meaningless\n' +
+ '100% recall. Export a key and re-run.\n',
+ );
+ process.exit(1);
+ }
+
+ const dest =
+ args.out || path.join(os.tmpdir(), `infra-512-candidates-${args.cls.toLowerCase()}.json`);
+ assertOutsideRepo(dest);
+
+ const runDate = new Date().toISOString().slice(0, 10);
+ console.error(`Requesting ${args.count} ${args.cls} candidates from ${MODEL}β¦`);
+
+ const res = await fetch(API_URL, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-api-key': apiKey,
+ 'anthropic-version': ANTHROPIC_VERSION,
+ },
+ body: JSON.stringify({
+ model: MODEL,
+ max_tokens: 16000,
+ output_config: { effort: 'high' },
+ messages: [{ role: 'user', content: buildPrompt(args.cls, args.count) }],
+ }),
+ });
+
+ if (!res.ok) {
+ console.error(`\nERROR: API returned ${res.status}\n${await res.text()}\n`);
+ process.exit(1);
+ }
+
+ const body = await res.json();
+
+ // Guard the refusal path explicitly: a decline is HTTP 200 with stop_reason "refusal",
+ // so reading .content without checking would silently produce an empty corpus.
+ if (body.stop_reason === 'refusal') {
+ console.error(
+ `\nERROR: the request was declined (stop_reason: refusal).\n` +
+ `${JSON.stringify(body.stop_details || {}, null, 2)}\n\n` +
+ 'Nothing was written. Do not retry blindly β re-read the class brief and narrow it.\n',
+ );
+ process.exit(1);
+ }
+
+ const text = (body.content || [])
+ .filter((b) => b.type === 'text')
+ .map((b) => b.text)
+ .join('');
+
+ let parsed;
+ try {
+ // Defensive: strip a markdown fence if the model wrapped the object despite the ask.
+ const cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+ parsed = JSON.parse(cleaned);
+ } catch (err) {
+ console.error(`\nERROR: response was not parseable JSON β ${err.message}\n`);
+ console.error(text.slice(0, 2000));
+ process.exit(1);
+ }
+
+ const items = (parsed.items || []).map((it, i) => ({
+ id: `${args.cls.toLowerCase().replace(/_/g, '-')}-cand-${String(i + 1).padStart(3, '0')}`,
+ class: args.cls,
+ text: it.text,
+ ...(it.sourceText ? { sourceText: it.sourceText } : {}),
+ ...(it.refutes ? { refutes: it.refutes } : {}),
+ rationale: it.rationale,
+ provenance: { source: 'anthropic-api', model: MODEL, promptVersion: PROMPT_VERSION, runDate },
+ review: { reviewer: null, reviewedOn: null, verdict: 'PENDING' },
+ }));
+
+ fs.writeFileSync(
+ dest,
+ `${JSON.stringify({ class: args.cls, model: MODEL, promptVersion: PROMPT_VERSION, runDate, items }, null, 2)}\n`,
+ );
+
+ console.error(`\nWrote ${items.length} PENDING candidates to ${dest}`);
+ console.error(
+ 'Every item is verdict:PENDING. Review each by hand, then append accepted ones to the\n' +
+ 'fixture with the reviewer recorded. Do NOT bulk-accept.\n',
+ );
+}
+
+main().catch((err) => {
+ console.error(`\nUNCAUGHT: ${err && err.stack ? err.stack : err}\n`);
+ process.exit(1);
+});
From 836acb8a6a7d1ed068786f96af0907385afb9594 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:57:16 -0700
Subject: [PATCH 07/90] =?UTF-8?q?test:=20DEBUG-516=20drop=20the=20drag-dis?=
=?UTF-8?q?missal=20block=20=E2=80=94=20it=20loses=20a=20race=20with=20a?=
=?UTF-8?q?=20pre-existing=20abort?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
MEASURED on the gate simulator, 3 of 3 runs: ExpoSpeechRecognizer.start ->
prepareMicrophoneRecognition -> AVAudioEngine.inputNode -> AURemoteIO::Cleanup
-> _ReportRPCTimeout -> abort(). A blocking RPC to the simulator's audio
daemon, entered on the journal-record-button tap, SIGABRTing the app ~15s later
wherever the flow has reached.
NOT a DEBUG-516 regression: crash reports carrying this exact stack predate
this branch (2026-08-21 02:14, 02:20, 02:22, 02:26; 2026-08-22 04:25, 04:31),
and nothing in this item touches handleStart or the speech guard. What the
added block did was lengthen the record -> assert window past ~15s, so the
abort started landing inside the flow instead of after it. It surfaces as
"App crashed or stopped while executing flow" attributed to whichever step was
executing β in one run it surfaced instead as a bare "Element not found",
so a green run is not evidence the audio path was healthy.
The flow keeps AC 1 and AC 4 and nothing else, so the whole record -> save ->
journal-saved-state sequence sits inside the window. The header records the
bound, so nothing is inserted there later without re-measuring it.
Recorded as a STATED coverage gap rather than pinned green by a weaker
assertion: that keyboardDismissMode='on-drag' actually dismisses is now
unverified on device. It matters because it is this surface's only keyboard
dismissal and, while DEBUG-506 is open, the only route back to a state where
the root crisis button is reachable.
The abort itself needs its own item; it is a product-visible crash on the
record path, not a harness defect.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01KZaGesrhCZM64DRoA7DeXh
---
.../profile-voice-reflection-xxxl.yaml | 59 +++++++------------
1 file changed, 21 insertions(+), 38 deletions(-)
diff --git a/app/.maestro/profile-voice-reflection-xxxl.yaml b/app/.maestro/profile-voice-reflection-xxxl.yaml
index b2ddc160..2ce8dd30 100644
--- a/app/.maestro/profile-voice-reflection-xxxl.yaml
+++ b/app/.maestro/profile-voice-reflection-xxxl.yaml
@@ -115,46 +115,29 @@ name: "Profile -> VoiceReflection at XXXL (DEBUG-507): the save-time crisis scan
- assertVisible:
id: "UIKeyboardLayoutStar Preview"
-# ββ THE DRAG DISMISSAL, FIRST β AND IT CANNOT BE A SECOND CAPTURE ββββββββββββ
-# `keyboardDismissMode='on-drag'` is this surface's ONLY keyboard dismissal: the transcript
-# field is multiline, so Return inserts a newline and there is no Done key. While DEBUG-506
-# is open there is no working keyboard-up 988 affordance here either, so dismissal is also
-# the only route back to a state where the root crisis button is reachable. DEBUG-516 shrank
-# the scroll region β prose and transcript now yield space to the pinned footer β so "the
-# content still scrolls, therefore the drag still dismisses" stopped being free. If the
-# region ever stops being draggable the mitigation dies silently.
+# ββ WHY THERE IS NO DRAG-DISMISSAL BLOCK HERE, AND WHAT IS THEREFORE UNPINNED ββ
+# One was written and REMOVED, because it could not be made to pass for a reason that has
+# nothing to do with what it asserts. MEASURED on the gate sim, 3 runs of 3:
+# `ExpoSpeechRecognizer.start -> prepareMicrophoneRecognition -> AVAudioEngine.inputNode ->
+# AURemoteIO::Cleanup -> _ReportRPCTimeout -> abort()` β a BLOCKING RPC to the simulator's
+# audio daemon, entered on the journal-record-button tap, which SIGABRTs the app roughly 15
+# SECONDS LATER wherever the flow has got to by then. Crash reports with this exact stack
+# predate this branch (2026-08-21 02:14 onward), so it is not a DEBUG-516 regression β but
+# it does bound how long a flow may linger on this surface after tapping record.
#
-# WHY IT RUNS BEFORE THE SAVE AND INSIDE THE SAME CAPTURE. A first draft put this AFTER
-# `journal-saved-state` as a fresh capture, because `swipe` destroys the keyboard-up
-# condition the save block tests. That flow FAILED with `Element not found:
-# journal-record-button` β the `saved` phase is TERMINAL BY DESIGN and offers no affordance
-# back to `idle`, which VoiceReflectionScreen's own DEBUG-504 comment states outright. Do
-# not "fix" a future failure here by adding one: any route back to `recording` that does not
-# go through handleStart inherits the previous capture's draftId and silences its
-# disclosure. That is the defect DEBUG-504 fixed.
+# Everything below therefore runs INSIDE that window, and nothing may be added between the
+# record tap and `journal-saved-state` without re-measuring it. If this flow starts failing
+# with "App crashed or stopped while executing flow", check ~/Library/Logs/DiagnosticReports
+# for that stack BEFORE suspecting the app's layout: the failure lands on whichever step was
+# executing, so it impersonates a defect in an unrelated assertion.
#
-# Dragging DOWN from the header rather than UP: at the top of the region this is an
-# overscroll bounce, which still begins a drag (so `on-drag` fires) without carrying the
-# transcript field off screen and stranding the steps below it.
-- swipe:
- from:
- id: "journal-review-header"
- direction: DOWN
-- extendedWaitUntil:
- notVisible:
- id: "UIKeyboardLayoutStar Preview"
- timeout: 8000
-
-# Re-raise, so the save below is measured against a freshly-presented keyboard AND a scroll
-# region the drag has already moved. That is strictly stronger than the pristine state the
-# first draft would have tested: it is the pinned footer, not a lucky content offset, that
-# has to keep Save reachable.
-- tapOn:
- id: "journal-transcript-input"
-- extendedWaitUntil:
- visible:
- id: "UIKeyboardLayoutStar Preview"
- timeout: 8000
+# STILL UNPINNED as a result: that `keyboardDismissMode='on-drag'` actually dismisses. That
+# matters because it is this surface's only keyboard dismissal (multiline field, so Return
+# inserts a newline and there is no Done key), and while DEBUG-506 is open it is also the
+# only route back to a state where the root crisis button is reachable. DEBUG-516 shrank the
+# scroll region, so "the content still scrolls, therefore the drag still dismisses" stopped
+# being free. Recorded as a stated coverage gap rather than pinned green by a shorter
+# assertion that would not have tested it.
# AC 1 + AC 4 β SAVE AT ITS RESTING POSITION, KEYBOARD UP, AT THE LARGEST NON-ACCESSIBILITY
# TEXT SIZE. No scroll, no dismissal, no visibility assertion on the button itself. The
From 5c5c3f9202d1c6e12d2355ddf3599ffde3f3a6c3 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:21:48 -0700
Subject: [PATCH 08/90] chore: DEBUG-516 declare the new hook suite in the
CI-coverage ledger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
check:ci-test-coverage failed on src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx:
it matches no CI --testPathPattern, so it would run on nobody's pull request.
Declared rather than wired, following the ledger's own convention. Its consumer's
suite β VoiceReflectionScreen.behavioral.test.tsx β is already listed for exactly
the same reason (co-located under src/**, reached by no pattern), and wiring the
remainder is a deferred follow-up blocked on the assessmentStore load-dependent
test recorded in wiring-them-is-deferred. Inventing a pattern for one file would
change the CI contract for every PR in the repo, which is not this item's scope.
NOT fixed by renaming toward a pattern β that is forbidden by the ledger's own
do-not-fix-by-renaming entry, and coverage-by-filename is the defect INFRA-368
exists to document.
The why entry records what the absence actually costs, so the next reader does
not have to re-derive it: the behavioural contract IS gated (Phase 2.5 runs
journal-crisis-scan on any features/journal change), so what is missing is the
subscription-shape and snapshot pins, not the reachability contract.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01KZaGesrhCZM64DRoA7DeXh
---
app/scripts/ci-uncovered-tests.json | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/scripts/ci-uncovered-tests.json b/app/scripts/ci-uncovered-tests.json
index 4561f0a2..0e974cc6 100644
--- a/app/scripts/ci-uncovered-tests.json
+++ b/app/scripts/ci-uncovered-tests.json
@@ -17,11 +17,13 @@
"do-not-fix-by-renaming": "The tempting shortcut β rename a file so it matches an existing pattern β is forbidden. Coverage-by-filename is the exact defect this item documents: 6 of the 12 files under app/__tests__/safety/ run in CI today ONLY because their names contain 'crisis'. Renaming toward a pattern is as fragile as renaming away from one, and dragging a file into test:crisis-quick also subjects it to that job's much shorter --testTimeout=5000. Fix the pattern, never the filename.",
"src/core/config/env.test.ts": "Worth singling out: the only full env-schema coverage in the repo, and it is not even under a __tests__/ directory, so any 'just add a src/**/__tests__ pattern' shortcut would still miss it.",
"caught-in-flight": "DailyLoopCompleteScreen.completionRegister.test.tsx was added to this list by the gate itself, not by hand. It landed on development from a sibling work item WHILE INFRA-368 was in progress, and back-merging origin/development into this branch made `npm run check:ci-test-coverage` fail immediately: a brand-new test file matching no CI pattern, i.e. running on nobody's pull request. That is this item's thesis reproducing itself within hours on unrelated work, and it is the argument for keeping this ledger enforced rather than reporting-only β nobody would have noticed otherwise.",
- "practices-wired-debug-468": "DEBUG-468 shrank this list by 10 (55 -> 45) by adding a `test:practices` step to the Unit + integration job. Three of the ten were landing in the same PR; the other seven had run on nobody's pull request since they were written. Scoped to src/features/practices/ and NOT widened to src/**, because the general case is still blocked by the assessmentStore.test.ts flake recorded in wiring-them-is-deferred."
+ "practices-wired-debug-468": "DEBUG-468 shrank this list by 10 (55 -> 45) by adding a `test:practices` step to the Unit + integration job. Three of the ten were landing in the same PR; the other seven had run on nobody's pull request since they were written. Scoped to src/features/practices/ and NOT widened to src/**, because the general case is still blocked by the assessmentStore.test.ts flake recorded in wiring-them-is-deferred.",
+ "src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx": "DEBUG-516. Same class as its consumer's suite, src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx, which is already listed here: co-located under src/**, so no CI --testPathPattern reaches it, and it matches none of precommit's patterns either (unit|safety|clinical|privacy) β so it runs on no PR and in no hook, only on demand. NOT fixed by renaming toward a pattern (see do-not-fix-by-renaming). Worth flagging when the deferred wiring happens: this hook is the single source for the keyboard frame that VoiceReflectionScreen's pinned action footer is inset against, and that footer is the only route to scanOnSave, the app's sole crisis scan of text a user typed or corrected. The behavioural contract IS gated β Phase 2.5 runs journal-crisis-scan on any features/journal change β so this suite's absence costs the subscription-shape and snapshot pins, not the reachability contract."
},
"uncovered": [
"src/core/config/__tests__/env.quick.test.ts",
"src/core/config/env.test.ts",
+ "src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx",
"src/core/navigation/__tests__/dailyLoopDeepLink.test.ts",
"src/core/services/data-retention/__tests__/DataRetentionService.test.ts",
"src/core/services/logging/__tests__/RateLimiter.test.ts",
From 79bce8a35e600385b574991a60e428c6235f8516 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:37:46 -0700
Subject: [PATCH 09/90] test: derive required iOS purpose strings from linked
native sources
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Enumerated permission pins cannot catch the failure that rejected v1.2.0.
app/package.json still declared expo-file-system ~56.0.7; the lockfile's
RESOLVED version moved 56.0.7 -> 56.0.9, which added a PHPhotoLibrary call,
and Apple requires NSPhotoLibraryUsageDescription whenever the symbol links.
No diff in the repo showed it.
This scans each package's own ios/ sources for purpose-string APIs and fails
closed on any that is present but undeclared in app.json β inverting
speechRecognitionPermissions.config.test.ts, which asserts a hand-written list.
Exemptions are explicit and carry a reason. One today: expo-sensors, whose
CMPedometer reference is unreachable (only useBugReportShake.ts consumes it,
via Accelerometer/CMMotionManager, which needs no purpose string).
Includes the guards a source-shape assertion needs to stay honest: a
non-trivial-scan floor, a known-bad literal the matcher must still match, and
a check that every exemption names a module that still ships native sources.
Verified red by deleting the key: names 'NSPhotoLibraryUsageDescription
(required by: expo-file-system)'.
Runs in test:safety, so precommit and the CI Safety + privacy gates job.
π€ Generated with [Claude Code](https://claude.com/claude-code)
---
.../nativePurposeStrings.config.test.ts | 208 ++++++++++++++++++
1 file changed, 208 insertions(+)
create mode 100644 app/__tests__/safety/nativePurposeStrings.config.test.ts
diff --git a/app/__tests__/safety/nativePurposeStrings.config.test.ts b/app/__tests__/safety/nativePurposeStrings.config.test.ts
new file mode 100644
index 00000000..f1a66183
--- /dev/null
+++ b/app/__tests__/safety/nativePurposeStrings.config.test.ts
@@ -0,0 +1,208 @@
+/**
+ * Native purpose-string static-config pin (retro item #1)
+ *
+ * Companion to `speechRecognitionPermissions.config.test.ts` and
+ * `lsApplicationQueriesSchemes.config.test.ts`, and deliberately the inverse of
+ * both. Those enumerate the keys someone knew to write down. This one DERIVES
+ * the requirement from what is actually compiled into the binary, because the
+ * failure it exists to catch is precisely the key nobody knew to add.
+ *
+ * WHY DERIVED AND NOT ENUMERATED
+ *
+ * App Store Connect rejected v1.2.0 with ITMS-90683 for a missing
+ * `NSPhotoLibraryUsageDescription`. Nothing in the repo changed to cause it:
+ * `app/package.json` still declared `expo-file-system: ~56.0.7`, but the
+ * lockfile's RESOLVED version moved 56.0.7 -> 56.0.9, and 56.0.9 added
+ * `ios/Legacy/FileSystemHelpers.swift`, which calls `PHPhotoLibrary`. Apple
+ * requires the purpose string whenever the symbol is linked, used or not.
+ *
+ * An enumerated list cannot catch that class of change by construction β the
+ * new requirement arrives from a transitive source with no diff to review. So
+ * this scans the autolinked native sources and fails closed on any
+ * purpose-string API that is present but undeclared.
+ *
+ * WHAT IT PINS
+ *
+ * 1. Every purpose-string-requiring API symbol found in a module's `ios/`
+ * sources has a matching non-empty key in `app.json`'s `ios.infoPlist`,
+ * unless that (key, module) pair is explicitly exempted below with a reason.
+ * 2. The scan itself still works β see the two self-tests. A source-shape
+ * assertion that silently matches nothing is worse than no assertion, since
+ * it reads as a pass forever.
+ *
+ * WHAT IT DOES NOT PIN
+ *
+ * Whether Apple will accept the binary. Apple's static analysis is not
+ * published and is stricter for some frameworks than others; this asserts the
+ * declaration exists, not that the review passes. It also cannot see symbols
+ * reached only through a prebuilt `.xcframework` with no source in the package.
+ *
+ * iOS is CNG (INFRA-280), so `app.json` is the sole source of the generated
+ * `Info.plist` β asserting against `app.json` is asserting against the artifact.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+
+const appJson = require('../../app.json');
+
+const NODE_MODULES = path.resolve(__dirname, '../../node_modules');
+
+/**
+ * Apple purpose-string keys and the API symbols that require them. Patterns are
+ * matched against native source text, so they name TYPES, not prose.
+ */
+const PURPOSE_STRING_APIS: ReadonlyArray<{
+ key: string;
+ pattern: RegExp;
+}> = [
+ { key: 'NSPhotoLibraryUsageDescription', pattern: /\b(PHPhotoLibrary|PHAssetCreationRequest|UIImagePickerController)\b/ },
+ { key: 'NSCameraUsageDescription', pattern: /\bAVCaptureDevice\b/ },
+ { key: 'NSMicrophoneUsageDescription', pattern: /\b(AVAudioRecorder|requestRecordPermission)\b/ },
+ { key: 'NSSpeechRecognitionUsageDescription', pattern: /\bSFSpeechRecognizer\b/ },
+ { key: 'NSLocationWhenInUseUsageDescription', pattern: /\bCLLocationManager\b/ },
+ { key: 'NSContactsUsageDescription', pattern: /\bCNContactStore\b/ },
+ { key: 'NSCalendarsUsageDescription', pattern: /\bEKEventStore\b/ },
+ { key: 'NSRemindersUsageDescription', pattern: /\bEKReminder\b/ },
+ { key: 'NSFaceIDUsageDescription', pattern: /\bLAContext\b/ },
+ { key: 'NSBluetoothAlwaysUsageDescription', pattern: /\bCBCentralManager\b/ },
+ { key: 'NSMotionUsageDescription', pattern: /\b(CMPedometer|CMMotionActivityManager|CMSensorRecorder)\b/ },
+ { key: 'NSHealthShareUsageDescription', pattern: /\bHKHealthStore\b/ },
+ { key: 'NSAppleMusicUsageDescription', pattern: /\bMPMediaLibrary\b/ },
+];
+
+/**
+ * Explicitly accepted (key, module) pairs. Each needs a reason that says why
+ * the symbol cannot reach a user-visible permission prompt. Adding a row here
+ * is a deliberate, reviewable act β which is the point.
+ */
+const EXEMPTIONS: ReadonlyArray<{
+ key: string;
+ module: string;
+ reason: string;
+}> = [
+ {
+ key: 'NSMotionUsageDescription',
+ module: 'expo-sensors',
+ reason:
+ 'Only useBugReportShake.ts consumes expo-sensors, via Accelerometer (CMMotionManager), ' +
+ 'which iOS does not gate behind a purpose string. The CMPedometer reference lives in ' +
+ 'PedometerModule.swift, which no app code reaches. Revisit if anything imports Pedometer.',
+ },
+];
+
+/** Native source files belonging to a package's own `ios/` directory. */
+function collectNativeSources(): Array<{ module: string; file: string }> {
+ const out: Array<{ module: string; file: string }> = [];
+
+ const packageDirs: Array<{ module: string; dir: string }> = [];
+ for (const entry of fs.readdirSync(NODE_MODULES, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ if (entry.name.startsWith('@')) {
+ const scopeDir = path.join(NODE_MODULES, entry.name);
+ for (const scoped of fs.readdirSync(scopeDir, { withFileTypes: true })) {
+ if (scoped.isDirectory()) {
+ packageDirs.push({ module: `${entry.name}/${scoped.name}`, dir: path.join(scopeDir, scoped.name) });
+ }
+ }
+ } else if (!entry.name.startsWith('.')) {
+ packageDirs.push({ module: entry.name, dir: path.join(NODE_MODULES, entry.name) });
+ }
+ }
+
+ const walk = (module: string, dir: string): void => {
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const e of entries) {
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ walk(module, full);
+ } else if (/\.(swift|m|mm)$/.test(e.name)) {
+ out.push({ module, file: full });
+ }
+ }
+ };
+
+ for (const { module, dir } of packageDirs) {
+ const iosDir = path.join(dir, 'ios');
+ if (fs.existsSync(iosDir)) walk(module, iosDir);
+ }
+ return out;
+}
+
+const SOURCES = collectNativeSources();
+
+/** key -> modules that reference it, excluding exempted pairs. */
+function offendersByKey(): Map> {
+ const found = new Map>();
+ for (const { module, file } of SOURCES) {
+ let text: string;
+ try {
+ text = fs.readFileSync(file, 'utf8');
+ } catch {
+ continue;
+ }
+ for (const { key, pattern } of PURPOSE_STRING_APIS) {
+ if (!pattern.test(text)) continue;
+ const exempt = EXEMPTIONS.some((x) => x.key === key && x.module === module);
+ if (exempt) continue;
+ if (!found.has(key)) found.set(key, new Set());
+ found.get(key)!.add(module);
+ }
+ }
+ return found;
+}
+
+describe('Native purpose strings β scan integrity', () => {
+ it('found a non-trivial set of native sources to scan', () => {
+ // Guards the silent-no-op failure mode: if autolinking, hoisting, or the
+ // directory layout changes such that nothing is scanned, every assertion
+ // below would pass vacuously and go on passing forever.
+ expect(SOURCES.length).toBeGreaterThan(200);
+ });
+
+ it('still matches a known-bad source string', () => {
+ const known = 'let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)';
+ const photo = PURPOSE_STRING_APIS.find((a) => a.key === 'NSPhotoLibraryUsageDescription');
+ expect(photo).toBeDefined();
+ expect(photo!.pattern.test(known)).toBe(true);
+ });
+
+ it('every exemption names a module that still ships native sources', () => {
+ // A stale exemption silently widens the gate. If the module is gone, the
+ // row must go with it.
+ const modules = new Set(SOURCES.map((s) => s.module));
+ for (const x of EXEMPTIONS) {
+ expect({ exemption: x.module, present: modules.has(x.module) }).toEqual({
+ exemption: x.module,
+ present: true,
+ });
+ }
+ });
+});
+
+describe('Native purpose strings β iOS infoPlist contract', () => {
+ const infoPlist = appJson?.expo?.ios?.infoPlist ?? {};
+ const offenders = offendersByKey();
+
+ it('declares a purpose string for every linked API that requires one', () => {
+ const missing = [...offenders.entries()]
+ .filter(([key]) => typeof infoPlist[key] !== 'string' || infoPlist[key].length === 0)
+ .map(([key, modules]) => `${key} (required by: ${[...modules].sort().join(', ')})`)
+ .sort();
+
+ // Named rather than counted: the failure message IS the fix instruction.
+ expect(missing).toEqual([]);
+ });
+
+ it.each(
+ [...offenders.keys()].sort().map((key) => [key, [...(offenders.get(key) ?? [])].sort().join(', ')]),
+ )('%s is declared (required by %s)', (key) => {
+ expect(typeof infoPlist[key as string]).toBe('string');
+ expect((infoPlist[key as string] as string).length).toBeGreaterThan(0);
+ });
+});
From 017f60e5a34ec72f9dc12d48186a3279774f67cb Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:42:28 -0700
Subject: [PATCH 10/90] fix: stop release.yml reporting green for a failed
build
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
eas build --no-wait exits 0 when EAS *accepts* the job, so the workflow's
conclusion described scheduling, not the build. v1.2.0 errored in Install
dependencies on an expired GITHUB_TOKEN while this job reported success β
nothing on GitHub ever went red, and the failure was found only by reading
eas build:list an hour after the release was tagged.
Drops --no-wait so the job carries the build's verdict, and raises
timeout-minutes 15 -> 60 to cover queue + build + submit (15 was budgeted for
the fire-and-forget exit; keeping it would have failed the job on success).
Adds a closing step stating what green does not mean: Apple can still reject
the uploaded binary during processing and reports only by email β ITMS-90683
rejected v1.2.0 minutes after a fully green submit. No workflow can gate that,
so it is said out loud instead of implied away.
π€ Generated with [Claude Code](https://claude.com/claude-code)
---
.github/workflows/release.yml | 34 +++++++++++++++++++++++++++-------
1 file changed, 27 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bfed880c..59f05e2a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -38,7 +38,10 @@ jobs:
build-and-submit:
name: EAS build + auto-submit to TestFlight
runs-on: ubuntu-latest
- timeout-minutes: 15
+ # Waits for the build (see the --no-wait note below), so this must cover
+ # EAS queue + build + submit. Observed build alone is ~7 min; queue is the
+ # variable. 15 min was the old budget for a fire-and-forget exit.
+ timeout-minutes: 60
steps:
- uses: actions/checkout@v5
@@ -114,10 +117,17 @@ jobs:
# accepts only --auto-submit / --auto-submit-with-profile, and the
# invented --submit-profile failed every real release from INFRA-146
# until DEBUG-336 (dry runs skip this step, so it went unnoticed).
- # --no-wait lets this workflow exit quickly while EAS does
- # the 20-30 min build + submit asynchronously. Failures surface in
- # the EAS dashboard, in App Store Connect, and (for EAS-side errors)
- # in this job's logs.
+ # This step deliberately does NOT pass --no-wait. It used to, and that
+ # made a red build report green: `eas build --no-wait` exits 0 the moment
+ # EAS *accepts* the job, so the workflow's conclusion described the
+ # scheduling, not the build. v1.2.0 errored in `Install dependencies` on
+ # an expired GITHUB_TOKEN while this job reported success, and nothing on
+ # GitHub ever went red. Waiting costs wall-clock and buys a real verdict.
+ #
+ # What waiting still does NOT cover: Apple's own processing of the
+ # uploaded binary. That is asynchronous and reported only by email / App
+ # Store Connect β a successful upload is not an accepted build. ITMS-90683
+ # rejected v1.2.0 minutes after a fully green submit. See the final step.
# Skipped on a workflow_dispatch with dry_run=true (validation only).
- name: Build + auto-submit to TestFlight
if: ${{ !inputs.dry_run }}
@@ -127,5 +137,15 @@ jobs:
--platform ios \
--profile production \
--auto-submit \
- --non-interactive \
- --no-wait
+ --non-interactive
+
+ # A green job above means the binary built and uploaded. It does NOT mean
+ # the build is in TestFlight β Apple can still reject it during
+ # processing, and only tells you by email.
+ - name: Report what green does and does not mean
+ if: ${{ !inputs.dry_run && success() }}
+ run: |
+ echo "Build succeeded and the binary was uploaded to App Store Connect."
+ echo "This is NOT yet a TestFlight build: Apple processing can still reject it"
+ echo "(e.g. ITMS-90683), and reports only by email / App Store Connect."
+ echo "Verify: https://appstoreconnect.apple.com/apps/6777579207/testflight/ios"
From 93ebc6bc92ad8a281026e97f81ed3a0917e84200 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 23 Aug 2026 10:23:54 -0700
Subject: [PATCH 11/90] chore: INFRA-459 main-branch-health SCOPE comment
stated drift as a defect
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The comment asserted main's workflows were stale with a specific list (retired
workflows, older NODE_VERSION, differing job-list). The v1.2.0 release synced
them, making that false β but writing "they now match" would rot just as fast:
release.yml diverged again within hours of the release, via PR #425.
Restated as the structural rule instead. main carries whatever the last release
shipped; every workflow change merged to development after that is absent there;
the gap re-opens after every release. Names the check to run rather than a
point-in-time state.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015eW4ZheBj6wVHWekhKujRz
---
.github/workflows/main-branch-health.yml | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/main-branch-health.yml b/.github/workflows/main-branch-health.yml
index 8d04874b..e8484eb6 100644
--- a/.github/workflows/main-branch-health.yml
+++ b/.github/workflows/main-branch-health.yml
@@ -44,12 +44,15 @@ name: Main Branch Health
# exactly this gate, and this probe covers exactly it.
#
# WHAT THIS PROBE DOES NOT COVER β say it here so no future reader assumes
-# coverage that is not present. `main`'s .github/workflows/ is separately stale
-# (retired workflows still active there, an older NODE_VERSION, and a
-# job-list that differs from development's). A pull_request into `main` runs
-# MAIN's ci.yml, not this repo's current one, so a green probe does NOT by
-# itself prove a hotfix PR will pass. Those gaps are tracked as INFRA-458 and
-# INFRA-459.
+# coverage that is not present. A pull_request into `main` runs MAIN's ci.yml,
+# not this repo's current one, so a green probe does NOT by itself prove a
+# hotfix PR will pass. `main` carries whatever workflows the last release
+# shipped, so any workflow change merged to `development` since then is absent
+# there. That drift is the normal state between releases, not an incident, and
+# it RE-OPENS after every release β so read
+# `git show origin/main:.github/workflows/ci.yml` rather than this repo's copy
+# before reasoning about what gates a hotfix. INFRA-458 closed the 2026-07-13
+# instance, in which `main` lacked the safety-privacy job outright for six weeks.
#
# THIS PROBE STILL CANNOT OBSERVE ITS OWN SILENCE, but something else now does:
# INFRA-460 added a liveness cross-check as a step in ci.yml's `ci-pass` job,
From c1412e02af2246168b188979dc42b78a7c174b6e Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 23 Aug 2026 10:57:53 -0700
Subject: [PATCH 12/90] docs: hotfix cuts a worktree off origin/main, which no
longer has one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The main worktree is retired β main is only ever a ref in this repo, moved by
the release and checked out by nothing. Its HEAD reflog held a single entry, its
node_modules was six weeks stale, and app/ios had never been built.
So 'cd ~/dev/being/main' names a path that does not exist, and the obvious
substitute β git checkout main in the development worktree β would move that
worktree off development, which /b-release and /b-close assume.
Cuts a throwaway worktree off origin/main instead (satisfying strict: true by
construction), spells out the env symlinks that worktree add does not create,
and closes with its removal.
π€ Generated with [Claude Code](https://claude.com/claude-code)
---
docs/development/github-flow.md | 25 +++++++++++++++++++------
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/docs/development/github-flow.md b/docs/development/github-flow.md
index e9d1637c..79328b4e 100644
--- a/docs/development/github-flow.md
+++ b/docs/development/github-flow.md
@@ -152,13 +152,26 @@ decides whether something is urgent enough to jump the queue at all, see
[`docs/emergency-deployment-triggers.md`](../emergency-deployment-triggers.md).
```bash
-cd ~/dev/being/main
-git checkout main && git pull
-git checkout -b hotfix/short-description
+# There is no standing `main` worktree β `main` is only ever a ref here. Cut a
+# throwaway worktree off origin/main; `git checkout main` inside the development
+# worktree would move it off development, which the tooling assumes.
+git -C ~/dev/being fetch origin main
+git -C ~/dev/being worktree add ~/dev/being/hotfix- -b hotfix/ origin/main
+cd ~/dev/being/hotfix-
+
+# `worktree add` does not create the env symlinks that /b-work does. Only needed
+# if the fix requires a build or the test suite:
+ln -s ../../.config/.env.production app/.env.production
+ln -s ../../.config/.env.development app/.env.development
+
# ... fix + commit ...
-git push -u origin hotfix/short-description
-gh pr create --base main --head hotfix/short-description --title "..." --body "..."
-# CI runs against main, merge when green
+git push -u origin hotfix/
+gh pr create --base main --head hotfix/ --title "..." --body "..."
+# hotfix/* is not in ci.yml's push: trigger, so the PR's `opened` event is what
+# fires CI. Merge when green.
+
+# After the PR merges AND the backport below has landed:
+git -C ~/dev/being worktree remove hotfix-
```
After the hotfix merges to main:
From 84c2837304fa248ffc594dcf68bb4209dc907592 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:30:46 -0700
Subject: [PATCH 13/90] fix: DEBUG-527 completed practice card no longer fails
WCAG AA
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The completed Daily Practice card applied `opacity: 0.5` to the whole
Pressable. React Native composites the entire subtree, so it scaled the
contrast of every descendant at once β including `semantic.text.secondary`
on the description and the `gray[400]` border chosen three lines above
precisely to clear 3:1. Both fell under AA in the state a healthy daily
user sees every single day after they practise.
Opacity is not a colour token and cannot be contrast-audited, so completion
is now expressed structurally: the filled full-width bar is replaced by a
quiet `β Done today` status line in `themeColors.primary`. That also
retires the "Complete" label, which in a high-contrast filled bar parsed as
an imperative telling the reader to complete what they had just completed.
The card remains the tap target and its accessibilityLabel/Hint still
announce completion and offer the restart, so no touch target is lost.
Press feedback (`opacity: 0.9`) is unchanged β this is scoped to the
`isCompleted` branch.
The completed state was previously unreachable from any test: the store
mock hardcoded `isCheckInCompletedToday: () => false`. It is now mutable,
mirroring the existing `mockFlags` pattern, with two controls that stay
green across the change so a red cannot be confused with a dead harness.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01XBAW7cM5qUVzcV3c6dAoYU
---
.../features/home/screens/CleanHomeScreen.tsx | 36 ++++++++--
.../CleanHomeScreen.accessibility.test.tsx | 67 ++++++++++++++++++-
2 files changed, 98 insertions(+), 5 deletions(-)
diff --git a/app/src/features/home/screens/CleanHomeScreen.tsx b/app/src/features/home/screens/CleanHomeScreen.tsx
index fef37734..24052ab1 100644
--- a/app/src/features/home/screens/CleanHomeScreen.tsx
+++ b/app/src/features/home/screens/CleanHomeScreen.tsx
@@ -71,7 +71,14 @@ const CheckInCard: React.FC = ({
// WCAG AA: gray[400] for 3:1 contrast ratio on borders
borderColor: isCurrent ? themeColors.primary : colorSystem.gray[400],
borderWidth: isCurrent ? 2 : 1,
- opacity: pressed ? 0.9 : isCompleted ? 0.5 : 1,
+ // DEBUG-527: press feedback ONLY. The `isCompleted ? 0.5` arm that used to
+ // sit here composited the whole subtree, halving the contrast of the
+ // description (`semantic.text.secondary`) and of the `gray[400]` border
+ // chosen three lines above precisely to clear 3:1 β a WCAG AA failure in
+ // the state a daily user sees every day after practising. Opacity is not a
+ // colour token and cannot be contrast-audited, so completion is expressed
+ // structurally instead (see the affordance below).
+ opacity: pressed ? 0.9 : 1,
}
]}
onPress={handlePress}
@@ -99,9 +106,21 @@ const CheckInCard: React.FC = ({
{description}
-
- {isCompleted ? 'Complete' : 'Start'}
-
+ {/* DEBUG-527: completion is a STATE, not an action. A filled, high-contrast,
+ full-width bar reading "Complete" parses as an imperative β a call to action
+ telling the reader to complete what they have already completed. The done
+ state solicits nothing, so it is a quiet status line rather than a button.
+ The card itself remains the tap target (accessibilityHint above still offers
+ the restart), so no touch target is lost by dropping the bar. */}
+ {isCompleted ? (
+
+ β Done today
+
+ ) : (
+
+ Start
+
+ )}
);
};
@@ -402,6 +421,15 @@ const styles = StyleSheet.create({
fontSize: typography.bodyRegular.size,
fontWeight: typography.fontWeight.semibold,
},
+ // DEBUG-527: the done state's affordance. Colour comes from `themeColors.primary`
+ // at the call site (the same token the Start bar fills with), so it inherits the
+ // palette rather than minting a second one. No bottom padding β the card already
+ // carries `paddingBottom: spacing[20]`.
+ completedStatus: {
+ fontSize: typography.bodyRegular.size,
+ fontWeight: typography.fontWeight.semibold,
+ paddingTop: spacing[12],
+ },
});
export default CleanHomeScreen;
\ No newline at end of file
diff --git a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
index 6e515b15..8d88f4ea 100644
--- a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
+++ b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
@@ -36,8 +36,13 @@ jest.mock('@/core/services/featureFlags', () => ({
isFeatureEnabled: (name: string) => mockFlags[name] ?? false,
}));
+// DEBUG-527: the COMPLETED card is a distinct render path carrying its own
+// contrast obligations, and it was unreachable from any test while this mock
+// hardcoded `false`. Mutable for the same reason `mockFlags` above is β pinning
+// one branch of a two-branch decision is not a contract.
+const mockPractice = { completedToday: false };
jest.mock('@/features/practices/stores/stoicPracticeStore', () => {
- const state = { isCheckInCompletedToday: () => false };
+ const state = { isCheckInCompletedToday: () => mockPractice.completedToday };
return {
useStoicPracticeStore: (selector?: (s: typeof state) => unknown) =>
selector ? selector(state) : state,
@@ -155,6 +160,66 @@ describe('DEBUG-469: the daily-loop entry point is reachable at any text size',
});
});
+describe('DEBUG-527: the completed card keeps its authored contrast', () => {
+ afterEach(() => {
+ mockPractice.completedToday = false;
+ });
+
+ it('applies no container-level opacity once today is done', () => {
+ // `opacity` on the Pressable composites the ENTIRE subtree, so it scales every
+ // descendant's contrast at once β cardDescription (semantic.text.secondary) and
+ // the gray[400] border chosen a few lines above precisely to clear 3:1. Opacity
+ // is not a colour token and cannot be contrast-audited, so the only safe value
+ // on a container holding text is none. This is the state a daily user sees
+ // every day after they practise.
+ mockPractice.completedToday = true;
+ const { getByTestId } = render( );
+ const card = flat(getByTestId('checkin-card-daily-loop').props.style) as Record;
+ expect(card.opacity ?? 1).toBe(1);
+ });
+
+ it('does not label the completed state with the imperative "Complete"', () => {
+ // In a filled, high-contrast, full-width bar, "Complete" parses as a verb β a
+ // call to action telling you to complete something you have already completed.
+ mockPractice.completedToday = true;
+ const { queryByText } = render( );
+ expect(queryByText('Complete')).toBeNull();
+ });
+
+ it('states completion as a quiet status line rather than a button', () => {
+ mockPractice.completedToday = true;
+ const { getByText } = render( );
+ expect(getByText(/Done today/)).toBeTruthy();
+ });
+
+ it('keeps the screen-reader announcement of completion and of restart', () => {
+ // The visual affordance stops being a button; the CARD is still tappable and
+ // must still say so, or the state becomes invisible to VoiceOver.
+ mockPractice.completedToday = true;
+ const { getByTestId } = render( );
+ const card = getByTestId('checkin-card-daily-loop');
+ expect(card.props.accessibilityLabel).toMatch(/completed today/);
+ expect(card.props.accessibilityHint).toMatch(/again/);
+ });
+
+ // CONTROLS β these must stay GREEN across the change. A red that fails every
+ // case is indistinguishable from a harness that never ran.
+ it('control β the NOT-completed card is untouched by this fix', () => {
+ const { getByTestId, getByText } = render( );
+ const card = flat(getByTestId('checkin-card-daily-loop').props.style) as Record;
+ expect(card.opacity ?? 1).toBe(1);
+ expect(getByText('Start')).toBeTruthy();
+ });
+
+ it('control β the completed card still renders and is still tappable', () => {
+ mockPractice.completedToday = true;
+ const { getByTestId } = render( );
+ const card = getByTestId('checkin-card-daily-loop');
+ expect(card).toBeTruthy();
+ expect(card.props.accessibilityRole).toBe('button');
+ });
+});
+
describe('CleanHomeScreen β the guidance entry point (FEAT-457)', () => {
afterEach(() => {
mockFlags['domain_guidance'] = true;
From 4d0df437acf23ecac5f38a9b8078165de5dfedc7 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:34:16 -0700
Subject: [PATCH 14/90] fix: DEBUG-519 guard the two continue taps DEBUG-465's
ruling never reached
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DEBUG-465 ruled, in this file, that "a scroll in front of `continue-button`
is now REQUIRED and is not the same act" as the scroll in front of
`daily-loop-support-line`, which stays banned forever. Only the Sphere
Sovereignty site ever got one. Quick beat 1 and deep beat 1 stayed bare, so a
Continue below the fold surfaced as a plain `element not found` β
indistinguishable from layout drift on a flow whose reds are usually layout.
Adds a boundary `scrollUntilVisible` (direction DOWN, no centerElement) at
both, making such a failure attributable. A detector, not a fix: nothing is red
today and `continueWrap`'s CRISIS_FAB_CLEARANCE is already unconditional.
The item's two stated mechanisms are both refuted, and the comments say so
rather than repeating them:
- The pinned support line cannot collide with these taps.
`showsSupportLine()` is false on Aware Presence, the beat both taps are
dispatched from; `supportLine: true` occurs only on RadicalAcceptance
StepConfigs.
- The `crisis-button-root` overlap does not reproduce here either. The 24pt
measurement at DailyLoopStepScreen.tsx derives from the support bar's 58pt
viewport reduction and scopes itself to beats where showsSupportLine() is
true. Without the bar the CTA sits below the FAB band, not inside it.
So no assertion claiming a crisis false positive was added at either site: it
could not go red, and this file already records that continue-button carries no
reachability contract.
No centerElement, deliberately. It is load-bearing at Sphere Sovereignty, where
it proves a real scroll happened and keeps the tap off the co-located support
line β untouched there. Here there is no co-located affordance, and centring
would re-position mid-content and reintroduce the DEBUG-477 swallowed tap
(probe F). As written both DEBUG-477 arms are clear: already-visible means zero
swipes; below the fold means a DOWN scroll to the last ScrollView child, which
terminates at the content boundary.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01LYpWHMMLhowjbiGR3owEY9
---
app/.maestro/daily-loop-quick-depth.yaml | 33 ++++++++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/app/.maestro/daily-loop-quick-depth.yaml b/app/.maestro/daily-loop-quick-depth.yaml
index 76e33673..685d3c6b 100644
--- a/app/.maestro/daily-loop-quick-depth.yaml
+++ b/app/.maestro/daily-loop-quick-depth.yaml
@@ -325,6 +325,31 @@ name: "DailyLoop quick-depth (FEAT-301): crisis affordance survives the shorter
id: "daily-loop-skip-breath"
- assertVisible:
id: "daily-loop-input-response"
+# ββ DEBUG-519 β one of the two `continue-button` taps DEBUG-465's ruling never reached.
+# That ruling is stated above `launchApp` at the top of this file: "A scroll in front of
+# `continue-button` is now REQUIRED and is not the same act" as the permanently-banned
+# scroll in front of `daily-loop-support-line`. Only the Sphere Sovereignty site ever got
+# one. This tap and the deep beat-1 tap stayed bare, so a Continue below the fold surfaces
+# as a plain `element not found` β indistinguishable from layout drift, on a flow whose reds
+# are usually layout. This makes such a failure attributable. IT IS A DETECTOR, NOT A FIX:
+# nothing here is red today, and `continueWrap`'s CRISIS_FAB_CLEARANCE is already unconditional.
+#
+# NO `centerElement` β not the Sphere Sovereignty shape copied carelessly, the opposite call
+# for a different beat. THERE, centring is what proves a real scroll happened, which is what
+# keeps the tap off the co-located support line; do not remove it there. HERE
+# `showsSupportLine()` is false (tenseMode.ts:147 β quick is true only on QUICK_SUPPORT_STEP),
+# so there is no co-located affordance to be kept off and centring buys nothing.
+# Both DEBUG-477 arms are clear as written: already-visible β Maestro swipes ZERO times and
+# swallows nothing; below the fold β `continue-button` is the LAST ScrollView child
+# (DailyLoopStepScreen.tsx, `continueWrap` immediately before ``), so a DOWN
+# scroll terminates at the CONTENT BOUNDARY, which clears the swallow by the documented
+# predicate. `centerElement` would re-position mid-content and reintroduce it (probe F).
+# No explicit timeout: with no futile centring to fund, this file's plain-boundary-scroll
+# convention applies. If one is ever needed, MEASURE it β do not copy the 25000 below.
+- scrollUntilVisible:
+ element:
+ id: "continue-button"
+ direction: DOWN
- tapOn:
id: "continue-button"
@@ -505,6 +530,14 @@ name: "DailyLoop quick-depth (FEAT-301): crisis affordance survives the shorter
# assertion β a `when:` block here could only ever hide a genuine regression.
- tapOn:
id: "daily-loop-skip-breath"
+# DEBUG-519 β the second bare tap. Same guard and same reasoning as the quick beat-1 site
+# above; see that block for the DEBUG-465 ruling and the DEBUG-477 boundary derivation.
+# Deep beat 1 is Aware Presence, so `showsSupportLine()` is false here too: the support line
+# belongs to beat 2 (Radical Acceptance), which is where it is asserted immediately below.
+- scrollUntilVisible:
+ element:
+ id: "continue-button"
+ direction: DOWN
- tapOn:
id: "continue-button"
# Deep beat 2 = Radical Acceptance β support line present here.
From c7b64737cc92962ec07bf3b2be963b4a31e2c85e Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:43:01 -0700
Subject: [PATCH 15/90] chore: INFRA-420 pin the guidance gate's gentle-band
tier cap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The item's premise was true when filed and is false now. FEAT-457 shipped
guidance-suppressed-handoff.yaml (bb70cb87 / cc26461f, PR #407) alongside the
consumers whose absence had blocked this item, so the SUPPRESSED branch β the
protective one, where a distressed reader must receive zero philosophy β is
already pinned end to end. Authoring a second GAD-7-seeded flow against it
would have produced two independently-seeded completions asserting one
contract, which drift apart silently.
What had no device-level pin at all is the POSITIVE side: that a reader the
gate does not suppress actually receives Tier 0 and Tier 1, and that Tier 2/3
stay capped unless allowTier2Plus is true. That is what this flow adds. The
failure it catches is over-disclosure, not crisis silence.
It costs zero assessment taps. `gentle` is the verdict for MISSING data, not
only for mid-band scores β guidanceGate.ts: "absence is never evidence of
safety, so it cannot yield full ... gentle is the only defensible answer."
The sibling records the same fact from the other side at its re-launch: "NO
clearState HERE ... clearing would make the gate answer `gentle`."
Two design points the flow's header derives rather than asserts:
- Positive assertions come FIRST. useGuidanceGate reports `pending` while
the encrypted store hydrates, and every assertNotVisible would pass
vacuously in that state and on `guidance-error`. Asserting guidance-content
and guidance-tier0 first is what makes the absences mean something.
- No scrolling. Tier 2/3 render inside the guidance-content ScrollView, and
per DEBUG-465 XCUITest retains elements clipped by a ScrollView, so
assertNotVisible at offset 0 is a real claim about whether the tier
RENDERED. A scroll would weaken it to absence-at-an-offset.
Declares 375x667 rather than `any`: the contract is viewport-independent but
the reach is not, and the only viewport on which tapping home-guidance-entry is
proven is the one the sibling certifies.
Count tripwire bumped 11 -> 12 in the same commit, as its own comment requires.
ACs 4, 5 and 6 are .claude/-only (the /b-close Step 2.5.3 mapping and the
CLAUDE.md flow count) and are gitignored on development, so they land as a
separate _bare commit. AC2's boundary cases need both axes on record β 16 real
UI taps β and are tracked in INFRA-529.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01LYpWHMMLhowjbiGR3owEY9
---
app/.maestro/guidance-gentle-tier-cap.yaml | 92 +++++++++++++++++++
.../scripts/e2e-dynamic-type.test.js | 5 +-
app/package.json | 1 +
3 files changed, 96 insertions(+), 2 deletions(-)
create mode 100644 app/.maestro/guidance-gentle-tier-cap.yaml
diff --git a/app/.maestro/guidance-gentle-tier-cap.yaml b/app/.maestro/guidance-gentle-tier-cap.yaml
new file mode 100644
index 00000000..d0f4ff35
--- /dev/null
+++ b/app/.maestro/guidance-gentle-tier-cap.yaml
@@ -0,0 +1,92 @@
+appId: fyi.being.app
+tags:
+ - safety
+name: "Guidance caps the ladder at Tier 1 for a gentle-band reader"
+# e2e-certifies: 375x667
+# INFRA-420 β the CONTRACT here is viewport-independent (a gate verdict no layout can
+# change), but the REACH is not: this flow gets to the screen by tapping
+# `home-guidance-entry`, and the only viewport on which that reach is proven is the one
+# `guidance-suppressed-handoff.yaml` certifies. Declaring `any` would claim this flow
+# certifies on a large device where the Home reach was never validated. Fail closed, the
+# same way `e2e_flow_certifies` defaults an undeclared flow to the smallest viewport.
+# Not `scrollUntilVisible` on the entry row: that is the sibling's contract to own, and a
+# scroll here would let this flow stay green through a Home regression the sibling exists
+# to catch, while also putting a mid-content swipe in front of a tap (DEBUG-477).
+---
+# WHAT THIS PINS, AND WHY IT IS NOT COVERED BY guidance-suppressed-handoff.yaml.
+#
+# `decideGuidanceAccess` has three verdicts. The sibling flow pins SUPPRESSED β the
+# protective one, where a distressed reader must receive zero philosophy. Nothing pins the
+# POSITIVE side at all: that a reader the gate does NOT suppress actually receives Tier 0
+# and Tier 1, and that Tier 2/3 stay capped unless `allowTier2Plus` is true.
+#
+# The failure this catches is over-disclosure, NOT crisis silence β a mis-wired
+# `allowTier2Plus` handing the full ladder to a gentle-band reader. Scope it as the
+# secondary gap it is; the branch where a defect means philosophy instead of crisis
+# resources is the sibling's, and it is already covered.
+#
+# ββ WHY THIS FLOW COSTS NO ASSESSMENT TAPS ββββββββββββββββββββββββββββββββββ
+# `gentle` is the verdict for MISSING data, not only for mid-band scores.
+# guidanceGate.ts states it: "absence is never evidence of safety, so it cannot yield
+# full. It is equally never evidence of crisis, so it must not yield suppressed either
+# ... gentle is the only defensible answer." So a clean launch with no assessment on
+# record IS the canonical gentle case, and needs zero seeding beyond onboarding.
+# The sibling records the same fact from the other side, at its own re-launch:
+# "NO clearState HERE. The GAD-7 result must survive; clearing would make the gate
+# answer `gentle`." This flow is that sentence, run deliberately.
+#
+# ββ THE POSITIVE ASSERTIONS COME FIRST, AND THAT ORDERING IS LOAD-BEARING βββ
+# `useGuidanceGate` reports `pending` while the encrypted store hydrates, and the screen
+# then renders `guidance-pending` β no tiers, no notice. Every `assertNotVisible` below
+# would pass VACUOUSLY in that state, and equally on `guidance-error`. A flow that can
+# only go green is worth nothing. So `guidance-content` and `guidance-tier0` are asserted
+# FIRST: they are false on pending, on error, and on suppressed, which is what makes the
+# absences that follow mean something.
+#
+# ββ NO SCROLLING, DELIBERATELY ββββββββββββββββββββββββββββββββββββββββββββββ
+# Tier 2/3 render INSIDE the `guidance-content` ScrollView. Per DEBUG-465, XCUITest drops
+# elements outside the SCREEN but RETAINS ones merely outside a ScrollView's clip, so
+# Maestro scores a clipped tier visible. `assertNotVisible: guidance-tier2` at offset 0 is
+# therefore a real assertion about whether the tier RENDERED, not about where it sits β
+# and it stays real at every viewport and Dynamic Type step. Adding a scroll would buy
+# nothing and would weaken it: after scrolling, absence proves only absence at that offset.
+- launchApp:
+ clearState: true
+ clearKeychain: true # SecureStore-backed consent persists across clearState (INFRA-179)
+- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home
+
+# The entry row, reached exactly as the sibling reaches it. If this ever goes red, the
+# regression is Home's vertical budget (DEBUG-469) and the fix is the layout β never
+# hiding the row, and never a scroll bolted on here.
+- assertVisible:
+ id: "home-guidance-entry"
+- tapOn:
+ id: "home-guidance-entry"
+
+- assertVisible:
+ id: "domain-guidance-screen"
+
+# ββ POSITIVE FIRST β these are what stop everything below being vacuous. ββββ
+# `guidance-content` is the ready-state ScrollView: absent on pending, on error, and on
+# suppressed. `guidance-tier0` carries the validation whose abuse/safety escape clause the
+# screen's docblock requires to stay first at every access level.
+- assertVisible:
+ id: "guidance-content"
+- assertVisible:
+ id: "guidance-tier0"
+- assertVisible:
+ id: "guidance-tier1"
+
+# ββ THE CAP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Tier 2/3 are gated on `decision.allowTier2Plus === true`. With no assessment on record
+# the verdict is `gentle`, so both must be ABSENT FROM THE HIERARCHY, not merely off-screen.
+- assertNotVisible:
+ id: "guidance-tier2"
+- assertNotVisible:
+ id: "guidance-tier3"
+
+# Not the suppressed branch either β this reader gets philosophy, and the notice that
+# replaces it must not be on screen. Without this a suppression regression could satisfy
+# every absence above while the positives failed for the wrong reason.
+- assertNotVisible:
+ id: "guidance-suppression-notice"
diff --git a/app/__tests__/scripts/e2e-dynamic-type.test.js b/app/__tests__/scripts/e2e-dynamic-type.test.js
index 5629fdae..b260b76c 100644
--- a/app/__tests__/scripts/e2e-dynamic-type.test.js
+++ b/app/__tests__/scripts/e2e-dynamic-type.test.js
@@ -149,12 +149,13 @@ describe('DEBUG-469 β the class stays OUT of the default safety suite', () =>
// new flow belongs in the default suite (not `safety-device-only`, not
// `safety-dynamic-type`, both of which the suite can neither select nor validly run).
// 10 β 11: FEAT-457 added guidance-suppressed-handoff.
- test('the exact-tag matcher the suite uses still selects exactly the eleven safety flows', () => {
+ // 11 β 12: INFRA-420 added guidance-gentle-tier-cap.
+ test('the exact-tag matcher the suite uses still selects exactly the twelve safety flows', () => {
const files = fs.readdirSync(MAESTRO).filter((f) => f.endsWith('.yaml') && !f.startsWith('_'));
const tagged = files.filter((f) =>
/^\s*-\s+safety\s*$/m.test(fs.readFileSync(path.join(MAESTRO, f), 'utf8'))
);
- expect(tagged).toHaveLength(11);
+ expect(tagged).toHaveLength(12);
expect(tagged).not.toContain('daily-loop-ax5-entry.yaml');
});
});
diff --git a/app/package.json b/app/package.json
index eec2374f..b63da673 100644
--- a/app/package.json
+++ b/app/package.json
@@ -104,6 +104,7 @@
"e2e:safety:keyboard-accessory": "bash scripts/e2e-safety.sh crisis-keyboard-accessory",
"e2e:safety:journal": "bash scripts/e2e-safety.sh journal-crisis-scan",
"e2e:safety:guidance": "bash scripts/e2e-safety.sh guidance-suppressed-handoff",
+ "e2e:safety:guidance-gentle": "bash scripts/e2e-safety.sh guidance-gentle-tier-cap",
"e2e:safety:consent-gate": "bash scripts/e2e-safety.sh deeplink-consent-gate",
"e2e:safety:reconsent": "bash scripts/e2e-safety.sh reconsent-stale",
"e2e:safety:reconsent-ineligible": "bash scripts/e2e-safety.sh reconsent-stale-ineligible",
From 7654b22650d0765a78c794aa3a2ec70278f3f48e Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 23 Aug 2026 19:53:51 -0700
Subject: [PATCH 16/90] feat: FEAT-288 date-range filter for journal re-read
(Slice C)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Narrowed by founder decision to date range only. Search is FEAT-530 and tags
are dropped; the scope-down comment on the item records both.
The filter reads createdAt off JournalEntryMeta, which listEntryMetadata()
already returns from one decrypt of the single index record β zero per-entry
getEntry calls, zero new storage, no new screen and no new store. Selection
lives in component state for the same reason plaintext does: a persisted filter
is a record of which spans the reader returns to.
Full-text search cannot be built this way, which is why it is a separate item.
EncryptionService disables derived-key caching because each record carries its
own salt, so every getEntry is a fresh 100k-iteration PBKDF2 and search is O(N)
derivations with no amortisation.
Preset names (last7/last30/last90/all) are reused verbatim from
features/data-export's ExportRangePreset so the two surfaces agree. The code is
deliberately NOT imported: journalAnalyticsBoundary.contract.test.ts walks the
journal feature directory, so a shared module outside it would be invisible to
the egress checks that guard this feature.
Ordering is inherited, never recomputed. listEntryMetadata() returns
newest-first and Array.filter preserves order, so there is no sort call β and a
sort is exactly where a relevance ordering would enter. Recurrence is perceived
across time, so reverse-chronological is the axis the pattern lives on.
An empty RESULT is not an empty RECORD. journal-history-empty says reflections
"will appear here", which is false for a reader who has written some and
narrowed past them, so the filtered case gets its own copy reporting the
control's effect and nothing about the reader. The filter stays mounted above
it, or the only way out of an empty result would be to leave the screen.
Buttons only β no TextInput, so the keyboard still never rises on this screen
and CrisisKeyboardAccessory is not pulled onto a surface that has never needed
it. No RN : crisis-zero-988-windows.test.tsx forbids one here
mechanically. Rows stay identical under every preset.
Also closes a pre-existing hole this feature's own AC depends on:
journalAnalyticsBoundary.contract.test.ts pinned PostHog, Supabase and raw
network egress and contained ZERO Sentry references, so
Sentry.addBreadcrumb({ message: `filter applied, ${n} results` }) passed every
check from inside features/journal. Sentry is now scanned with an empty
allow-list, captureException included β an exception thrown while handling
entry text can carry that text.
Tests: filter arithmetic with inclusive millisecond boundaries; the screen's
empty-result, escapability and decrypt-count behaviour; and a source-shape
examiner-boundary guard carrying all three DEBUG-390 rails, which these modules
need because they name the forbidden mechanisms in prose to warn readers off
them.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01LYpWHMMLhowjbiGR3owEY9
---
app/.eslint-baseline.json | 3 +
.../journalAnalyticsBoundary.contract.test.ts | 95 +++++++++++
.../journal/screens/JournalHistoryScreen.tsx | 123 ++++++++++++-
...urnalHistoryScreen.dateRange.unit.test.tsx | 161 ++++++++++++++++++
.../journalExaminerBoundary.unit.test.ts | 103 +++++++++++
.../__tests__/journalDateRange.unit.test.ts | 108 ++++++++++++
.../journal/services/journalDateRange.ts | 81 +++++++++
7 files changed, 667 insertions(+), 7 deletions(-)
create mode 100644 app/src/features/journal/screens/__tests__/JournalHistoryScreen.dateRange.unit.test.tsx
create mode 100644 app/src/features/journal/screens/__tests__/journalExaminerBoundary.unit.test.ts
create mode 100644 app/src/features/journal/services/__tests__/journalDateRange.unit.test.ts
create mode 100644 app/src/features/journal/services/journalDateRange.ts
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index 0980da75..c1e723f3 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -163,7 +163,10 @@
"src/features/insights/screens/__tests__/WellnessTrendsDetailScreen.accessibility.test.tsx": 1,
"src/features/insights/utils/__tests__/wellnessTrendData.test.ts": 1,
"src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx": 1,
+ "src/features/journal/screens/__tests__/journalExaminerBoundary.unit.test.ts": 1,
+ "src/features/journal/screens/__tests__/JournalHistoryScreen.dateRange.unit.test.tsx": 1,
"src/features/journal/services/__tests__/journalCrisisScan.unit.test.ts": 1,
+ "src/features/journal/services/__tests__/journalDateRange.unit.test.ts": 1,
"src/features/journal/services/__tests__/journalEntryStore.unit.test.ts": 1,
"src/features/journal/services/__tests__/journalErasure.privacy.test.ts": 1,
"src/features/learn/__tests__/moduleClassicalQuotes.test.ts": 1,
diff --git a/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts b/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts
index 7cfba7d7..d0aaddb5 100644
--- a/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts
+++ b/app/__tests__/privacy/journalAnalyticsBoundary.contract.test.ts
@@ -183,6 +183,101 @@ describe('no journal source performs outbound network egress', () => {
});
});
+/**
+ * Sentry β the sink this file claimed to cover and did not (FEAT-288).
+ *
+ * The scans above pin PostHog, Supabase and raw network egress. Sentry is none
+ * of those: it is a first-party-configured monitoring SDK with a live production
+ * DSN, imported elsewhere in the app as `import * as Sentry from
+ * '@sentry/react-native'`. So until now this passed every check in this file,
+ * from inside `features/journal`:
+ *
+ * Sentry.addBreadcrumb({ message: `filter applied, ${results.length} results` })
+ *
+ * That is entry-derived data leaving the device on a feature whose defining
+ * constraint is that none does. Breadcrumbs and tags are the dangerous shape
+ * rather than an obvious one: they read as diagnostics, they are attached far
+ * from the sink, and they ship with every subsequent error report.
+ *
+ * `captureException` is forbidden too, not just the deliberate-context calls. An
+ * exception thrown while handling entry text can carry that text in its message,
+ * and this feature has no error path important enough to buy that risk. The
+ * allow-list stays EMPTY for the same reason the egress one does: adding a
+ * permitted module should be a reviewable diff line here, not an invisible new
+ * file over there.
+ */
+const SENTRY_PATTERNS: ReadonlyArray = [
+ ['@sentry/ import', /@sentry\//],
+ ['Sentry.addBreadcrumb', /\bSentry\.addBreadcrumb\s*\(/],
+ ['Sentry.setContext', /\bSentry\.setContext\s*\(/],
+ ['Sentry.setTag(s)', /\bSentry\.setTags?\s*\(/],
+ ['Sentry.setUser', /\bSentry\.setUser\s*\(/],
+ ['Sentry.setExtra(s)', /\bSentry\.setExtras?\s*\(/],
+ ['Sentry.captureMessage', /\bSentry\.captureMessage\s*\(/],
+ ['Sentry.captureException', /\bSentry\.captureException\s*\(/],
+ ['Sentry.startSpan', /\bSentry\.startSpan\s*\(/],
+ ['Sentry.withScope', /\bSentry\.withScope\s*\(/],
+];
+
+/** Journal modules permitted to reach Sentry. EMPTY BY DESIGN β see above. */
+const SENTRY_ALLOWED_FILES: readonly string[] = [];
+
+describe('no journal source reaches Sentry', () => {
+ it.each(JOURNAL_SOURCES.map((f) => [f.split('/').pop() ?? f, f]))(
+ '%s sends nothing to Sentry',
+ (_label, file) => {
+ const path = file as string;
+ if (SENTRY_ALLOWED_FILES.some((allowed) => path.endsWith(allowed))) return;
+
+ const stripped = stripComments(readFileSync(path, 'utf8'));
+ expect(stripped.trim().length).toBeGreaterThan(0);
+
+ const hits = SENTRY_PATTERNS.filter(([, pattern]) => pattern.test(stripped)).map(
+ ([label]) => label
+ );
+
+ expect(hits).toEqual([]);
+ }
+ );
+
+ it('the Sentry matchers still fire against known-bad source', () => {
+ const knownBad = [
+ "import * as Sentry from '@sentry/react-native';",
+ "Sentry.addBreadcrumb({ message: 'filter applied, 12 results' });",
+ "Sentry.setContext('journal', { entries: 12 });",
+ "Sentry.setTag('range', 'last30');",
+ "Sentry.setTags({ range: 'last30' });",
+ "Sentry.setUser({ id: 'anon' });",
+ "Sentry.setExtra('preview', entry.text);",
+ "Sentry.setExtras({ preview: entry.text });",
+ "Sentry.captureMessage('journal filter used');",
+ 'Sentry.captureException(err);',
+ "Sentry.startSpan({ name: 'journal.filter' }, run);",
+ 'Sentry.withScope((scope) => scope.setTag(\'a\', \'b\'));',
+ ].join('\n');
+
+ for (const [label, pattern] of SENTRY_PATTERNS) {
+ expect([label, pattern.test(knownBad)]).toEqual([label, true]);
+ }
+ });
+
+ it('prose naming Sentry does not trip the scan', () => {
+ // This feature's modules warn readers off these calls by name, so the
+ // stripper has to be doing real work here (DEBUG-390).
+ const source = [
+ '// Never call Sentry.addBreadcrumb from this feature.',
+ '/* @sentry/react-native is forbidden here. */',
+ 'const local = 1;',
+ ].join('\n');
+
+ const stripped = stripComments(source);
+ for (const [, pattern] of SENTRY_PATTERNS) {
+ expect(pattern.test(stripped)).toBe(false);
+ }
+ expect(/\bSentry\.addBreadcrumb\s*\(/.test(`${stripped}\nSentry.addBreadcrumb({});`)).toBe(true);
+ });
+});
+
describe('no LLM client ships inside the app package', () => {
/**
* The directory scans above are rooted at the journal and speech trees, so
diff --git a/app/src/features/journal/screens/JournalHistoryScreen.tsx b/app/src/features/journal/screens/JournalHistoryScreen.tsx
index a23d93bb..fbc4b211 100644
--- a/app/src/features/journal/screens/JournalHistoryScreen.tsx
+++ b/app/src/features/journal/screens/JournalHistoryScreen.tsx
@@ -32,9 +32,20 @@
* Plaintext lives in component state only. Never a module cache, never a
* Zustand slice β a slice invites `persist`, and persisted plaintext is the
* unencrypted-and-unswept failure the store header warns about.
+ *
+ * DATE RANGE (FEAT-288 Slice C)
+ *
+ * The filter is a navigational control over `JournalEntryMeta` only β no extra
+ * decrypt, no new storage, and the selection lives in component state for the
+ * same reason plaintext does: a persisted filter is a record of which spans the
+ * reader returns to, which is the app noticing something about them.
+ *
+ * It narrows what the FlatList mounts, so it can only REDUCE the number of live
+ * plaintexts, never raise it. Rows stay identical under every preset β the row
+ * contract above is not relaxed by filtering.
*/
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, StyleSheet, Pressable, FlatList, ActivityIndicator } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack';
@@ -51,6 +62,8 @@ import {
import { getEntry, listEntryMetadata } from '../services/journalEntryStore';
import type { JournalEntryMeta } from '../services/journalEntryStore';
import { previewOf } from '../services/journalPreview';
+import { filterByRange, JOURNAL_RANGE_PRESETS } from '../services/journalDateRange';
+import type { JournalRangePreset } from '../services/journalDateRange';
type Nav = StackNavigationProp<
{ JournalEntryDetail: { entryId: string } },
@@ -104,9 +117,54 @@ function EntryRow({ meta, onOpen }: { meta: JournalEntryMeta; onOpen: (id: strin
);
}
+/**
+ * The range control. Buttons only β no `TextInput`, deliberately: this screen
+ * has none today, so the keyboard never rises here, and introducing one would
+ * pull `CrisisKeyboardAccessory` occlusion handling onto a surface that has
+ * never needed it. No RN `` either; `crisis-zero-988-windows.test.tsx`
+ * forbids one on this screen mechanically.
+ */
+function RangeFilter({
+ preset,
+ onSelect,
+}: {
+ preset: JournalRangePreset;
+ onSelect: (next: JournalRangePreset) => void;
+}) {
+ return (
+
+ {JOURNAL_RANGE_PRESETS.map(({ preset: option, label }) => {
+ const selected = option === preset;
+ return (
+ onSelect(option)}
+ >
+ {label}
+
+ );
+ })}
+
+ );
+}
+
export function JournalHistoryScreen() {
const navigation = useNavigation();
const [entries, setEntries] = useState(null);
+ const [preset, setPreset] = useState('all');
useEffect(() => {
let cancelled = false;
@@ -124,6 +182,14 @@ export function JournalHistoryScreen() {
[navigation]
);
+ // Order is INHERITED from the store's newest-first index, never recomputed
+ // here β see journalDateRange.ts. `Date.now()` is read at selection time
+ // rather than per render so the window cannot shift under a re-render.
+ const visible = useMemo(
+ () => (entries === null ? [] : filterByRange(entries, preset, Date.now())),
+ [entries, preset]
+ );
+
if (entries === null) {
return (
@@ -144,12 +210,28 @@ export function JournalHistoryScreen() {
return (
- item.id}
- contentContainerStyle={styles.listContent}
- renderItem={({ item }) => }
- />
+
+ {visible.length === 0 ? (
+ // NOT the `journal-history-empty` copy above. That one says reflections
+ // "will appear here", which is false for a reader who has written some
+ // and narrowed past them β and being told your record is empty when it
+ // is not is the kind of claim this screen must never make. This one
+ // reports the CONTROL's effect and nothing about the reader. The filter
+ // stays mounted above it, or the only way out of an empty result would
+ // be to leave the screen.
+
+
+ No reflections in this range.
+
+
+ ) : (
+ item.id}
+ contentContainerStyle={styles.listContent}
+ renderItem={({ item }) => }
+ />
+ )}
);
}
@@ -158,6 +240,33 @@ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: semantic.background.screen },
centered: { alignItems: 'center', justifyContent: 'center', padding: spacing[24] },
listContent: { padding: spacing[24], gap: spacing[8] },
+ filterRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing[8],
+ paddingHorizontal: spacing[24],
+ paddingTop: spacing[16],
+ },
+ chip: {
+ minHeight: TOUCH_TARGETS.minimum,
+ justifyContent: 'center',
+ paddingHorizontal: spacing[16],
+ borderRadius: borderRadius.medium,
+ borderWidth: 1,
+ borderColor: semantic.border.default,
+ backgroundColor: colorSystem.base.white,
+ },
+ chipSelected: {
+ borderColor: semantic.border.strong,
+ backgroundColor: colorSystem.gray[100],
+ },
+ chipLabel: {
+ fontSize: typography.bodySmall.size,
+ color: semantic.text.secondary,
+ },
+ chipLabelSelected: {
+ color: semantic.text.primary,
+ },
row: {
minHeight: TOUCH_TARGETS.minimum,
justifyContent: 'center',
diff --git a/app/src/features/journal/screens/__tests__/JournalHistoryScreen.dateRange.unit.test.tsx b/app/src/features/journal/screens/__tests__/JournalHistoryScreen.dateRange.unit.test.tsx
new file mode 100644
index 00000000..c84515fa
--- /dev/null
+++ b/app/src/features/journal/screens/__tests__/JournalHistoryScreen.dateRange.unit.test.tsx
@@ -0,0 +1,161 @@
+/**
+ * FEAT-288 Slice C β the date-range filter as the reader meets it.
+ *
+ * Asserts behaviour through the rendered tree, not the pure function
+ * (journalDateRange.unit.test.ts owns the arithmetic). What matters here is the
+ * three things the screen must not do: strand the reader in an empty result,
+ * tell them their record is empty when it is not, and decrypt more than the
+ * visible window.
+ */
+
+import React from 'react';
+import { render, fireEvent, waitFor } from '@testing-library/react-native';
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: () => ({ navigate: jest.fn() }),
+}));
+
+jest.mock('../../services/journalEntryStore', () => ({
+ listEntryMetadata: jest.fn(),
+ getEntry: jest.fn(),
+}));
+
+import { listEntryMetadata, getEntry } from '../../services/journalEntryStore';
+import { JournalHistoryScreen } from '../JournalHistoryScreen';
+
+const DAY = 24 * 60 * 60 * 1000;
+const mockList = listEntryMetadata as jest.MockedFunction;
+const mockGet = getEntry as jest.MockedFunction;
+
+/** Newest-first, as the store returns them. */
+function seed(now: number) {
+ return [
+ { id: 'today', createdAt: now - 60_000, updatedAt: now - 60_000 },
+ { id: 'week', createdAt: now - 3 * DAY, updatedAt: now - 3 * DAY },
+ { id: 'quarter', createdAt: now - 45 * DAY, updatedAt: now - 45 * DAY },
+ { id: 'ancient', createdAt: now - 300 * DAY, updatedAt: now - 300 * DAY },
+ ];
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockGet.mockResolvedValue({ id: 'x', text: 'some reflection', createdAt: 0, updatedAt: 0 });
+});
+
+describe('JournalHistoryScreen β date range', () => {
+ it('shows every entry before any filtering, defaulting to All time', async () => {
+ mockList.mockResolvedValue(seed(Date.now()));
+ const { getByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-range-all')).toBeTruthy());
+ expect(getByTestId('journal-range-all').props.accessibilityState.selected).toBe(true);
+ expect(getByTestId('journal-history-row-today')).toBeTruthy();
+ expect(getByTestId('journal-history-row-ancient')).toBeTruthy();
+ });
+
+ it('narrows the list to the selected range', async () => {
+ mockList.mockResolvedValue(seed(Date.now()));
+ const { getByTestId, queryByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-range-last7')).toBeTruthy());
+ fireEvent.press(getByTestId('journal-range-last7'));
+
+ await waitFor(() => expect(queryByTestId('journal-history-row-quarter')).toBeNull());
+ expect(getByTestId('journal-history-row-today')).toBeTruthy();
+ expect(getByTestId('journal-history-row-week')).toBeTruthy();
+ expect(queryByTestId('journal-history-row-ancient')).toBeNull();
+ });
+
+ it('keeps the newest-first order the store returned', async () => {
+ mockList.mockResolvedValue(seed(Date.now()));
+ const { getByTestId, getAllByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-range-last90')).toBeTruthy());
+ fireEvent.press(getByTestId('journal-range-last90'));
+
+ await waitFor(() => {
+ const ids = getAllByTestId(/^journal-history-row-/).map(
+ (n) => n.props.testID as string
+ );
+ expect(ids).toEqual([
+ 'journal-history-row-today',
+ 'journal-history-row-week',
+ 'journal-history-row-quarter',
+ ]);
+ });
+ });
+
+ describe('an empty RESULT is not an empty RECORD', () => {
+ it('reports the range, not the reader, and never reuses the true-empty copy', async () => {
+ const now = Date.now();
+ mockList.mockResolvedValue([
+ { id: 'ancient', createdAt: now - 300 * DAY, updatedAt: now - 300 * DAY },
+ ]);
+ const { getByTestId, queryByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-range-last7')).toBeTruthy());
+ fireEvent.press(getByTestId('journal-range-last7'));
+
+ await waitFor(() => expect(getByTestId('journal-history-range-empty')).toBeTruthy());
+ // `journal-history-empty` says reflections "will appear here" β false for a
+ // reader who has written some and narrowed past them.
+ expect(queryByTestId('journal-history-empty')).toBeNull();
+ });
+
+ it('leaves the filter mounted, so the reader is never stranded', async () => {
+ const now = Date.now();
+ mockList.mockResolvedValue([
+ { id: 'ancient', createdAt: now - 300 * DAY, updatedAt: now - 300 * DAY },
+ ]);
+ const { getByTestId, queryByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-range-last7')).toBeTruthy());
+ fireEvent.press(getByTestId('journal-range-last7'));
+ await waitFor(() => expect(getByTestId('journal-history-range-empty')).toBeTruthy());
+
+ expect(getByTestId('journal-history-range-filter')).toBeTruthy();
+ fireEvent.press(getByTestId('journal-range-all'));
+ await waitFor(() => expect(getByTestId('journal-history-row-ancient')).toBeTruthy());
+ expect(queryByTestId('journal-history-range-empty')).toBeNull();
+ });
+
+ it('still shows the true-empty state, without a filter, when nothing is stored', async () => {
+ mockList.mockResolvedValue([]);
+ const { getByTestId, queryByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-history-empty')).toBeTruthy());
+ // Filtering nothing is meaningless β offering the control would imply the
+ // absence might be the filter's doing.
+ expect(queryByTestId('journal-history-range-filter')).toBeNull();
+ });
+ });
+
+ it('narrowing cannot raise the number of decrypts', async () => {
+ mockList.mockResolvedValue(seed(Date.now()));
+ const { getByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-history-row-today')).toBeTruthy());
+ const unfiltered = mockGet.mock.calls.length;
+ mockGet.mockClear();
+
+ fireEvent.press(getByTestId('journal-range-last7'));
+ await waitFor(() => expect(getByTestId('journal-history-row-week')).toBeTruthy());
+
+ expect(mockGet.mock.calls.length).toBeLessThanOrEqual(unfiltered);
+ });
+
+ it('carries no result count anywhere in the control', async () => {
+ // A count is a fact about the reader's practice. Attaching one to a control
+ // turns choosing a span into being told about a span.
+ mockList.mockResolvedValue(seed(Date.now()));
+ const { getByTestId } = render( );
+
+ await waitFor(() => expect(getByTestId('journal-range-last7')).toBeTruthy());
+ for (const option of ['all', 'last7', 'last30', 'last90']) {
+ const label = getByTestId(`journal-range-${option}`).props.accessibilityLabel as string;
+ expect(label).not.toMatch(/\d+\s*(entr|reflection|result|match)/i);
+ expect(label).not.toMatch(/^\d|\(\d/);
+ }
+ expect('3 reflections').toMatch(/\d+\s*(entr|reflection|result|match)/i);
+ });
+});
diff --git a/app/src/features/journal/screens/__tests__/journalExaminerBoundary.unit.test.ts b/app/src/features/journal/screens/__tests__/journalExaminerBoundary.unit.test.ts
new file mode 100644
index 00000000..2cdfd65f
--- /dev/null
+++ b/app/src/features/journal/screens/__tests__/journalExaminerBoundary.unit.test.ts
@@ -0,0 +1,103 @@
+/**
+ * FEAT-288 Slice C β the app-as-examiner boundary, pinned at source shape.
+ *
+ * The invariant is stated at VoiceReflectionScreen.tsx: "the censor must be the
+ * self, so the app must not become the examiner." The user does the examining;
+ * the app does the retrieval. Nothing in the re-read path may rank, score,
+ * cluster, or assert a pattern about what the reader wrote.
+ *
+ * WHY A SOURCE-SHAPE TEST AT ALL. There is no rendered output for "the app did
+ * not infer anything" β the failure is the ARRIVAL of a mechanism, not a wrong
+ * value, so a behavioural test would have to guess which mechanism arrived. This
+ * catches the class.
+ *
+ * DEBUG-390 RAILS, all three, because this file is exactly where that defect
+ * recurs: these modules deliberately NAME the forbidden mechanisms in prose to
+ * warn the next reader off them, so a bare `toContain` would match the warning
+ * and fail on correct code.
+ * 1. Comments are stripped before matching.
+ * 2. Patterns are identifier-shaped, not bare words.
+ * 3. Every matcher is proved to still fire against a known-bad literal, and
+ * the stripped source is proved non-trivial β a narrowed regex over
+ * accidentally-empty input is the silent way this test stops working.
+ */
+
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+const FEATURE = join(__dirname, '../..');
+
+const GUARDED = [
+ ['screens/JournalHistoryScreen.tsx', join(FEATURE, 'screens/JournalHistoryScreen.tsx')],
+ ['services/journalDateRange.ts', join(FEATURE, 'services/journalDateRange.ts')],
+] as const;
+
+/** Block and line comments removed. Rail 1. */
+function strip(source: string): string {
+ return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
+}
+
+const read = (path: string) => strip(readFileSync(path, 'utf8'));
+
+/**
+ * Identifier-shaped, so a word inside a string or a docblock cannot trip them.
+ * Each is a mechanism by which the app would start doing the examining.
+ */
+const FORBIDDEN: ReadonlyArray = [
+ ['embedding', /\bembeddings?\s*[(<[.=:]/i],
+ ['cosine/vector similarity', /\b(cosine|vectorSimilarity|similarityScore)\s*[(<[.=:]/i],
+ ['clustering', /\b(kmeans|kMeans|cluster(ing)?)\s*[(<[.=:]/i],
+ ['tf-idf / term scoring', /\b(tfidf|termFrequency|termScore)\s*[(<[.=:]/i],
+ ['relevance ranking', /\b(relevanceScore|rankBy|scoreEntry|rankEntries)\s*[(<[.=:]/i],
+ ['sentiment / mood inference', /\b(sentiment|moodScore|inferMood|detectTheme)\s*[(<[.=:]/i],
+ ['re-sorting the inherited order', /\.sort\s*\(/],
+];
+
+describe('the re-read path does not become the examiner', () => {
+ describe.each(GUARDED)('%s', (_label, path) => {
+ it.each(FORBIDDEN)('contains no %s', (_name, pattern) => {
+ expect(read(path)).not.toMatch(pattern);
+ });
+
+ it('renders no resurfacing copy', () => {
+ // A control is labelled by its range. Copy naming an occasion asserts that
+ // a span deserves re-examination, which is the reader's judgement.
+ expect(read(path)).not.toMatch(/a year ago|on this day|this time last|resurfac/i);
+ });
+ });
+
+ describe('the matchers still fire β this test can go red', () => {
+ it.each(FORBIDDEN)('%s matches known-bad source', (_name, pattern) => {
+ const bad = [
+ 'const e = embeddings(entry.text);',
+ 'const s = cosine(a, b);',
+ 'const c = cluster(entries);',
+ 'const t = tfidf(entry);',
+ 'const r = rankBy(entries, score);',
+ 'const m = sentiment(entry.text);',
+ 'const ordered = metas.sort((a, b) => b.createdAt - a.createdAt);',
+ ].join('\n');
+ expect(bad).toMatch(pattern);
+ });
+
+ it('the resurfacing matcher fires', () => {
+ expect('A year ago today you wrote').toMatch(/a year ago|on this day|this time last|resurfac/i);
+ });
+
+ it('comment stripping is real, and does not empty the file', () => {
+ for (const [label, path] of GUARDED) {
+ const raw = readFileSync(path, 'utf8');
+ const stripped = strip(raw);
+ expect(stripped.length).toBeGreaterThan(200);
+ expect(stripped.length).toBeLessThan(raw.length);
+ // The guarded modules DO name these mechanisms in prose. If that ever
+ // stops being true the rail is untested, so assert the collision the
+ // stripping exists to resolve is still present somewhere in the set.
+ expect(label.length).toBeGreaterThan(0);
+ }
+ const prose = GUARDED.map(([, p]) => readFileSync(p, 'utf8')).join('\n');
+ expect(prose).toMatch(/relevance|similarity|a year ago today/i);
+ expect(GUARDED.map(([, p]) => read(p)).join('\n')).not.toMatch(/a year ago today/i);
+ });
+ });
+});
diff --git a/app/src/features/journal/services/__tests__/journalDateRange.unit.test.ts b/app/src/features/journal/services/__tests__/journalDateRange.unit.test.ts
new file mode 100644
index 00000000..c3dc2cae
--- /dev/null
+++ b/app/src/features/journal/services/__tests__/journalDateRange.unit.test.ts
@@ -0,0 +1,108 @@
+/**
+ * FEAT-288 Slice C β the date-range filter's arithmetic and its ordering contract.
+ *
+ * `now` is injected rather than mocked so every boundary below is asserted at an
+ * exact millisecond. The ordering test is not decoration: reverse-chronological
+ * is the axis recurrence is perceived on, and it is preserved here by INHERITING
+ * the store's order rather than re-sorting. A test that only checked membership
+ * would stay green if someone added a sort.
+ */
+
+import { filterByRange, JOURNAL_RANGE_PRESETS } from '../journalDateRange';
+import type { JournalRangePreset } from '../journalDateRange';
+import type { JournalEntryMeta } from '../journalEntryStore';
+
+const DAY = 24 * 60 * 60 * 1000;
+const NOW = Date.UTC(2026, 7, 23, 12, 0, 0);
+
+const meta = (id: string, createdAt: number): JournalEntryMeta => ({
+ id,
+ createdAt,
+ updatedAt: createdAt,
+});
+
+/** Newest-first, as `listEntryMetadata()` returns them. */
+const NEWEST_FIRST: JournalEntryMeta[] = [
+ meta('d0', NOW),
+ meta('d3', NOW - 3 * DAY),
+ meta('d7', NOW - 7 * DAY),
+ meta('d30', NOW - 30 * DAY),
+ meta('d90', NOW - 90 * DAY),
+ meta('d200', NOW - 200 * DAY),
+];
+
+describe('filterByRange', () => {
+ it('returns every entry for `all`, and a copy rather than the input array', () => {
+ const out = filterByRange(NEWEST_FIRST, 'all', NOW);
+ expect(out.map((m) => m.id)).toEqual(['d0', 'd3', 'd7', 'd30', 'd90', 'd200']);
+ expect(out).not.toBe(NEWEST_FIRST);
+ });
+
+ it.each([
+ ['last7', ['d0', 'd3', 'd7']],
+ ['last30', ['d0', 'd3', 'd7', 'd30']],
+ ['last90', ['d0', 'd3', 'd7', 'd30', 'd90']],
+ ] as [JournalRangePreset, string[]][])('%s selects %p', (preset, expected) => {
+ expect(filterByRange(NEWEST_FIRST, preset, NOW).map((m) => m.id)).toEqual(expected);
+ });
+
+ describe('the lower bound is INCLUSIVE, at the millisecond', () => {
+ // A reader who cannot find an entry in the window it plainly belongs to has
+ // been told something false about their own record, so the edge is pinned
+ // on both sides rather than assumed.
+ it('keeps an entry written exactly 7 days ago', () => {
+ const edge = [meta('edge', NOW - 7 * DAY)];
+ expect(filterByRange(edge, 'last7', NOW)).toHaveLength(1);
+ });
+
+ it('drops one written a single millisecond earlier', () => {
+ const justOutside = [meta('edge', NOW - 7 * DAY - 1)];
+ expect(filterByRange(justOutside, 'last7', NOW)).toHaveLength(0);
+ });
+ });
+
+ it('preserves input order and never re-sorts', () => {
+ // Deliberately NOT newest-first. If an implementation ever sorted, this
+ // would come back reordered β which is how a relevance ordering would
+ // first show itself.
+ const scrambled = [meta('b', NOW - 5 * DAY), meta('a', NOW), meta('c', NOW - 2 * DAY)];
+ expect(filterByRange(scrambled, 'last7', NOW).map((m) => m.id)).toEqual(['b', 'a', 'c']);
+ });
+
+ it('handles an empty index without special-casing', () => {
+ expect(filterByRange([], 'last30', NOW)).toEqual([]);
+ expect(filterByRange([], 'all', NOW)).toEqual([]);
+ });
+
+ it('does not mutate its input', () => {
+ const input = [...NEWEST_FIRST];
+ filterByRange(input, 'last7', NOW);
+ expect(input).toHaveLength(NEWEST_FIRST.length);
+ });
+});
+
+describe('JOURNAL_RANGE_PRESETS', () => {
+ it('labels every preset by its RANGE, never by a reason', () => {
+ // "Last 30 days" states what the control does. A label naming an occasion
+ // asserts that some span deserves re-examination, which is the reader's
+ // judgement and not the app's.
+ const forbidden = /ago|today|memory|remember|this time|since your|last check|anniversar/i;
+ for (const { label } of JOURNAL_RANGE_PRESETS) {
+ expect(label).not.toMatch(forbidden);
+ }
+ // The matcher must be able to fire, or this test proves nothing (DEBUG-390).
+ expect('A year ago today').toMatch(forbidden);
+ });
+
+ it('offers an unfiltered option so the control is always escapable', () => {
+ expect(JOURNAL_RANGE_PRESETS.map((p) => p.preset)).toContain('all');
+ });
+
+ it('has a label for every preset the filter accepts', () => {
+ expect(JOURNAL_RANGE_PRESETS).toHaveLength(4);
+ for (const { preset, label } of JOURNAL_RANGE_PRESETS) {
+ expect(label.trim().length).toBeGreaterThan(0);
+ expect(filterByRange([], preset, NOW)).toEqual([]);
+ }
+ });
+});
diff --git a/app/src/features/journal/services/journalDateRange.ts b/app/src/features/journal/services/journalDateRange.ts
new file mode 100644
index 00000000..def993a1
--- /dev/null
+++ b/app/src/features/journal/services/journalDateRange.ts
@@ -0,0 +1,81 @@
+/**
+ * Date-range filtering for journal re-read (FEAT-288, Slice C).
+ *
+ * THE APP DOES THE RETRIEVAL; THE USER DOES THE EXAMINING. That boundary is the
+ * whole design constraint, stated at VoiceReflectionScreen.tsx: "the censor must
+ * be the self, so the app must not become the examiner." Everything below is a
+ * navigational control the reader operates. Nothing here ranks, scores, groups,
+ * or says anything about what the entries contain.
+ *
+ * WHY THIS IS CHEAP, AND WHY THAT DECIDED THE SCOPE.
+ * Filtering reads `createdAt` off `JournalEntryMeta`, which `listEntryMetadata()`
+ * already returns from ONE decrypt of the single index record. Zero per-entry
+ * `getEntry` calls, zero new storage. Full-text search cannot be built this way:
+ * `EncryptionService` disables derived-key caching because each record carries
+ * its own salt, so every `getEntry` is a fresh 100k-iteration PBKDF2 and search
+ * is O(N) derivations with no amortisation. That is why search is its own item
+ * and this one is a date range.
+ *
+ * ORDERING IS INHERITED, NEVER RECOMPUTED.
+ * `Array.prototype.filter` preserves input order, and `listEntryMetadata()`
+ * already returns newest-first. There is deliberately no sort here. Recurrence
+ * is perceived ACROSS TIME, so reverse-chronological is the axis the pattern
+ * lives on and any relevance ordering would destroy it β and a sort call is
+ * exactly where such an ordering would enter. Its absence is load-bearing and is
+ * pinned by a source-shape guard.
+ */
+
+import type { JournalEntryMeta } from './journalEntryStore';
+
+/**
+ * Preset vocabulary, deliberately identical to `features/data-export`'s
+ * `ExportRangePreset`. The NAMES are reused so the two surfaces agree; the code
+ * is not imported, on purpose. `journalAnalyticsBoundary.contract.test.ts` scans
+ * the journal feature directory recursively, so a shared module living outside
+ * it would be invisible to the egress check that guards this feature.
+ */
+export type JournalRangePreset = 'last7' | 'last30' | 'last90' | 'all';
+
+/**
+ * LABELLED BY RANGE, NEVER BY REASON. "Last 30 days" states what the control
+ * does. A preset labelled for an occasion β "a year ago today", "around your
+ * last check-in" β asserts that some span deserves re-examination, which is the
+ * reader's judgement to make and not the app's. Same reason there is no
+ * on-open resurfacing and no notification: presets are controls, not surfacings.
+ */
+export const JOURNAL_RANGE_PRESETS: readonly {
+ readonly preset: JournalRangePreset;
+ readonly label: string;
+}[] = [
+ { preset: 'all', label: 'All time' },
+ { preset: 'last7', label: 'Last 7 days' },
+ { preset: 'last30', label: 'Last 30 days' },
+ { preset: 'last90', label: 'Last 90 days' },
+];
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+const PRESET_DAYS: Record, number> = {
+ last7: 7,
+ last30: 30,
+ last90: 90,
+};
+
+/**
+ * Metadata whose `createdAt` falls inside the preset's window, in input order.
+ *
+ * `now` is a parameter rather than a `Date.now()` call so the boundary is
+ * testable at the exact millisecond. Inclusive at the lower bound: an entry
+ * written exactly N days ago is inside "last N days" β the alternative silently
+ * drops an entry on the edge, and a reader who cannot find yesterday's entry in
+ * "last 7 days" has been told something false about their own record.
+ */
+export function filterByRange(
+ metas: readonly JournalEntryMeta[],
+ preset: JournalRangePreset,
+ now: number
+): JournalEntryMeta[] {
+ if (preset === 'all') return [...metas];
+ const from = now - PRESET_DAYS[preset] * DAY_MS;
+ return metas.filter((meta) => meta.createdAt >= from);
+}
From bbf7f7479941bd3785e9e7ad7695d7ca230e496b Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 23 Aug 2026 20:41:29 -0700
Subject: [PATCH 17/90] fix: DEBUG-519 record the measured bounds, which
correct the derivation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AC2 asked for the tap proven from element bounds. Measured on iPhone SE 3 /
iOS 18.6 / 375x667 via `maestro hierarchy`, identical at quick beat 1 and deep
beat 1:
continue-button [20,433][283,489]
crisis-button-root [331,523][375,567]
daily-loop-support-line ABSENT FROM HIERARCHY
Both mechanisms this ticket cited are now refuted empirically, not just by
argument. The support line is genuinely absent rather than below the fold, and
there is 34pt of vertical and 48pt of horizontal clearance.
The second refutation was right for the wrong reason, which is why the numbers
are recorded rather than the argument. I derived [571,627] by assuming the beat
fills the viewport so the CTA lands at its bottom, and concluded the CTA sits
BELOW the FAB band. It sits ABOVE it: AwarePresence content is short. The
CTA's y-position is content-dependent and a longer beat pushes it down THROUGH
523..567 β which is precisely why DEBUG-518's fix is horizontal and
unconditional rather than a vertical assumption. x=283 is CRISIS_FAB_CLEARANCE
landing exactly where DailyLoopStepScreen.tsx:97 predicted it would.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01LYpWHMMLhowjbiGR3owEY9
---
app/.maestro/daily-loop-quick-depth.yaml | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/app/.maestro/daily-loop-quick-depth.yaml b/app/.maestro/daily-loop-quick-depth.yaml
index 685d3c6b..b3ccdace 100644
--- a/app/.maestro/daily-loop-quick-depth.yaml
+++ b/app/.maestro/daily-loop-quick-depth.yaml
@@ -346,6 +346,19 @@ name: "DailyLoop quick-depth (FEAT-301): crisis affordance survives the shorter
# predicate. `centerElement` would re-position mid-content and reintroduce it (probe F).
# No explicit timeout: with no futile centring to fund, this file's plain-boundary-scroll
# convention applies. If one is ever needed, MEASURE it β do not copy the 25000 below.
+#
+# MEASURED on iPhone SE 3 / iOS 18.6 / 375x667, `maestro hierarchy`, clearState run,
+# IDENTICAL at quick beat 1 and deep beat 1:
+# continue-button [20,433][283,489]
+# crisis-button-root [331,523][375,567] 34pt below the CTA, 48pt right of it
+# daily-loop-support-line ABSENT FROM HIERARCHY
+# So neither mechanism this ticket cited can fire here, and the second was refuted for a
+# reason worth keeping: the CTA sits ABOVE the FAB band, not below it, because
+# AwarePresence content does not fill the viewport. Its y-position is CONTENT-DEPENDENT β
+# a longer beat pushes it down THROUGH 523..567 β which is exactly why DEBUG-518's fix is
+# a HORIZONTAL inset and unconditional. x=283 is CRISIS_FAB_CLEARANCE landing where
+# DailyLoopStepScreen.tsx:97 predicted. Do not re-derive this from viewport arithmetic;
+# that is what produced the wrong answer before it was measured.
- scrollUntilVisible:
element:
id: "continue-button"
From 783b0e7eaff01a688da114b5d1e63f91ecf26b8f Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 24 Aug 2026 10:34:00 -0700
Subject: [PATCH 18/90] =?UTF-8?q?chore:=20INFRA-420=20scroll=20before=20as?=
=?UTF-8?q?serting=20the=20tier=20cap=20=E2=80=94=20it=20was=20vacuous?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The mutation proof this item's own ACs demanded did not go red, which is how
the defect surfaced: with `allowTier2Plus` forced open, Tier 2 rendered and
`assertNotVisible: guidance-tier2` passed anyway. The cap β the whole point of
the flow β could not fail.
The header blamed the wrong thing first. It cited DEBUG-465 to argue XCUITest
retains elements outside a ScrollView's clip, so absence at offset 0 was a real
claim about whether a tier RENDERED. That dropped the qualifier the rest of the
suite states correctly (_legal-and-onboarding.yaml:106: "outside a ScrollView's
clip BUT STILL ON SCREEN"). Retention is bounded by the screen, not the clip.
Measured on the gate sim with the gate forced open, so Tier 2/3 really rendered:
offset 0 guidance-tier2 ABSENT guidance-tier0/1 present
after 3 scrolls guidance-tier2 PRESENT guidance-tier0/1 absent
The hierarchy is a viewport-relative window. An earlier theory that the wrapper
was being view-flattened was also wrong and is recorded as such β Tier0 is an
equally bare style-less View and survives.
Both mutation arms now verified: gate forced open makes assertNotVisible go red;
gate as shipped passes.
The suppression-notice assertion moves ABOVE the scrolls, where the notice would
actually render, since it replaces the whole ScrollView.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01LYpWHMMLhowjbiGR3owEY9
---
app/.maestro/guidance-gentle-tier-cap.yaml | 48 +++++++++++++++-------
1 file changed, 33 insertions(+), 15 deletions(-)
diff --git a/app/.maestro/guidance-gentle-tier-cap.yaml b/app/.maestro/guidance-gentle-tier-cap.yaml
index d0f4ff35..dd88d9cb 100644
--- a/app/.maestro/guidance-gentle-tier-cap.yaml
+++ b/app/.maestro/guidance-gentle-tier-cap.yaml
@@ -43,13 +43,25 @@ name: "Guidance caps the ladder at Tier 1 for a gentle-band reader"
# FIRST: they are false on pending, on error, and on suppressed, which is what makes the
# absences that follow mean something.
#
-# ββ NO SCROLLING, DELIBERATELY ββββββββββββββββββββββββββββββββββββββββββββββ
-# Tier 2/3 render INSIDE the `guidance-content` ScrollView. Per DEBUG-465, XCUITest drops
-# elements outside the SCREEN but RETAINS ones merely outside a ScrollView's clip, so
-# Maestro scores a clipped tier visible. `assertNotVisible: guidance-tier2` at offset 0 is
-# therefore a real assertion about whether the tier RENDERED, not about where it sits β
-# and it stays real at every viewport and Dynamic Type step. Adding a scroll would buy
-# nothing and would weaken it: after scrolling, absence proves only absence at that offset.
+# ββ THE ABSENCE ASSERTIONS MUST BE MADE AFTER SCROLLING, AND THAT IS MEASURED ββ
+# This block first said the opposite, citing DEBUG-465: that XCUITest retains elements
+# outside a ScrollView's clip, so `assertNotVisible: guidance-tier2` at offset 0 would be
+# a real claim about whether the tier RENDERED. That dropped the qualifier the rest of
+# this suite states correctly β `_legal-and-onboarding.yaml:106` says "outside a
+# ScrollView's clip BUT STILL ON SCREEN". The retention is bounded by the SCREEN, not by
+# the clip, and Tier 2/3 sit far below it.
+#
+# MEASURED on the gate sim with the tier gate forced open, so Tier 2/3 really did render:
+# at scroll offset 0 guidance-tier2: ABSENT guidance-tier0/1: present
+# after three scrolls guidance-tier2: PRESENT guidance-tier0/1: absent
+# The hierarchy is a viewport-relative window. Asserting absence at offset 0 is therefore
+# VACUOUS for anything below the fold β it passes whether or not the tier rendered.
+#
+# Proven by mutation, both arms, which is the only reason to trust it:
+# gate forced open β `assertNotVisible: guidance-tier2` FAILED (goes red)
+# gate as shipped β flow passes
+# Without the scrolls the mutated arm PASSED, i.e. the cap β this flow's whole point β
+# could not fail. Do not remove them, and do not assert a tier's absence at offset 0.
- launchApp:
clearState: true
clearKeychain: true # SecureStore-backed consent persists across clearState (INFRA-179)
@@ -77,16 +89,22 @@ name: "Guidance caps the ladder at Tier 1 for a gentle-band reader"
- assertVisible:
id: "guidance-tier1"
+# Not the suppressed branch either β this reader gets philosophy, and the notice that
+# replaces it must not be on screen. Asserted HERE, at offset 0, because the notice
+# replaces the whole ScrollView and would render exactly where we are standing.
+- assertNotVisible:
+ id: "guidance-suppression-notice"
+
# ββ THE CAP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-# Tier 2/3 are gated on `decision.allowTier2Plus === true`. With no assessment on record
-# the verdict is `gentle`, so both must be ABSENT FROM THE HIERARCHY, not merely off-screen.
+# Tier 2/3 are gated on `decision.allowTier2Plus === true`; with no assessment on record
+# the verdict is `gentle`, so neither may render. Scroll to the end of the ladder FIRST β
+# see the block above. For a gentle reader the content ends at Tier 1, so these scrolls
+# are a near no-op; if Tier 2/3 ever render, this is what brings them into the window and
+# turns the two assertions below from vacuous into load-bearing.
+- scroll
+- scroll
+- scroll
- assertNotVisible:
id: "guidance-tier2"
- assertNotVisible:
id: "guidance-tier3"
-
-# Not the suppressed branch either β this reader gets philosophy, and the notice that
-# replaces it must not be on screen. Without this a suppression regression could satisfy
-# every absence above while the positives failed for the wrong reason.
-- assertNotVisible:
- id: "guidance-suppression-notice"
From 2fc22d54112fabc3fd4657dea624e3fd1c7d21e0 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 24 Aug 2026 10:50:51 -0700
Subject: [PATCH 19/90] fix: DEBUG-527 correct stale docstring left by the
opacity removal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`isCheckInCompletedToday`'s docstring said it was "used by Home screen to
determine faded appearance". Nothing fades any more β Home now uses it to
choose between the Start bar and the done status line. Comment-only.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01XBAW7cM5qUVzcV3c6dAoYU
---
app/src/features/practices/stores/stoicPracticeStore.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/src/features/practices/stores/stoicPracticeStore.ts b/app/src/features/practices/stores/stoicPracticeStore.ts
index b0b033f2..00321a4a 100644
--- a/app/src/features/practices/stores/stoicPracticeStore.ts
+++ b/app/src/features/practices/stores/stoicPracticeStore.ts
@@ -598,7 +598,9 @@ export const useStoicPracticeStore = create((set, get) => ({
/**
* Check if a specific check-in type was completed today
- * Used by Home screen to determine faded appearance
+ * Used by the Home screen to choose the card's done-state affordance.
+ * (It drove a faded appearance until DEBUG-527: a container opacity
+ * composited the whole subtree and failed WCAG AA.)
*/
isCheckInCompletedToday: (type: CheckInType): boolean => {
const today = getTodayString();
From 7402d8e36d0dcaa5a01baa199bc497a1d003164e Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:02:36 -0700
Subject: [PATCH 20/90] chore: MAINT-528 the daily practice card hugs, the
space goes terminal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The card carried `flexGrow: 1` plus `justifyContent: 'space-between'`, so it
swelled to absorb every spare pixel and then pinned its own title block to its
top and its button to its bottom. That put ~300pt of dead space INSIDE a
bordered container, where it reads as a hole rather than as air.
Removing the growth from the whole chain (checkInSection, checkInCard,
minHeight, space-between) lets every child take its intrinsic height; the
surplus falls below the last element out of `justifyContent` defaulting to
flex-start. No spacer View, so no new node in the XCUITest hierarchy and no
fresh DEBUG-465 surface.
This ELIMINATES the DEBUG-469 failure class rather than surviving it: with
nothing competing for vertical space, the flexBasis-0 collapse that pushed the
card off screen at AX5 cannot recur by construction. `content.flexGrow: 1`
stays; the ScrollView still engages when content exceeds the viewport.
The header's 4pt and 2pt intervals open to spacing[8] (one was `borderRadius.xs`
β a radius token used as spacing). Dense-top-plus-void-bottom is the signature
of an unfinished layout; spaciousness is a rhythm property, not a quantity.
Copy, per philosopher constraint C1 β the card must state no principle COUNT.
Quick depth runs THREE beats (QUICK_STEP_KEYS), and FEAT-301 already ruled as a
philosopher blocker that the count must not be stated for quick, because it
re-ranks quick as the deficient version against DepthSelect's pinned "Both are
complete practices." The old string additionally named the two beats quick
omits, and truncated mid-word at 375pt so two of the five never rendered.
DEBUG-469's regression test is REWRITTEN, not deleted: the invariant (card
reachable at AX5) survives, only the mechanism it pinned is replaced.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01XBAW7cM5qUVzcV3c6dAoYU
---
.../features/home/screens/CleanHomeScreen.tsx | 62 ++++++++++++-------
.../CleanHomeScreen.accessibility.test.tsx | 49 ++++++++++++---
2 files changed, 78 insertions(+), 33 deletions(-)
diff --git a/app/src/features/home/screens/CleanHomeScreen.tsx b/app/src/features/home/screens/CleanHomeScreen.tsx
index 24052ab1..1458c1ce 100644
--- a/app/src/features/home/screens/CleanHomeScreen.tsx
+++ b/app/src/features/home/screens/CleanHomeScreen.tsx
@@ -234,7 +234,16 @@ const CleanHomeScreen: React.FC = () => {
{
SITED HERE, between checkInSection and the Practices row, and inside
the ScrollView DEBUG-469 added. What it displaces: `checkInSection`
- and `checkInCard` are now `flexGrow: 1` with a `minHeight: 180` floor
- rather than `flex: 1`, so this row's height comes out of the card's
- SURPLUS down to that floor, and past it the ScrollView scrolls instead
- of the card collapsing. (This comment originally said `flex: 1` and
- "nothing below moves" β true when written, and DEBUG-469 landed the
- AX5 fix that made it false. The floor is what now bounds the squeeze.)
+ and `checkInCard` no longer grow at all (MAINT-528), so this row
+ displaces NOTHING: every element takes its intrinsic height and this
+ row's arrival simply shortens the terminal margin below the Practices
+ row. Past the point where that margin reaches zero, the ScrollView
+ engages. (This comment has been wrong twice β it first claimed
+ `flex: 1` and "nothing below moves", then described a `minHeight: 180`
+ floor bounding a squeeze. There is no squeeze now to bound.)
It must stay INSIDE the ScrollView. Pinned below one, it would share
screen coordinates with content clipped behind it, and XCUITest scores
@@ -321,17 +331,22 @@ const styles = StyleSheet.create({
paddingVertical: spacing[8],
alignItems: 'center',
},
+ // MAINT-528: the header's intervals were 4pt and 2pt, which made the top of the screen
+ // dense while the bottom held a void β the visual signature of an unfinished layout
+ // rather than of restraint. Spaciousness is a rhythm property, not a quantity one: it
+ // reads as deliberate only when every interval is generous and roughly proportional.
appTitle: {
fontSize: typography.display2.size,
fontWeight: typography.fontWeight.bold,
color: colorSystem.base.midnightBlue,
- marginBottom: spacing[4],
+ marginBottom: spacing[8],
},
greeting: {
fontSize: typography.title.size,
fontWeight: typography.fontWeight.semibold,
color: semantic.text.primary,
- marginBottom: borderRadius.xs,
+ // Was `borderRadius.xs` β a RADIUS token used as spacing, and only 2pt of it.
+ marginBottom: spacing[8],
},
subtitle: {
fontSize: typography.bodySmall.size,
@@ -340,13 +355,14 @@ const styles = StyleSheet.create({
marginBottom: spacing[12],
},
checkInSection: {
- // DEBUG-469: `flexGrow: 1`, NOT `flex: 1`. `flex: 1` sets flexBasis to 0, so this
- // section's base size is nothing and it grows only into leftover space β of which
- // there is none at AX5 once the header, badge and practices row have taken their
- // intrinsic heights. That is what collapsed the card to 40pt and pushed it off
- // screen. flexBasis stays `auto` here, so the section is sized by its content first.
- flexGrow: 1,
- marginTop: spacing[12],
+ // MAINT-528: no `flexGrow`. DEBUG-469 kept the card reachable at AX5 by making this
+ // chain grow into surplus with a `minHeight` floor; this removes the growth instead.
+ // With nothing in the tree competing for vertical space, the flexBasis-0 collapse
+ // cannot recur by construction β every child takes its intrinsic height and the
+ // ScrollView engages when they exceed the viewport. The surplus now falls BELOW the
+ // last element, out of `justifyContent` defaulting to flex-start, rather than being
+ // absorbed into the card.
+ marginTop: spacing[48],
},
// FEAT-293: fixed height, so it never competes with the flex:1 check-in cards.
practicesEntry: {
@@ -368,17 +384,17 @@ const styles = StyleSheet.create({
color: semantic.text.secondary,
},
checkInCard: {
- // DEBUG-469: same reasoning as checkInSection β flexGrow, not flex. The minHeight is
- // the floor that stops a squeeze taking the card below a usable size; it binds only
- // when space is scarce, since at AX5 the card's own content is far taller than this.
- flexGrow: 1,
- minHeight: 180,
- justifyContent: 'space-between',
+ // MAINT-528: the card HUGS its content. It previously carried `flexGrow: 1` plus
+ // `justifyContent: 'space-between'`, so it swelled to eat every spare pixel and then
+ // pinned its own title block to its top and its button to its bottom β putting ~300pt
+ // of dead space INSIDE a bordered container, where it reads as a hole rather than as
+ // air. `minHeight: 180` went with them: it existed only to bound a squeeze, and with
+ // nothing squeezing it would now just force the card past its own content.
paddingTop: spacing[16],
paddingHorizontal: spacing[16],
paddingBottom: spacing[20], // Extra to optically balance with title line-height
borderRadius: borderRadius.xl,
- marginBottom: spacing[16],
+ marginBottom: spacing[40],
// MAINT-222: border-preferred elevation (DS guidance), replacing the
// hand-rolled black-shadow recipe. Matches the unified card system.
borderWidth: 1,
diff --git a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
index 8d88f4ea..85a8aa3a 100644
--- a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
+++ b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
@@ -145,18 +145,47 @@ describe('DEBUG-469: the daily-loop entry point is reachable at any text size',
expect(getByTestId('home-screen')).toBeTruthy();
});
- it('does not give the growth chain flexBasis 0 β the collapse mechanism', () => {
- const { getByTestId } = render( );
+ // MAINT-528 REWROTE this test rather than deleting it. The INVARIANT is unchanged and
+ // still DEBUG-469's: the card must be reachable at AX5, which requires that nothing in
+ // the tree competes for vertical space. Only the MECHANISM changed. DEBUG-469 satisfied
+ // it with `flexGrow: 1` + a `minHeight` floor β a card that grows into surplus and is
+ // stopped from collapsing below 180. MAINT-528 removes the growth entirely: with no
+ // grower, there is no flexBasis-0 collapse to guard against, and the surplus lands
+ // BELOW the last element instead of inside the card.
+ //
+ // Deleting it would have silently dropped the regression coverage for a defect that
+ // made the daily loop completely unenterable at accessibility text sizes.
+ it('lets nothing inside the content container grow β the collapse cannot recur', () => {
+ const { getByTestId, UNSAFE_getAllByType } = render( );
+
+ // The container itself MUST still grow: it is what makes the content fill the
+ // viewport at default size, and `flexGrow` (never `flex: 1`) is what keeps it free
+ // to exceed the viewport at AX5 so the ScrollView engages.
+ const scroll = UNSAFE_getAllByType(ScrollView)[0];
+ const content = flat(scroll.props.contentContainerStyle) as Record;
+ expect(content.flexGrow).toBe(1);
+ expect(content.flex).toBeUndefined();
+ expect(content.flexBasis).not.toBe(0);
+
+ // ...and nothing INSIDE it may grow. A single grower re-creates the competition
+ // DEBUG-469 diagnosed; at AX5 it is handed nothing and collapses.
const card = flat(getByTestId('checkin-card-daily-loop').props.style) as Record;
- // `flex: 1` would surface as flexBasis 0 once flattened by RN's style resolver.
+ expect(card.flexGrow).toBeUndefined();
+ expect(card.flex).toBeUndefined();
expect(card.flexBasis).not.toBe(0);
- expect(card.flex).not.toBe(1);
- // It must still fill a surplus at default text size, or the card shrinks to its
- // content and Home looks broken for the 99% case.
- expect(card.flexGrow).toBe(1);
- // And it must have a floor, so a squeeze cannot take it below a usable size.
- expect(typeof card.minHeight).toBe('number');
- expect(card.minHeight as number).toBeGreaterThan(0);
+ // The floor existed only to bound a squeeze. With no squeeze, a floor would just be
+ // a magic number forcing the card past its own content.
+ expect(card.minHeight).toBeUndefined();
+ });
+
+ it('adds no spacer or wrapper node β the surplus falls out of flex-start', () => {
+ // A `` spacer would also work, but it is a new node in
+ // the XCUITest hierarchy and therefore a new DEBUG-465 surface. The surplus should
+ // come from `justifyContent` defaulting to flex-start, costing zero nodes.
+ const { getByTestId } = render( );
+ const card = flat(getByTestId('checkin-card-daily-loop').props.style) as Record;
+ // `space-between` on the card is what stranded the button 300pt below its own text.
+ expect(card.justifyContent).toBeUndefined();
});
});
From 4b2119d72b15fdc91b2cc00f828f1b53ed3664e5 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:46:50 -0700
Subject: [PATCH 21/90] fix: DEBUG-534 privacy policy named an
analytics-deletion control that does not exist
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The policy directed users to "Settings > Privacy > Delete Analytics Data".
That control was removed as a non-functional stub (MAINT-173) and is absent
from app/src, so the copy was an affirmative representation about a named
control the app does not provide β the FTC Act Β§5 deception shape.
Remedy (b): correct the copy, do not build the control. Compliance ruled that
CCPA/GDPR/TDPSA require a designated request method rather than a self-service
in-app control. Building it was independently rejected because usePostHog()
returns undefined whenever the provider is unmounted β the analytics-OFF state,
i.e. exactly when a user would reach for the control β so handleAnalyticsDeletion
no-ops and still returns success, reproducing the misrepresentation in code.
The replacement copy states only what is verifiable in source and already
published elsewhere in the same document: analytics is opt-in default OFF,
toggling it off stops collection immediately, server-side events are deleted
after 90 days (Β§7.2), and earlier deletion is available via privacy@being.fyi,
honored within 30 days (Β§7.3).
Also corrected, because the same defect had two other shapes:
- The identical claim was mirrored at docs/architecture/analytics-architecture.md,
which no legal tooling watches. Left alone it would re-seed the claim from an
internal doc presenting itself as the policy's mirror.
- Five further stale in-app paths across privacy-policy, california-privacy and
support. There is no "Settings" navigation root at all β the tab is Profile and
the screen is "Privacy & Data". Export and Delete Account do exist, so those
were path-naming defects, not missing controls. The iOS system path
"Settings > Apple ID > Subscriptions" is not app navigation and is unchanged.
Adds app/__tests__/privacy/analyticsControlClaims.privacy.test.ts, run by CI's
Safety + privacy gates. It asserts the relation none of the three existing guards
covered: legal-registry.js compares filenames and never reads prose;
legal-site-freshness.js derives both sides of its comparison from the same
markdown, so it fingerprinted the deception as fresh; and
consumer-privacy-posture.test.ts never opens docs/legal. The new check asserts
that a control path named in the legal copy resolves to a real label in
PrivacyDataScreen.tsx.
Per DEBUG-390, markdown is matched on the DIRECTIVE shape rather than bare
presence, so the DPIA change log can record what it corrected without tripping
its own guard; source is comment-stripped so the MAINT-173 prose warning does
not match. A matcher-integrity block proves every regex still fires.
legalContent.generated.ts is gitignored (app/.gitignore, DEBUG-178) and
regenerates on postinstall/prestart/preios, so it is deliberately absent from
this diff and must never be hand-edited.
Verified: test:privacy 542 passed; legal-registry consistent;
check:ci-test-coverage consistent; typecheck clean; no new lint errors.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01CMQzeUa97ZCj4mnE1ZMLzS
---
.../analyticsControlClaims.privacy.test.ts | 238 ++++++++++++++++++
docs/architecture/analytics-architecture.md | 4 +-
docs/legal/california-privacy.md | 2 +-
docs/legal/dpia-sensitive-wellness-data.md | 3 +
docs/legal/privacy-policy.md | 8 +-
docs/legal/support.md | 4 +-
6 files changed, 250 insertions(+), 9 deletions(-)
create mode 100644 app/__tests__/privacy/analyticsControlClaims.privacy.test.ts
diff --git a/app/__tests__/privacy/analyticsControlClaims.privacy.test.ts b/app/__tests__/privacy/analyticsControlClaims.privacy.test.ts
new file mode 100644
index 00000000..03fcc3c0
--- /dev/null
+++ b/app/__tests__/privacy/analyticsControlClaims.privacy.test.ts
@@ -0,0 +1,238 @@
+/**
+ * Legal-copy control-claim guard (DEBUG-534)
+ *
+ * The privacy policy directed users to "Settings > Privacy > Delete Analytics
+ * Data" β a control that was removed as a non-functional stub (MAINT-173) and
+ * never existed in working form. The claim shipped in three tracked places and
+ * was served live on being.fyi. That is an affirmative representation about a
+ * named control the app does not provide: the FTC Act Β§5 deception shape.
+ *
+ * Nothing caught it, and the three near-misses each show why:
+ * - `scripts/legal-registry.js` compares document FILENAMES to the generator's
+ * source list. It never reads a byte of prose, so it can see a missing
+ * document but not a false claim inside one.
+ * - `scripts/legal-site-freshness.js` fingerprints live being.fyi prose against
+ * the markdown. BOTH SIDES DERIVE FROM THE SAME MARKDOWN, so it detects a
+ * stale deploy, never a wrong claim β it fingerprinted the deception as fresh.
+ * - `__tests__/compliance/consumer-privacy-posture.test.ts` is named for this
+ * duty and cites FTC Β§5, but asserts only store-level posture. It never
+ * opens docs/legal.
+ *
+ * So this suite asserts the one relation none of them cover: A CONTROL PATH
+ * NAMED IN THE LEGAL COPY MUST RESOLVE TO A CONTROL THAT EXISTS. It is
+ * deliberately wider than the single string β there is no "Settings" navigation
+ * root in this app at all (the tab is Profile, the screen is "Privacy & Data"),
+ * so pinning one literal would leave five sibling claims equally wrong and would
+ * go green forever the moment that literal was deleted.
+ *
+ * DEBUG-390 DISCIPLINE. This codebase deliberately names retired anti-patterns
+ * in prose to warn the next reader off them β `CloudBackupSettings.tsx` records
+ * that the "Delete Analytics Data" control "was a non-functional stub". A bare
+ * `not.toContain` over source would match that comment and fail on correct code.
+ * Source is therefore comment-stripped before matching, and `describe('matcher
+ * integrity')` proves the stripper and every regex still fire, so this file
+ * cannot silently match nothing and pass forever.
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+
+const REPO_ROOT = path.resolve(__dirname, '../../..');
+const LEGAL_DIR = path.join(REPO_ROOT, 'docs/legal');
+const ARCH_DIR = path.join(REPO_ROOT, 'docs/architecture');
+const APP_SRC = path.join(REPO_ROOT, 'app/src');
+
+const PRIVACY_SCREEN = path.join(
+ APP_SRC,
+ 'features/profile/screens/PrivacyDataScreen.tsx'
+);
+
+/** The removed control (MAINT-173). */
+const ABSENT_CONTROL = 'Delete Analytics Data';
+
+/**
+ * In PROSE, naming the control is not the offence β DIRECTING users to it is.
+ * The DPIA change log and this suite's own header must be able to name the
+ * string in order to record that it was removed, exactly as
+ * `translatorProvenanceDocs.test.ts` distinguishes naming a banned translator
+ * from attributing shipped text to one. So markdown is matched on the
+ * directive shape; app source and the generated module (below) keep bare
+ * presence, because neither has any reason to contain the string at all.
+ */
+const DIRECTS_TO_ABSENT_CONTROL = new RegExp(
+ String.raw`(?:via|Go to|Navigate to|Tap|Open)\b[^.\n]*` + ABSENT_CONTROL,
+ 'i'
+);
+
+/**
+ * The real in-app route title (ProfileStackNavigator.tsx). There is no
+ * "Settings" navigation root; asserting its absence is the general fix.
+ */
+const REAL_ROUTE_TITLE = 'Privacy & Data';
+
+/**
+ * iOS SYSTEM Settings paths are not app navigation and are legitimately named.
+ * Anchored on "Settings > Apple ID", which only the system path uses.
+ */
+const SYSTEM_SETTINGS_PATH = /Settings\s*>\s*Apple ID/;
+
+/** Strip block and line comments so prose warnings are not matched as code. */
+function stripComments(src: string): string {
+ return src
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+}
+
+function readMarkdownFiles(dir: string): Array<{ file: string; text: string }> {
+ return fs
+ .readdirSync(dir)
+ .filter((f) => f.endsWith('.md'))
+ .map((f) => ({
+ file: path.relative(REPO_ROOT, path.join(dir, f)),
+ text: fs.readFileSync(path.join(dir, f), 'utf8'),
+ }));
+}
+
+function walkSource(dir: string, acc: string[] = []): string[] {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
+ walkSource(full, acc);
+ } else if (/\.tsx?$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name)) {
+ acc.push(full);
+ }
+ }
+ return acc;
+}
+
+describe('DEBUG-534 β legal copy may not name a control the app lacks', () => {
+ const legalDocs = readMarkdownFiles(LEGAL_DIR);
+ const archDocs = readMarkdownFiles(ARCH_DIR);
+
+ it('has legal documents to scan (non-vacuity)', () => {
+ expect(legalDocs.length).toBeGreaterThan(3);
+ expect(archDocs.length).toBeGreaterThan(0);
+ });
+
+ it.each([...legalDocs, ...archDocs])(
+ 'does not direct users to the removed control β $file',
+ ({ file, text }) => {
+ // The filename rides in the asserted VALUE, not in a message argument:
+ // Jest's expect() takes exactly one argument, and the diff must name the
+ // offending doc on its own.
+ expect({ file, directsToRemovedControl: DIRECTS_TO_ABSENT_CONTROL.test(text) }).toEqual({
+ file,
+ directsToRemovedControl: false,
+ });
+ }
+ );
+
+ it('names no "Settings >" in-app navigation path β there is no Settings root', () => {
+ const offenders: string[] = [];
+ for (const { file, text } of [...legalDocs, ...archDocs]) {
+ text.split('\n').forEach((line, i) => {
+ if (!/Settings\s*>/.test(line)) return;
+ if (SYSTEM_SETTINGS_PATH.test(line)) return; // iOS system path, legitimate
+ offenders.push(`${file}:${i + 1} ${line.trim()}`);
+ });
+ }
+ // Offenders are the diff. The app has no Settings navigation root β the tab
+ // is Profile and the screen is "Privacy & Data".
+ expect(offenders).toEqual([]);
+ });
+
+ it('every in-app control path it DOES name resolves to a real label', () => {
+ const screen = stripComments(fs.readFileSync(PRIVACY_SCREEN, 'utf8'));
+ expect(screen.length).toBeGreaterThan(2000); // stripper did not gut the file
+
+ const named = new Set();
+ for (const { text } of [...legalDocs, ...archDocs]) {
+ const re = new RegExp(`${REAL_ROUTE_TITLE}\\s*>\\s*([A-Za-z][A-Za-z ]+)`, 'g');
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(text)) !== null) named.add(m[1].trim());
+ }
+
+ const missing = [...named].filter((label) => !screen.includes(label));
+ // Any entry here is a control the legal copy names but PrivacyDataScreen
+ // does not render under that label.
+ expect(missing).toEqual([]);
+ });
+
+ it('no app source file offers the removed control (comments stripped)', () => {
+ const offenders = walkSource(APP_SRC)
+ .filter((f) => stripComments(fs.readFileSync(f, 'utf8')).includes(ABSENT_CONTROL))
+ .map((f) => path.relative(REPO_ROOT, f));
+ expect(offenders).toEqual([]);
+ });
+
+ it('the generated in-app module carries the corrected copy', () => {
+ const generated = path.join(
+ APP_SRC,
+ 'features/profile/content/legalContent.generated.ts'
+ );
+ // Gitignored (app/.gitignore, DEBUG-178) but regenerated on postinstall /
+ // prestart / preios. CI's Safety + privacy gates job runs npm ci, so it exists there.
+ if (!fs.existsSync(generated)) {
+ throw new Error(
+ 'legalContent.generated.ts is missing. Run: cd app && npm run generate:legal-content'
+ );
+ }
+ const text = fs.readFileSync(generated, 'utf8');
+ expect(text.includes(ABSENT_CONTROL)).toBe(false);
+ expect(text).toContain(REAL_ROUTE_TITLE); // codegen bridge is live
+ });
+});
+
+/**
+ * Proves every matcher above can still go red. Without this the suite could
+ * silently match nothing β the DEBUG-390 failure mode β and pass forever.
+ */
+describe('DEBUG-534 β matcher integrity', () => {
+ it('the comment stripper removes a prose mention but keeps code', () => {
+ const sample = [
+ '// the "Delete Analytics Data" control was a non-functional stub.',
+ '/* Delete Analytics Data */',
+ 'const label = "Anonymous Usage Analytics";',
+ ].join('\n');
+ const stripped = stripComments(sample);
+ expect(stripped).not.toContain(ABSENT_CONTROL);
+ expect(stripped).toContain('Anonymous Usage Analytics');
+ });
+
+ it('the allowlisted prose mention really exists β the stripper is doing work', () => {
+ const raw = fs.readFileSync(
+ path.join(APP_SRC, 'core/components/settings/CloudBackupSettings.tsx'),
+ 'utf8'
+ );
+ // If this ever fails, the MAINT-173 comment moved and the stripper is no
+ // longer exercised by real code β re-point it before trusting the suite.
+ expect(raw).toContain(ABSENT_CONTROL);
+ expect(stripComments(raw)).not.toContain(ABSENT_CONTROL);
+ });
+
+ it('the directive matcher fires on the real pre-fix line, not on a historical record', () => {
+ expect(
+ DIRECTS_TO_ABSENT_CONTROL.test(
+ '- Request deletion via Settings > Privacy > Delete Analytics Data'
+ )
+ ).toBe(true);
+ // The DPIA change log must be able to record what it corrected.
+ expect(
+ DIRECTS_TO_ABSENT_CONTROL.test(
+ 'it directed users to "Settings > Privacy > Delete Analytics Data", a control removed as a stub.'
+ )
+ ).toBe(false);
+ });
+
+ it('the Settings-path matcher fires on a known-bad line and spares the iOS one', () => {
+ expect(/Settings\s*>/.test('Opt-in via Settings > Privacy > Analytics')).toBe(true);
+ expect(SYSTEM_SETTINGS_PATH.test('**iOS:** Settings > Apple ID > Subscriptions > Being')).toBe(true);
+ expect(SYSTEM_SETTINGS_PATH.test('Go to Settings > Privacy > Export Data')).toBe(false);
+ });
+
+ it('the control-path matcher extracts a label from a known-good line', () => {
+ const re = new RegExp(`${REAL_ROUTE_TITLE}\\s*>\\s*([A-Za-z][A-Za-z ]+)`, 'g');
+ const m = re.exec('Opt-in via Privacy & Data > Anonymous Usage Analytics');
+ expect(m?.[1].trim()).toBe('Anonymous Usage Analytics');
+ });
+});
diff --git a/docs/architecture/analytics-architecture.md b/docs/architecture/analytics-architecture.md
index 901b81b3..586d4f1f 100644
--- a/docs/architecture/analytics-architecture.md
+++ b/docs/architecture/analytics-architecture.md
@@ -370,8 +370,8 @@ Required disclosure for privacy policy:
>
> **Your Control:**
> - Analytics is OFF by default
-> - Opt-in via Settings > Privacy > Analytics
-> - Request deletion via Settings > Privacy > Delete Analytics Data
+> - Opt-in via **Privacy & Data > Anonymous Usage Analytics**; turning it off stops collection immediately
+> - Analytics events on our servers are automatically deleted after 90 days. To request deletion sooner, email privacy@being.fyi
>
> **Data Residency:** EU (Frankfurt, Germany)
>
diff --git a/docs/legal/california-privacy.md b/docs/legal/california-privacy.md
index 90631c2e..8ef05511 100644
--- a/docs/legal/california-privacy.md
+++ b/docs/legal/california-privacy.md
@@ -90,7 +90,7 @@ We will not discriminate against you for exercising your privacy rights. You wil
### In-App
-Go to **Settings > Privacy** to:
+Go to **Privacy & Data** to:
- View your data
- Export your data
- Delete your data
diff --git a/docs/legal/dpia-sensitive-wellness-data.md b/docs/legal/dpia-sensitive-wellness-data.md
index 807e9d86..6d8171c8 100644
--- a/docs/legal/dpia-sensitive-wellness-data.md
+++ b/docs/legal/dpia-sensitive-wellness-data.md
@@ -246,6 +246,9 @@ Recorded here rather than left silent because Β§3 and `lia-crisis-telemetry.md`
| 2.6 | 2026-08-17 | Palouse Labs LLC | FEAT-470: the Art. 9(2)(a) wellness-processing consent becomes refusable and revocable; interim enforcement window accepted on the record. (1) **Β§7 control 22 added** (see above) β refusal at collection recorded as an affirmative `false` with no new discriminator field (Art. 5(1)(c), given the key is erasure-excluded), and withdrawal via `updateConsent` rather than `revokeConsent`, independent of `universalOptOut`. (2) **A reading of control 20 is narrowed, not corrected.** Control 20's "`canPerformOperation` returns false for **all five** operations" remains true as written β it is scoped to *lapsed* consent, where `consentStatus !== 'valid'`. It was never a claim about a *refusing* user, whose record is `valid` with the flag `false`, and it is recorded here that it must not be read as one. (3) **π΄ The interim enforcement window is disclosed rather than left implicit.** `canPerformOperation('mental_health_processing')` has zero production consumers as a gate, so between this change and FEAT-318 a refusal is recorded and then honoured by no code path. This is the reverse of the usual disclosure posture β the entry records a control that is *deliberately incomplete* β and it is made because the alternative is a DPIA that reads as though refusal is enforced. Founder decision of 2026-08-17 accepted the window in preference to holding the fix (leaving the live Art. 7(4) defect for every TestFlight user, on an item parked twice) or folding enforcement in (placing the first-ever caller of an unexercised gate near the crisis-capture path, which the crisis pass constrained to persistence-only). (4) **No re-consent and CONSENT_VERSION deliberately NOT bumped.** This change *expands* a right rather than altering agreed data practices, so it is not the class of change `CONSENT_CHANGELOG` exists to explain, and a blanket re-prompt would be the consent-fatigue dark pattern DEBUG-150/FEAT-399 were built to avoid. Not bumping also keeps the tracked `ReConsentScreen` divergence (FEAT-475) unreachable. (5) **`privacy-policy.md` Β§6 gains a withdrawal right**, since a control existing in-app but unpublished would be an Art. 12/13 transparency gap; it states the consent is optional, where to change it, and that withdrawal does not itself delete stored data. Note this file sits on the INFRA-348 legal-site sync path. **Material-change assessment:** NOT a Β§1 trigger β no new sensitive wellness data category, no new sub-processor, no local-first architecture change, no new jurisdiction. A control-coverage addition plus a disclosed enforcement gap, same class as v1.3, v1.7, v2.2 and v2.4. Founder self-certification. No 16 CFR Part 318 trigger: nothing was exposed. |
| 2.7 | 2026-08-20 | Palouse Labs LLC | FEAT-475: the Art. 9(2)(a) unbundling is extended to `ReConsentScreen`, closing the divergence v2.6 recorded. (1) **The v2.6/item-4 forward pointer is discharged.** v2.6 noted that not bumping `CONSENT_VERSION` "keeps the tracked `ReConsentScreen` divergence (FEAT-475) unreachable" β a deliberate deferral, not an omission. That divergence is now closed at the source: Submit on the re-consent path is gated on the three contract acceptances (ToS, Privacy Policy, wellness disclaimer) only, and the Art. 9(2)(a) tick is captured and written to both records at whatever value it holds. Both consent-collection surfaces now carry control 22's shape, so a `CONSENT_VERSION` bump no longer re-opens the Art. 7(4) defect on the second entry point. (2) **Control 22's interim-enforcement-window disclosure is restated as covering TWO surfaces, not one.** The window itself is unchanged and is still not asserted to be short: `canPerformOperation('mental_health_processing')` still has zero production consumers as a gate, so a refusal arriving by the re-consent path is recorded and then honoured by no code path, exactly as one arriving at the legal gate is. FEAT-318 remains the closing item for both. (3) **A reading of control 20 is corrected at its remaining source.** v2.6 narrowed control 20's "all five operations" to *lapsed* consent; the same over-broad reading was still embedded in the `ReConsentScreen` invariant suite's own header, which attributed the breadth to `declineReConsent`. It is attributable to neither that function nor a refusing user: `declineReConsent` writes an audit entry and mutates no consent state, and the breadth comes from `canPerformOperation` failing closed on `consentStatus !== 'valid'`. Corrected in place rather than deleted with the note. (4) **`declineReConsent` deliberately unchanged.** Its stale-on-purpose behaviour is load-bearing for the next-launch re-prompt; a refusing user now has a route that does not depend on it, which is the actual remedy. (5) **No re-consent and `CONSENT_VERSION` again NOT bumped** β same reasoning as v2.6: this expands a right rather than altering agreed data practices. **Material-change assessment:** NOT a Β§1 trigger β no new sensitive wellness data category, no new sub-processor, no architecture change, no new jurisdiction. Control-parity completion across a second entry point, same class as v1.3, v1.7, v2.2, v2.4 and v2.6. Founder self-certification. No 16 CFR Part 318 trigger: nothing was exposed. |
| 2.8 | 2026-08-21 | Palouse Labs LLC | DEBUG-474: server-side subscription re-verification against Apple, and a scope silence corrected. (1) **Β§2 gains the App Store Server API channel**, which this DPIA had never named. It was not new processing β `verify-apple-receipt` has queried Apple for entitlement verification since INFRA-467 β but Β§2 listed Stripe for billing metadata and was silent on Apple, so a reader could not have found the channel from this document. DEBUG-474 adds a second, *scheduled* trigger for the same purpose, which is the natural occasion to correct the omission rather than leave it to be rediscovered. (2) **A scheduled trigger is not a new processing activity.** The purpose (subscription entitlement verification), the legal basis (contract performance), the recipient (Apple), and the datum sent (`original_transaction_id`, Apple's own identifier) are all unchanged; only the trigger moves from user-initiated to cron. Β§4's purpose entry is not qualified as user-initiated, so a scheduled reconciliation of the same fact is the same activity. No new disclosure is owed and `privacy-policy.md` is deliberately unedited β Β§5.1 already discloses Apple, and that file sits on the INFRA-348 legal-site sync path. (3) **A `subscriptions.environment` column is added** (Production|Sandbox, read from the verified Apple claim). It is one more attribute inside Β§3 category 7, already classified *sensitive* since v1.0 β not a new category. It is Apple routing metadata about the transaction and asserts nothing about the user; it must not be cited as a user- or account-verification control. (4) **Automated entitlement changes are audited.** A cron-driven status change writes `subscription_events` via the DEBUG-446 shared writer, using event types already permitted by the CHECK constraint β no migration, and Β§7 control 8's credited "audit logging on subscription events" therefore covers the no-user-present path too rather than silently excluding it. (5) **The heartbeat table's PII-free claim is preserved by construction.** `grace_period_automation_runs.errors` is jsonb with no size CHECK, so per-subscription failures are aggregated to class-and-count lines emitted from a closed vocabulary; no transaction id, row id, `user_id`, bundle id or Apple response text can reach it. Identified detail goes to `subscription_events` (RLS-protected, ownership-checked, 2KB-capped) instead. (6) **Retention unchanged** β no new obligation; `grace_period_automation_runs`'s existing 90-day self-prune (`20260808000000`) is unaffected given (5). (7) **Census at authoring time:** `subscriptions` 0 rows, `subscription_events` 0 rows, `grace_period_automation_runs` 11 rows. **Material-change assessment:** NOT a Β§1 trigger β no new category of sensitive wellness data, no new sub-processor (Apple already in scope as a recipient), no local-first architecture change, no new jurisdiction. A scope-documentation correction plus a control-coverage extension, same class as v1.3, v1.7, v2.2 and v2.6. Founder self-certification per Β§10. No 16 CFR Part 318 (FTC HBNR) trigger: nothing was exposed. **One finding filed rather than absorbed:** Β§2's Stripe bullet describes "subscription billing metadata processed via Stripe", but `subscriptions` is commented IAP-only (Apple/Google) and no edge function calls Stripe β so that bullet may describe a path that no longer exists. Not corrected here, because verifying it is a question about the payments architecture rather than about this change, and a legal document should not be edited on an unverified premise. |
+| 2.9 | 2026-08-24 | Palouse Labs LLC | DEBUG-534: a named control that does not exist, removed from the published copy. (1) **`privacy-policy.md` Β§5.2 corrected** β it directed users to "Settings > Privacy > Delete Analytics Data", a control removed as a non-functional stub (MAINT-173) and absent from `app/src`. That is an affirmative representation about a control the app does not provide. Remedy: describe what the app actually does β analytics is opt-in default OFF, toggling it off unmounts the PostHog provider and stops collection immediately, server-side analytics events are deleted after 90 days (Β§7.2), and earlier deletion is available via privacy@being.fyi, honored within 30 days (Β§7.3). **No new control was built**: compliance ruled that CCPA/GDPR/TDPSA require a designated request method, not a self-service in-app control, and that the pseudonymous product-analytics identifier at issue is proportionate to a manual channel. (2) **The identical claim was mirrored, untracked by any legal tooling, at `docs/architecture/analytics-architecture.md`** and is corrected in the same commit; leaving it would have re-seeded the claim from an internal doc presenting itself as the policy's mirror. (3) **Five further stale in-app navigation paths corrected** across `privacy-policy.md`, `california-privacy.md` and `support.md` β the app has no "Settings" navigation root at all (the tab is Profile, the screen is "Privacy & Data"). Export and Delete Account do exist, so those were path-naming defects rather than missing controls. The iOS system path "Settings > Apple ID > Subscriptions" is not app navigation and is unchanged. (4) **A drift check now enforces the relation none of the three existing guards covered** (`app/__tests__/privacy/analyticsControlClaims.privacy.test.ts`, run by CI's Safety + privacy gates): a control path named in the legal copy must resolve to a real label in `PrivacyDataScreen.tsx`. (5) Material-change assessment recorded below. |
+
+**DEBUG-534 material-change assessment (2026-08-24).** Assessed against the Β§1 triggers: *new derived category of sensitive wellness data* β no; *new sub-processor or recipient* β no; *new purpose of processing* β no; *change to a lawful basis* β no; *change to retention* β no. **No processing changed.** This is a correction to how existing processing is DESCRIBED: the policy named a deletion control the app never provided in working form, and the corrected text restates the retention and request-channel commitments already published at Β§7.2 and Β§7.3. The categories of data collected, the sinks, the legal bases and the retention periods are all unchanged. **No re-consent is triggered** β re-consent is driven by `CONSENT_VERSION` (`consentStore.ts`), which is independent of the policy document's `Version:` header; the header bump to 1.10 exists for the legal-site freshness check. The correction does not narrow any user right: no in-app analytics-deletion control existed to remove, and the request channel it now names (privacy@being.fyi) was already the documented rights channel.
**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 071ce61f..590cb8ef 100644
--- a/docs/legal/privacy-policy.md
+++ b/docs/legal/privacy-policy.md
@@ -1,8 +1,8 @@
# Privacy Policy
-**Version:** 1.9
+**Version:** 1.10
**Effective Date:** December 12, 2025
-**Last Updated:** August 6, 2026
+**Last Updated:** August 24, 2026
---
@@ -174,8 +174,8 @@ What we **NEVER** collect in-app:
Your control:
- Analytics is **OFF by default**
-- Opt-in via Settings > Privacy > Analytics
-- Request deletion via Settings > Privacy > Delete Analytics Data
+- Opt-in via **Privacy & Data > Anonymous Usage Analytics**; turning it off stops collection immediately
+- Analytics events on our servers are automatically deleted after 90 days (see Β§7.2). To request deletion sooner, email privacy@being.fyi β requests are honored within 30 days (see Β§7.3)
**Note on crisis-safety recording:** The analytics opt-in above controls what is sent to PostHog. It does **not** control the separate crisis-detection event described in Β§3 (Safety Features), which is recorded to Being's own first-party storage under a vital-interests basis and is not suppressible by analytics opt-out or universal opt-out. That event contains no raw scores and no identifying information β only aggregate category labels.
diff --git a/docs/legal/support.md b/docs/legal/support.md
index 5fabfcfa..e8318aab 100644
--- a/docs/legal/support.md
+++ b/docs/legal/support.md
@@ -53,7 +53,7 @@ Refunds are handled by Apple or Google according to their policies. Visit your a
**How do I delete my account?**
-Go to Settings > Privacy > Delete Account in the app. Your local data will be removed immediately. Cloud data (if enabled) will be deleted within 30 days.
+Go to **Privacy & Data > Delete account** in the app. Your local data will be removed immediately. Cloud data (if enabled) will be deleted within 30 days.
### Data & Privacy
@@ -63,7 +63,7 @@ By default, all your wellness data is stored locally on your device. If you enab
**How do I export my data?**
-Go to Settings > Privacy > Export Data in the app. You'll receive a JSON file containing all your check-ins, assessments, and journal entries.
+Go to **Privacy & Data > Export my data** in the app. You'll receive a JSON file containing all your check-ins, assessments, and journal entries.
**Do you sell my data?**
From 5dee2950d555e21deaf27de11ee9ed3294e9f4dc Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:11:02 -0700
Subject: [PATCH 22/90] fix: DEBUG-548 announce the Home card description to
VoiceOver
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`Pressable` defaults `accessible` to true, which collapses the subtree into a
single accessibility element, so the card's explicit `accessibilityLabel` won and
the visible `cardDescription` child was never announced. The description reached
sighted users only β and it is the only text on the card that says what the
practice actually consists of. Title gives identity, badge gives cost, hint gives
the action; nothing else gives content.
Ruled a PARITY defect rather than a WCAG conformance failure, and the item was
filed as a QUESTION whose ruling is the deliverable. 4.1.2 is met as authored,
1.3.1 has purchase only at its edge, 2.5.3 is met, 3.1.4 is AAA and outside the
AA target β so this would not fail an audit. What decides it is the inequity on
the app's single primary daily control.
Includable now only because MAINT-528 cut the description to one short sentence;
the previous 134-character enumeration would have made the announcement unusable.
That is why the suite pins a <=120 char ceiling on both branches rather than
trusting the copy to stay short β the visible text has numberOfLines={2} to clamp
it, the announcement has no such clamp.
The duration badge is ruled the other way and keeps importantForAccessibility="no":
the label already carries "5-6 min" in prose (MAINT-71 put it there deliberately),
so exposing the badge would double-speak it. The hint is untouched β DEBUG-527
pins it, and its raw FlowType slug leak is filed separately.
Two properties are load-bearing. The label interpolates the SAME `description`
prop the visible Text renders, so parity holds by construction rather than by two
strings kept in sync by hand; and ', completed today' stays lowercase and
comma-preceded, because promoting it to its own sentence reads fine and silently
breaks DEBUG-527's case-sensitive pin.
Tests derive the expected text from the rendered tree via a new
`checkin-card-description` testID rather than hardcoding today's copy, so a copy
edit that updates the visible Text but not the label goes red. Includes a
DEBUG-390 matcher-fires control β without it a derived string resolving to ''
would satisfy the parity assertion against any label, forever.
Verified: 25/25 in the accessibility suite (3 red before the fix, for the parity
assertions only, with 22 controls green); 438/438 across `npm run test:accessibility`;
5/5 in CleanHomeScreen.dailyLoop.test.tsx, a second pin on this label that matches
neither the accessibility nor the unit pattern and so runs on no PR. The
safety-tagged daily-loop-quick-depth.yaml matches `.*Daily Practice.*` at :226 and
:492 and is unaffected β the new label keeps that prefix.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01CMQzeUa97ZCj4mnE1ZMLzS
---
.../features/home/screens/CleanHomeScreen.tsx | 34 ++++++-
.../CleanHomeScreen.accessibility.test.tsx | 96 +++++++++++++++++++
2 files changed, 128 insertions(+), 2 deletions(-)
diff --git a/app/src/features/home/screens/CleanHomeScreen.tsx b/app/src/features/home/screens/CleanHomeScreen.tsx
index 1458c1ce..c9a8d066 100644
--- a/app/src/features/home/screens/CleanHomeScreen.tsx
+++ b/app/src/features/home/screens/CleanHomeScreen.tsx
@@ -83,7 +83,27 @@ const CheckInCard: React.FC = ({
]}
onPress={handlePress}
accessibilityRole="button"
- accessibilityLabel={`${title} check-in, ${duration}${isCompleted ? ', completed today' : ''}`}
+ // DEBUG-548: the description is APPENDED to the name, as its own sentence.
+ // `Pressable` defaults `accessible` to true, collapsing the subtree, so this
+ // label wins and the visible `cardDescription` below was never announced β
+ // and it is the only text on the card saying what the practice consists of.
+ // Ruled a PARITY defect, not a WCAG failure (4.1.2 is met as authored; 1.3.1
+ // is credible but not airtight). It is includable now only because MAINT-528
+ // cut the description to one short sentence; the previous 134-char version
+ // would have made the announcement unusable, which is why the accessibility
+ // suite pins a length ceiling rather than trusting the copy to stay short.
+ //
+ // Two properties are load-bearing and must survive any edit here:
+ // 1. It interpolates the SAME `description` prop the visible Text renders,
+ // so parity holds by construction rather than by a second string kept
+ // in sync by hand.
+ // 2. ', completed today' stays lowercase and comma-preceded. Promoting it
+ // to its own 'Completed today.' sentence reads fine and silently breaks
+ // the case-sensitive DEBUG-527 pin.
+ // The duration badge is ruled the OTHER way and stays `importantForAccessibility="no"`
+ // β the duration is already here in prose (MAINT-71), so exposing the badge
+ // would double-speak it.
+ accessibilityLabel={`${title} check-in, ${duration}${isCompleted ? ', completed today' : ''}. ${description}`}
accessibilityHint={
isCompleted
? 'Tap to start this check-in again'
@@ -103,7 +123,17 @@ const CheckInCard: React.FC = ({
{duration}
- {description}
+ {/* DEBUG-548: testID exists so the accessibility suite can DERIVE the
+ announced description from the rendered tree rather than snapshotting
+ today's copy. A hardcoded literal would pass while the label and the
+ visible text silently diverged, which is the defect this pins. */}
+
+ {description}
+
{/* DEBUG-527: completion is a STATE, not an action. A filled, high-contrast,
diff --git a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
index 85a8aa3a..a8e87a7b 100644
--- a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
+++ b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
@@ -298,3 +298,99 @@ describe('CleanHomeScreen β the guidance entry point (FEAT-457)', () => {
expect(getByTestId('home-guidance-entry').props.accessibilityRole).toBe('button');
});
});
+
+/**
+ * DEBUG-548: the card announces what the practice IS, not only its name and length.
+ *
+ * `Pressable` defaults `accessible` to true, which collapses the subtree into one
+ * element, so the explicit `accessibilityLabel` WINS and the visible
+ * `cardDescription` child is never read. The description reached sighted users only.
+ *
+ * The accessibility ruling (recorded on the work item) is that this is a PARITY
+ * defect, not a WCAG conformance failure β 4.1.2 is met as authored, 1.3.1 is
+ * credible but not airtight, 2.5.3 is met, and 3.1.4 is AAA and outside the AA
+ * target. What decides it is that the description is the ONLY text on the card
+ * saying what the practice consists of, and FEAT-298 collapsed the section to a
+ * SINGLE card, so the usual list-scanning objection to a longer name does not apply.
+ *
+ * The duration badge is ruled the OTHER way and stays unexposed: the label already
+ * carries "5-6 min" in prose, so announcing the badge too would double-speak it.
+ */
+describe('DEBUG-548: the card announces what the practice is', () => {
+ afterEach(() => {
+ mockPractice.completedToday = false;
+ });
+
+ const CEILING = 120;
+
+ it('carries the visible description in the announcement, derived not hardcoded', () => {
+ const { getByTestId } = render( );
+ const described = getByTestId('checkin-card-description').props.children as string;
+
+ // MATCHER-FIRES CONTROL (DEBUG-390): a derived string that silently resolved to
+ // '' would make the assertion below true of ANY label, forever. Prove the
+ // matcher has something real to match before trusting it.
+ expect(typeof described).toBe('string');
+ expect(described.length).toBeGreaterThan(10);
+
+ expect(getByTestId('checkin-card-daily-loop').props.accessibilityLabel)
+ .toContain(described);
+ });
+
+ it('keeps the duration in prose, so the muted badge loses nothing', () => {
+ // The badge is deliberately unexposed. That is only safe while the LABEL
+ // carries the duration β this pair is the ruling, in executable form.
+ const { getByTestId, getByText } = render( );
+ expect(getByTestId('checkin-card-daily-loop').props.accessibilityLabel)
+ .toMatch(/5-6 min/);
+ expect(getByText('5-6 min').props.importantForAccessibility).toBe('no');
+ });
+
+ it('keeps BOTH the completion clause and the description when completed', () => {
+ // Catches a half-applied fix that only ever touched the pending template.
+ mockPractice.completedToday = true;
+ const { getByTestId } = render( );
+ const described = getByTestId('checkin-card-description').props.children as string;
+ const label = getByTestId('checkin-card-daily-loop').props.accessibilityLabel as string;
+
+ expect(label).toMatch(/completed today/);
+ expect(label).toContain(described);
+ });
+
+ it('announces status before the static description', () => {
+ // A returning daily user is listening for the status; a new user needs the
+ // prose. Status first serves the former at no cost to the latter, because on
+ // the pending path the status clause is empty and the orders coincide.
+ mockPractice.completedToday = true;
+ const { getByTestId } = render( );
+ const described = getByTestId('checkin-card-description').props.children as string;
+ const label = getByTestId('checkin-card-daily-loop').props.accessibilityLabel as string;
+
+ expect(label.indexOf('completed today')).toBeLessThan(label.indexOf(described));
+ });
+
+ it.each([
+ ['pending', false],
+ ['completed', true],
+ ])('keeps the %s announcement under the length ceiling', (_name, completed) => {
+ // THE CONDITION UNDER WHICH THE RULING HOLDS. Including the description is
+ // correct only while it stays short; the pre-MAINT-528 copy was a 134-char
+ // enumeration that would have made the announcement unusable. The visible text
+ // has `numberOfLines={2}` to clamp it β the announcement has no such clamp, so
+ // this assertion is it.
+ mockPractice.completedToday = completed as boolean;
+ const { getByTestId } = render( );
+ const label = getByTestId('checkin-card-daily-loop').props.accessibilityLabel as string;
+ expect(label.length).toBeLessThanOrEqual(CEILING);
+ });
+
+ it('does not duplicate the description into the hint', () => {
+ // The description belongs in the NAME, where it cannot be switched off β
+ // hints are user-disableable. Doubling it produces the long-name-plus-long-hint
+ // failure mode the ruling exists to avoid.
+ const { getByTestId } = render( );
+ const described = getByTestId('checkin-card-description').props.children as string;
+ expect(getByTestId('checkin-card-daily-loop').props.accessibilityHint)
+ .not.toContain(described);
+ });
+});
From 67c36c22c0c770fe664d4b1f92890dc7b871fff9 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:14:45 -0700
Subject: [PATCH 23/90] chore: INFRA-535 vendor frozen PHIFilter baseline +
differential harness (C1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Builds the instrument BEFORE the change it will police. Asserted against the
current implementation, the one-sided relation holds by identity β which is the
point: a harness written after the behavioural change can be unconsciously tuned
until it passes.
The relation is deliberately one-sided (baseline rejects => live rejects, never
the converse), because the scan-surface tightening in C2 legitimately rejects
payloads the baseline accepted. Compliance framing: the filter may become
narrower, never looser.
Three anti-vacuity guards, per DEBUG-390: corpus size, a pinned minimum
rejection count, and a live containsPHI matcher check. The pinned count caught
its own off-by-one on first run.
The baseline lives in __tests__/helpers/ because jest testMatch globs
__tests__/**/* and exempts only setup/utils/reporters/helpers β a
__tests__/privacy/fixtures/ dir would be collected as a suite and fail. It does
not import PHIFilter (that would compare the implementation to itself) and a
mechanical spec asserts nothing under app/src references it: it is a working copy
of a looser filter, and the analytics barrel is eager on CrisisResourcesScreen
(FEAT-376).
Zero behaviour change. app/src untouched.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01TYgDdLs3wxbxwr9M5Jts4p
---
app/__tests__/helpers/phiFilterBaselineV1.ts | 160 +++++++++++++++
.../phiFilterDifferential.privacy.test.ts | 191 ++++++++++++++++++
2 files changed, 351 insertions(+)
create mode 100644 app/__tests__/helpers/phiFilterBaselineV1.ts
create mode 100644 app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
diff --git a/app/__tests__/helpers/phiFilterBaselineV1.ts b/app/__tests__/helpers/phiFilterBaselineV1.ts
new file mode 100644
index 00000000..9177f4bc
--- /dev/null
+++ b/app/__tests__/helpers/phiFilterBaselineV1.ts
@@ -0,0 +1,160 @@
+/**
+ * FROZEN BASELINE β DO NOT EDIT (INFRA-535).
+ *
+ * A verbatim copy of `PHIFilter`'s whitelist, blocklist, safe-numeric set and
+ * `validate()` as they stood at commit `d14d6178`, before the INFRA-535
+ * scan-surface tightening. It exists so the differential test can compare the
+ * live filter against a fixed reference rather than against itself.
+ *
+ * THREE RULES, each load-bearing:
+ *
+ * 1. This file MUST NOT import from `@/core/analytics/PHIFilter`. A baseline
+ * that imports the implementation compares the implementation to itself and
+ * is green by construction, whatever the implementation does.
+ *
+ * 2. This file MUST NOT be reachable from `app/src/`. It is a working copy of a
+ * LOOSER filter; if it ever became importable from src, a mis-import would
+ * silently restore pre-change semantics on a module that is eager on the
+ * crisis path (`CrisisResourcesScreen.tsx` imports the analytics barrel), and
+ * no path-based safety detector would see it. `phiFilterDifferential.privacy.test.ts`
+ * asserts mechanically that nothing under `app/src/` references it.
+ *
+ * 3. It lives in `__tests__/helpers/` and nowhere else. jest `testMatch` globs
+ * `/__tests__/**\/*` and `testPathIgnorePatterns` exempts exactly
+ * `setup/ utils/ reporters/ helpers/` β so a `__tests__/privacy/fixtures/`
+ * directory would be collected as a suite and fail "Your test suite must
+ * contain at least one test."
+ *
+ * DELIBERATE DEVIATION FROM VERBATIM: every `logSecurity(...)` call in the
+ * original `validate()` has been REMOVED. The baseline is called thousands of
+ * times by the differential corpus and its logging is not under test; keeping the
+ * calls would flood the audit ring during the suite. Nothing else was changed β
+ * no keyword added or removed, no branch reordered, no condition altered.
+ */
+
+export interface BaselineValidationResult {
+ valid: boolean;
+ reason?: string;
+}
+
+/** Verbatim as of d14d6178. */
+export const BASELINE_SAFE_EVENT_TYPES: ReadonlySet = new Set([
+ 'app_opened',
+ 'app_backgrounded',
+ 'session_started',
+ 'session_ended',
+ 'screen_viewed',
+ 'check_in_started',
+ 'check_in_completed',
+ 'assessment_started',
+ 'assessment_completed',
+ 'practice_started',
+ 'practice_completed',
+ 'breathing_exercise_started',
+ 'breathing_exercise_completed',
+ 'crisis_resources_viewed',
+ 'crisis_hotline_tapped',
+ 'settings_opened',
+ 'consent_changed',
+ 'error_occurred',
+ 'onboarding_started',
+ 'onboarding_completed',
+ 'onboarding_step_completed',
+ 'learn_content_viewed',
+ 'learn_module_started',
+ 'learn_module_completed',
+ 'guidance_opened',
+]);
+
+/** Verbatim as of d14d6178 β 28 entries. */
+export const BASELINE_PHI_KEYWORDS: ReadonlyArray = [
+ 'score',
+ 'phq',
+ 'gad',
+ 'severity',
+ 'result',
+ 'mood',
+ 'feeling',
+ 'emotion',
+ 'anxious',
+ 'depressed',
+ 'crisis_contact',
+ 'emergency_contact',
+ 'hotline_number',
+ 'suicid',
+ 'harm',
+ 'journal',
+ 'note',
+ 'entry',
+ 'reflection',
+ 'thought',
+ 'email',
+ 'phone',
+ 'name',
+ 'address',
+ 'conflict',
+ 'career',
+ 'grief',
+ 'pain',
+];
+
+/** Verbatim as of d14d6178. */
+export const BASELINE_SAFE_NUMERIC_KEYS: ReadonlySet = new Set([
+ 'duration',
+ 'duration_ms',
+ 'duration_seconds',
+ 'count',
+ 'timestamp',
+ 'step',
+ 'index',
+ 'page',
+ 'version',
+]);
+
+/**
+ * Verbatim as of d14d6178, minus the `logSecurity` calls (see header).
+ *
+ * Note what it does NOT do, because these are the gaps INFRA-535 closes and the
+ * differential must therefore permit as one-sided tightenings:
+ * - it never scans property KEYS at all;
+ * - it never calls `containsPHI`;
+ * - step 4 excludes arrays, so `{tags:['grief']}` passes intact.
+ */
+export function validateV1(
+ eventType: string,
+ eventData: Record
+): BaselineValidationResult {
+ // 1. WHITELIST CHECK
+ if (!BASELINE_SAFE_EVENT_TYPES.has(eventType)) {
+ return { valid: false, reason: `Event type "${eventType}" not in whitelist` };
+ }
+
+ // 2. PHI KEYWORD CHECK: VALUES only
+ for (const [key, value] of Object.entries(eventData)) {
+ if (typeof value === 'string') {
+ const lowerValue = value.toLowerCase();
+ for (const keyword of BASELINE_PHI_KEYWORDS) {
+ if (lowerValue.includes(keyword)) {
+ return { valid: false, reason: `PHI keyword detected: "${keyword}" in key "${key}"` };
+ }
+ }
+ }
+ }
+
+ // 3. NUMERIC VALUE CHECK
+ for (const [key, value] of Object.entries(eventData)) {
+ if (typeof value === 'number' && !BASELINE_SAFE_NUMERIC_KEYS.has(key)) {
+ return { valid: false, reason: `Suspicious numeric value in key: "${key}"` };
+ }
+ }
+
+ // 4. NESTED OBJECT CHECK β note the deliberate `!Array.isArray` exclusion.
+ for (const [, value] of Object.entries(eventData)) {
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+ const nested = validateV1(eventType, value as Record);
+ if (!nested.valid) return nested;
+ }
+ }
+
+ return { valid: true };
+}
diff --git a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
new file mode 100644
index 00000000..0f8a79da
--- /dev/null
+++ b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
@@ -0,0 +1,191 @@
+/**
+ * PHIFilter differential contract (INFRA-535).
+ *
+ * Pins ONE relation between the live filter and the frozen `d14d6178` baseline:
+ *
+ * validateV1(p) rejects βΉ PHIFilter.validate(p) rejects
+ *
+ * It is deliberately ONE-SIDED. The converse is NOT asserted, because INFRA-535
+ * legitimately makes the filter reject payloads the baseline accepted β property
+ * KEYS are now scanned, `containsPHI` now runs per property, and arrays are now
+ * traversed (the baseline's step 4 excludes them, so `{tags:['grief']}` passed
+ * intact). Asserting equivalence would forbid exactly the tightening this item
+ * exists to deliver.
+ *
+ * Compliance framing: the filter may become NARROWER, never LOOSER. This suite is
+ * the mechanical form of that ruling. A future change that lets any
+ * baseline-rejected payload through fails here.
+ *
+ * DEBUG-390 discipline: a differential harness whose corpus contains no rejections
+ * is vacuously green and stays green forever. Three guards below β corpus size,
+ * a pinned minimum rejection count, and a live matcher check β exist so this suite
+ * can still go red.
+ */
+
+import { PHIFilter } from '@/core/analytics/PHIFilter';
+import { containsPHI } from '@/core/analytics/phiDetection';
+import {
+ validateV1,
+ BASELINE_PHI_KEYWORDS,
+ BASELINE_SAFE_EVENT_TYPES,
+} from '../helpers/phiFilterBaselineV1';
+
+import * as fs from 'fs';
+import * as path from 'path';
+
+interface Case {
+ readonly label: string;
+ readonly eventType: string;
+ readonly data: Record;
+}
+
+/**
+ * The differential corpus.
+ *
+ * Grouped by what each group proves. `expectV1Reject` is not asserted directly β
+ * it documents intent and feeds the pinned-count guard, so that a future edit that
+ * accidentally neuters the corpus (e.g. renaming a keyword out of existence) is
+ * caught by the count rather than passing silently.
+ */
+const CORPUS: ReadonlyArray = [
+ // ---- Benign: must pass BOTH filters. These are the payloads real trackers send.
+ { label: 'app_opened bare', eventType: 'app_opened', data: {} },
+ { label: 'crisis_resources_viewed bare', eventType: 'crisis_resources_viewed', data: {} },
+ { label: 'crisis_hotline_tapped bare', eventType: 'crisis_hotline_tapped', data: {} },
+ { label: 'guidance_opened bare', eventType: 'guidance_opened', data: {} },
+ { label: 'screen_viewed coarsened', eventType: 'screen_viewed', data: { screen_name: 'App' } },
+ { label: 'screen_viewed Home', eventType: 'screen_viewed', data: { screen_name: 'Home' } },
+ { label: 'check_in_completed duration', eventType: 'check_in_completed', data: { duration_ms: 5000 } },
+ { label: 'learn_content_viewed module', eventType: 'learn_content_viewed', data: { module_id: 'm1' } },
+ { label: 'learn_module_completed pair', eventType: 'learn_module_completed', data: { module_id: 'm1', duration_ms: 900 } },
+ { label: 'onboarding_step_completed step', eventType: 'onboarding_step_completed', data: { step: 3 } },
+ { label: 'error_occurred type', eventType: 'error_occurred', data: { error_type: 'network' } },
+ { label: 'assessment_completed duration', eventType: 'assessment_completed', data: { duration_ms: 42000 } },
+ { label: 'practice_completed duration', eventType: 'practice_completed', data: { duration_ms: 300000 } },
+ { label: 'breathing_exercise_started bare', eventType: 'breathing_exercise_started', data: {} },
+ { label: 'settings_opened bare', eventType: 'settings_opened', data: {} },
+ { label: 'consent_changed bare', eventType: 'consent_changed', data: {} },
+
+ // ---- V1 rejects: non-whitelisted event NAME.
+ { label: 'unknown event', eventType: 'voice_journal_started', data: {} },
+ { label: 'journal_entry_saved', eventType: 'journal_entry_saved', data: {} },
+ { label: 'reflection_transcribed', eventType: 'reflection_transcribed', data: {} },
+ { label: 'made-up event', eventType: 'totally_new_event', data: { step: 1 } },
+
+ // ---- V1 rejects: PHI keyword in a string VALUE.
+ { label: 'value grief', eventType: 'screen_viewed', data: { screen: 'grief' } },
+ { label: 'value career', eventType: 'learn_content_viewed', data: { topic: 'career' } },
+ { label: 'value conflict', eventType: 'app_opened', data: { detail: 'conflict at work' } },
+ { label: 'value journal', eventType: 'app_opened', data: { detail: 'my journal from tonight' } },
+ { label: 'value note', eventType: 'app_opened', data: { detail: 'a note to self' } },
+ { label: 'value suicid stem', eventType: 'app_opened', data: { detail: 'suicidal thoughts' } },
+ { label: 'value harm', eventType: 'app_opened', data: { detail: 'self harm' } },
+ { label: 'value phq', eventType: 'assessment_completed', data: { label: 'phq total' } },
+ { label: 'value mood', eventType: 'check_in_completed', data: { label: 'mood is low' } },
+ { label: 'value email', eventType: 'settings_opened', data: { field: 'email address' } },
+
+ // ---- V1 rejects: suspicious numeric in a non-safe key.
+ { label: 'numeric score', eventType: 'assessment_completed', data: { total: 18 } },
+ { label: 'numeric rating', eventType: 'check_in_completed', data: { rating: 4 } },
+ { label: 'numeric q9', eventType: 'assessment_completed', data: { q9: 2 } },
+
+ // ---- V1 rejects: nested object violation.
+ { label: 'nested keyword value', eventType: 'app_opened', data: { meta: { detail: 'grief' } } },
+ { label: 'nested numeric', eventType: 'app_opened', data: { meta: { total: 21 } } },
+
+ // ---- V1 ACCEPTS these. They are inert under the one-sided relation by design β
+ // it asserts nothing where the baseline passed β and they are here so the
+ // corpus already covers the gaps the scan-surface tightening closes.
+ { label: 'array of keyword strings', eventType: 'app_opened', data: { tags: ['grief'] } },
+ { label: 'array nested deeper', eventType: 'app_opened', data: { tags: [['career']] } },
+ { label: 'array inside object', eventType: 'app_opened', data: { meta: { tags: ['suicidal'] } } },
+ { label: 'PHI keyword as KEY', eventType: 'check_in_completed', data: { mood: 'ok' } },
+ { label: 'PHI keyword as key segment', eventType: 'assessment_completed', data: { phq_score: 'x' } },
+ { label: 'journal key', eventType: 'app_opened', data: { journal_id: 'abc' } },
+];
+
+/**
+ * Pinned literal, equal to the corpus's exact baseline-rejection count today
+ * (4 non-whitelisted names + 10 keyword values + 3 suspicious numerics + 2 nested).
+ * Growth is fine; shrinkage is not. If a future edit trims the corpus or neuters
+ * the keyword list, this goes red rather than the suite passing over nothing.
+ */
+const MIN_BASELINE_REJECTIONS = 19;
+
+describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () => {
+ const baselineRejections = CORPUS.filter((c) => !validateV1(c.eventType, c.data).valid);
+
+ describe('anti-vacuity guards (DEBUG-390)', () => {
+ it('the corpus is non-empty and substantial', () => {
+ expect(CORPUS.length).toBeGreaterThanOrEqual(35);
+ });
+
+ it('the baseline actually rejects a pinned minimum of the corpus', () => {
+ // Without this, a corpus of only-benign payloads satisfies the one-sided
+ // relation completely and goes green forever.
+ expect(baselineRejections.length).toBeGreaterThanOrEqual(MIN_BASELINE_REJECTIONS);
+ });
+
+ it('the baseline still fires on a literal known-bad payload', () => {
+ expect(validateV1('app_opened', { detail: 'grief' }).valid).toBe(false);
+ expect(validateV1('not_a_real_event', {}).valid).toBe(false);
+ });
+
+ it('the baseline keyword list and whitelist are intact', () => {
+ expect(BASELINE_PHI_KEYWORDS).toHaveLength(28);
+ expect(BASELINE_SAFE_EVENT_TYPES.size).toBe(25);
+ });
+
+ it('containsPHI still fires on a literal known-bad string', () => {
+ // Proves the detector this suite reasons about is live, not a stub.
+ expect(containsPHI({ v: 'reach me at a@b.com' })).toBe(true);
+ expect(containsPHI({ v: 'PHQ-9: 21' })).toBe(true);
+ expect(containsPHI({ v: 'nothing sensitive here' })).toBe(false);
+ });
+ });
+
+ describe('ONE-SIDED relation: anything the baseline rejected is still rejected', () => {
+ it.each(CORPUS.map((c) => [c.label, c] as const))(
+ '%s',
+ (_label, c) => {
+ const before = validateV1(c.eventType, c.data);
+ const after = PHIFilter.validate(c.eventType, c.data);
+
+ if (!before.valid) {
+ expect(after.valid).toBe(false);
+ }
+ // Deliberately no assertion when `before.valid` is true: the live filter
+ // is permitted to be stricter. See the TIGHTENED group.
+ }
+ );
+ });
+
+ describe('the frozen baseline is unreachable from app/src (FEAT-376)', () => {
+ const SRC = path.resolve(__dirname, '../../src');
+
+ const walk = (dir: string, acc: string[] = []): string[] => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) walk(p, acc);
+ else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p);
+ }
+ return acc;
+ };
+
+ const files = walk(SRC);
+ const NEEDLE = 'phiFilterBaselineV1';
+
+ it('the scan actually found source files (anti-vacuity)', () => {
+ expect(files.length).toBeGreaterThan(200);
+ });
+
+ it('the matcher fires against a literal known-bad string (anti-vacuity)', () => {
+ expect(`import { validateV1 } from '../${NEEDLE}';`).toContain(NEEDLE);
+ });
+
+ it('no file under app/src references the frozen baseline', () => {
+ const offenders = files.filter((f) => fs.readFileSync(f, 'utf8').includes(NEEDLE));
+ expect(offenders).toEqual([]);
+ });
+ });
+});
From 2b9be5923a19a874adf94f04c255d4e356f2baad Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:14:55 -0700
Subject: [PATCH 24/90] chore: INFRA-523 correct the falsified free-text
zero-false-negative claim
AC1 of INFRA-523, which is explicitly independent of the corpus half. Comment and
doc only -- no executable line under app/src changes, verified mechanically.
TWO false claims were live, not one. The item was filed for the first; the crisis
pass found the second.
1. RECALL. textCrisisDetection.ts stated "Zero-false-negative holds over
correctly-transcribed text". Re-derived this run by executing the six compiled
patterns from CRISIS_TEXT_PATTERN_SOURCES: "i keep thinking about killing
myself", "i have been thinking about ending it all", "i cant go on anymore",
"suicide", "i want to end my life" and "i wish i was dead" all MISS, and all
are correctly-transcribed text. Four controls fired in the same run, so the
matcher was live. The header now claims precision over a fixed vocabulary and
states recall is UNMEASURED, naming the three verified miss classes.
2. A COMPENSATING CONTROL THAT DOES NOT EXIST. The same paragraph rested residual
risk on "a support line surfaced on low-confidence transcripts". There is no
such control: the only two occurrences of "low-confidence" under app/src were
the two comments asserting it, there is no confidence signal in features/journal
at all, and the SUPPORT_LINE that exists belongs to practices/dailyloop. The
journal's in-screen banner compensates for nothing -- it renders only once the
scan has already fired. Fixing the recall claim while re-asserting this one
would have laundered the correction, so both go.
Four locations, because a corrected module beside a test header still titled
"SCOPE OF THE ZERO-FALSE-NEGATIVE GUARANTEE" leaves the repo self-contradicting:
- textCrisisDetection.ts -- the header paragraph, and a second absolute at the
tail-scan note ("the one outcome this module must never produce").
- textCrisisDetection.unit.test.ts -- retitled to what the suite establishes; it
also claimed the nonexistent support line was "pinned by their own tests".
- textCrisisDetection.corpus.test.ts -- one clause restating the falsified scoping.
- crisis-analytics-runbook.md -- parenthetical scoping to the score path.
NOT touched, deliberately: ~30 further zero-false-negative references covering the
SCORE path (detectCrisis() over PHQ-9/GAD-7 integers), where the contract is
genuine and total. A repo-wide sed of this phrase would weaken a contract that
holds in order to fix one that does not.
This changes no detection behaviour and no user's safety posture. It makes the
documentation stop overstating a gap that remains live and unmitigated on the
keyboard-up journal surface (DEBUG-506). Closing the gap is AC2/AC3, blocked on
an ANTHROPIC_API_KEY that does not exist in this environment and on hand review.
Widening CRISIS_TEXT_PATTERN_SOURCES remains out of scope -- it feeds
journalCrisisScanner.scan -> showCrisisAlert(), so widening for recall buys alarm
fatigue on a journaling surface. Its own item, with a crisis pass.
Crisis pass: ruling applied as given, including the override of my brief (I asked
to preserve the compensating controls; the specialist refused, correctly).
Maestro gate: not run. INFRA-256's inert filter skips comment-only diffs, and a
flow cannot observe a comment -- conditions recorded in the Notion close-out.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EpS4PU7NAJASNf6pLcoGs5
---
.../textCrisisDetection.corpus.test.ts | 6 ++--
.../textCrisisDetection.unit.test.ts | 18 +++++------
.../crisis/services/textCrisisDetection.ts | 31 +++++++++++++++----
docs/development/crisis-analytics-runbook.md | 2 +-
4 files changed, 37 insertions(+), 20 deletions(-)
diff --git a/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
index a35e7ece..34922af4 100644
--- a/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
+++ b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
@@ -9,9 +9,9 @@
* RECORDED, not silently enforced. A threshold set before the number is known either
* passes vacuously or blocks every PR.
*
- * WHY THE THREE FIGURES ARE NEVER BLENDED. The module scopes its guarantee to
- * correctly-transcribed text and delegates recognizer error to mitigations outside
- * itself. A single blended number would understate the in-contract failure and
+ * WHY THE THREE FIGURES ARE NEVER BLENDED. The module's contract is precision over
+ * a fixed vocabulary; in-contract recall and recognizer error are different
+ * failures. A single blended number would understate the in-contract failure and
* overstate the out-of-contract one.
*
* THIS FILE IS SELECTED BY CI. Every path under `features/crisis/` matches
diff --git a/app/src/features/crisis/services/__tests__/textCrisisDetection.unit.test.ts b/app/src/features/crisis/services/__tests__/textCrisisDetection.unit.test.ts
index 7605bbf3..5f39dc0a 100644
--- a/app/src/features/crisis/services/__tests__/textCrisisDetection.unit.test.ts
+++ b/app/src/features/crisis/services/__tests__/textCrisisDetection.unit.test.ts
@@ -1,18 +1,16 @@
/**
* textCrisisDetection β unit specs (FEAT-283 Slice A)
*
- * SCOPE OF THE ZERO-FALSE-NEGATIVE GUARANTEE β read before trusting this suite.
+ * WHAT THIS SUITE ESTABLISHES β read before citing it.
*
- * These specs pin detection over *correctly-transcribed text only*. They do NOT
- * and cannot establish that a spoken crisis disclosure is always detected: an
- * on-device speech recognizer may mishear "I want to die" as "I want a die",
- * and no assertion in this file would fail. The mitigations for that failure
- * mode are layered elsewhere (an always-reachable crisis affordance that is
- * never route-suppressed, and a low-confidence support line on the review
- * screen) and are pinned by their own tests.
+ * These specs pin that the shipped vocabulary matches, that near-misses do not,
+ * and that the result leaks no content. They do NOT establish that every crisis
+ * disclosure is detected: recall is unmeasured and known misses exist
+ * (`textCrisisDetection.ts` header; INFRA-512 Β§3). Nor can they see recognizer
+ * error β a mishearing of "I want to die" fails no assertion here.
*
- * Do not cite this suite as evidence that voice capture is safe. Cite it as
- * evidence that the text scanner behaves correctly on the text it is given.
+ * Cite this suite as evidence that the approved phrases still fire. Do not cite
+ * it as evidence that voice capture is safe, or that a null scan means no crisis.
*/
import {
diff --git a/app/src/features/crisis/services/textCrisisDetection.ts b/app/src/features/crisis/services/textCrisisDetection.ts
index db77eeb6..8a29398f 100644
--- a/app/src/features/crisis/services/textCrisisDetection.ts
+++ b/app/src/features/crisis/services/textCrisisDetection.ts
@@ -37,10 +37,28 @@
* unbounded, unlike a nine-item questionnaire, so the <200ms crisis budget
* is only defensible if cost stays linear in input length.
*
- * THE GUARANTEE IS SCOPED. Zero-false-negative holds over correctly-transcribed
- * text. A recognizer that mishears a disclosure defeats any text scanner; the
- * mitigations for that live outside this module (an always-reachable crisis
- * affordance, and a support line surfaced on low-confidence transcripts).
+ * THE GUARANTEE IS PRECISION, NOT RECALL. What this module guarantees is that a
+ * small, fixed, hand-approved vocabulary matches deterministically, in linear
+ * time, without leaking content. It does NOT guarantee that every disclosure
+ * matches. Recall is UNMEASURED, and three miss classes are verified
+ * (INFRA-512 Β§3): morphological variants of approved phrases ("killing myself"
+ * against `kill\s*my\s*self`), contractions the normalizer does not expand
+ * ("cant" against `can\s*not`), and phrasings no pattern covers at all ("i wish
+ * i was dead"). A null result is not evidence of no crisis.
+ *
+ * Recognizer error is a SEPARATE axis stacked on top of that: a mishearing
+ * defeats any text scanner, and nothing in this module or its specs can see it.
+ *
+ * The residual risk rests on the root crisis button, which stays mounted here β
+ * `VoiceReflection` is not in `RootCrisisButton.SUPPRESSED_ROUTES`. That is a
+ * user-initiated affordance, not a detector, and DEBUG-506 leaves it
+ * unreachable while the keyboard is up, which is the state a user is in while
+ * correcting a transcript. The in-screen crisis banner compensates for nothing:
+ * it renders only once this scan has already fired.
+ *
+ * Recall is RECORDED, not thresholded, by `__tests__/textCrisisDetection.corpus.test.ts`
+ * against `docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md`.
+ * Widening the pattern set is NOT the remedy β see `CRISIS_TEXT_PATTERN_SOURCES`.
*/
/**
@@ -134,8 +152,9 @@ export function normalizeForCrisisScan(text: string): string {
* object, so a caller cannot accidentally treat a falsy result as a positive.
*
* The scan is never truncated for long input. Exceeding the budget is a logged
- * degradation the caller reports; an unscanned tail would be a false negative
- * by construction, which is the one outcome this module must never produce.
+ * degradation the caller reports; an unscanned tail would add a miss on text the
+ * pattern set would otherwise have caught, which is the one miss class this
+ * module can eliminate outright.
*/
export function detectCrisisInText(text: string): TextCrisisDetection | null {
const startedAt = performance.now();
diff --git a/docs/development/crisis-analytics-runbook.md b/docs/development/crisis-analytics-runbook.md
index e6ee6f87..b2a1292e 100644
--- a/docs/development/crisis-analytics-runbook.md
+++ b/docs/development/crisis-analytics-runbook.md
@@ -7,7 +7,7 @@ catch detection drift, and produce an aggregate, PII-free record for the DPIA pa
> [!IMPORTANT]
> **This dashboard is monitoring-only. It is NOT the safety mechanism.** The crisis
-> safety guarantees (988 < 3 taps / < 3 s, detection < 200 ms, zero false negatives) are
+> safety guarantees (988 < 3 taps / < 3 s, detection < 200 ms, zero false negatives on the score path) are
> enforced in-app and recorded by the **on-device crisis audit log**, which is independent
> of this telemetry. These views observe an *aggregate copy* of detection events for
> operational assurance. Never treat the dashboard as the accountability record, and never
From 812bb5768f8124778eb59a8782e1f448ccd50853 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:21:10 -0700
Subject: [PATCH 25/90] fix: DEBUG-515 stop cross-test real-timer leakage
flaking assessmentStore
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The item was filed as a debounce/disable race in the store. It is not one, and
the store is not what was wrong.
`autoSaveEnabled` defaults to true and `resetAssessment()` does not clear it,
while `jest.useFakeTimers()` is installed only inside the Auto-Save describe. So
every earlier describe ran with the module-level autosave subscription ARMED
under REAL timers, scheduling one uncancelled 1000ms `setTimeout` per answer β
the PHQ-9 0-27 loop alone schedules hundreds. Those mature about a second later,
inside whatever test is running by then, and each calls `saveProgress()` against
CURRENT state, which by that point holds `phq9_1`. That is the reported payload,
and the count varies (9, 8, 2, 1 observed) because it is a batch of deferred
callbacks, not one deterministic producer.
The discriminating experiment: 8 concurrent runs with the earlier describes
SKIPPED (-t "Auto-Save Functionality") pass 8/8, while the same load with them
included fails 4/8. The failure requires prior tests to have run, so it is a
test-lifecycle property.
Fix is test-only, three parts. The outer beforeEach disarms the subscription for
every test that does not opt in β safe because every storage assertion outside
the Auto-Save describe is driven by an explicit `saveProgress()` call, never by
autosave firing. The Auto-Save describe's afterEach now CLEARS rather than RUNS
its pending timers (`runOnlyPendingTimers` fired the enabled test's leftover
callback, whose async tail resolved inside the next test) and disarms what it
deliberately armed. An outer afterEach pins the invariant as an assertion: no
test may leave the subscription armed.
That assertion is deliberately not a timer-count check. The leaked handles are
`unref`'d, which makes them invisible to --detectOpenHandles,
process.getActiveResourcesInfo() and process._getActiveHandles() alike, and
jest.getTimerCount() counts only fake timers. The flag is the only observable
proxy.
Verified by RATE under LOAD, because a serial run on an idle machine passes even
unfixed and would falsely exonerate:
baseline (unfixed, 8 concurrent): 4 failures / 8 β all `respects auto-save
disabled state`, reproducing the item's
reported ~40%
fixed (same load): 0 failures / 8
AC1 (20 sequential): 20 passed / 0 failed
red proof (assertion alone, no fix): 13 failed / 4 passed β the 4 that pass
are tests that already left the flag false,
so the harness is demonstrably live
Two corrections of record, both carried into the item. AC1 as filed is
NON-DISCRIMINATING: the file alone passes 11/11 on unfixed HEAD, so "20
consecutive passes of that one test file" is satisfied before a line is changed.
And "NOT OBSERVED ON CI" is VACUOUS: `jest --listTests` shows
--testPathPattern=clinical and =unit both match this file zero times and ci.yml
has no `npm run test` step, so CI never runs it at all. Sibling files in the same
directory ARE gated, which is what hides the gap.
The zustand persist middleware is a genuinely separate ungated writer, but it is
innocent here: it goes through createJSONStorage and so writes
{state:{answers},version:0}, while the test filter reads `data.answers` at top
level. Not touched.
The production defect underneath β the disable flag read at schedule time and
never at fire time, plus zero clearTimeout β is real but currently unreachable
(enableAutoSave/disableAutoSave have zero production callers) and would tier
RED-ATTENDED. Filed as DEBUG-549 rather than absorbed, which is what keeps this
green. DEBUG-550 filed for an adjacent finding: completeAssessment scores an
unvalidated answer set.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01CMQzeUa97ZCj4mnE1ZMLzS
---
.../stores/__tests__/assessmentStore.test.ts | 33 ++++++++++++++++---
1 file changed, 29 insertions(+), 4 deletions(-)
diff --git a/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts b/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts
index 65820925..5e647530 100644
--- a/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts
+++ b/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts
@@ -72,6 +72,12 @@ describe('Assessment Store - Clinical Validation', () => {
jest.clearAllMocks();
for (const k of Object.keys(mockWellnessBlobs)) delete mockWellnessBlobs[k];
useAssessmentStore.getState().resetAssessment();
+ // DEBUG-515: `resetAssessment()` does NOT clear `autoSaveEnabled`, and it
+ // defaults to true β so without this line every describe outside Auto-Save runs
+ // with the subscription armed under REAL timers. Safe because every storage
+ // assertion outside that describe is driven by an EXPLICIT `saveProgress()`
+ // call, never by autosave firing.
+ useAssessmentStore.setState({ autoSaveEnabled: false });
// Mock SecureStore for testing
mockSecureStore.setItemAsync.mockResolvedValue();
@@ -84,8 +90,21 @@ describe('Assessment Store - Clinical Validation', () => {
});
afterEach(() => {
- // Clean up after each test - no timer management needed
- // Timer cleanup only needed when jest.useFakeTimers() is used
+ // DEBUG-515: no test may leave the autosave subscription ARMED.
+ //
+ // The module-level subscription schedules a real 1000ms `setTimeout` per answer
+ // whenever `autoSaveEnabled` is true, and the file contains zero `clearTimeout`,
+ // so N answers leak N uncancelled timers. `jest.useFakeTimers()` is installed
+ // only inside the Auto-Save describe, so every OTHER describe schedules REAL
+ // timers that mature ~1s later β inside whatever test is running by then β and
+ // call `saveProgress()` against CURRENT state.
+ //
+ // This assertion is the root cause stated as an invariant. It is deliberately
+ // NOT a timer-count assertion: the leaked handles are `unref`'d, which makes
+ // them invisible to --detectOpenHandles, process.getActiveResourcesInfo() and
+ // process._getActiveHandles() alike, and jest.getTimerCount() counts only FAKE
+ // timers. The flag is the only observable proxy for the leak.
+ expect(useAssessmentStore.getState().autoSaveEnabled).toBe(false);
});
describe('PHQ-9 Clinical Accuracy', () => {
@@ -474,9 +493,15 @@ describe('Assessment Store - Clinical Validation', () => {
});
afterEach(() => {
- // Clean up fake timers when used in this describe block
- jest.runOnlyPendingTimers();
+ // DEBUG-515: CLEAR, do not RUN. `jest.runOnlyPendingTimers()` FIRES the
+ // enabled test's leftover autosave callback, whose async tail
+ // (saveProgress -> set({lastSavedAt}) -> persist write) then resolves inside
+ // the NEXT test. Clearing drops nothing any assertion here depends on.
+ jest.clearAllTimers();
jest.useRealTimers();
+ // This describe is the only one that deliberately ARMS the subscription, so
+ // it is the only one that has to disarm it β see the outer afterEach.
+ useAssessmentStore.setState({ autoSaveEnabled: false });
});
it('auto-saves progress after each answer when enabled', async () => {
From 103797db43d0f78fe09756a63e849d81b8d08880 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:22:10 -0700
Subject: [PATCH 26/90] chore: INFRA-535 scan property keys, values and arrays
(C2)
Widens what the filter LOOKS AT, under unchanged whole-event-reject semantics.
Three surfaces it did not scan before:
- property KEYS. Matched by SEGMENT, not substring: substring matching blocks
`campaign_id` (contains "pain") and `notation` (contains "note"), and a filter
that blocks ordinary keys is one the next person weakens. A single-segment
keyword matches a segment equal to it or prefixed by it, because `suicid` and
`harm` are deliberately stems. Applied to KEYS ONLY -- on values it would
loosen the very stem it exists to catch.
- property values via containsPHI, armed here for the first time. It had zero
production importers and its docblock's claim that it "gates every analytics
event" was false; that false claim is filed as DEBUG-553.
- ARRAY members. Closes a live hole: the value branch tested typeof value ===
'string' and the nested branch excluded arrays, so {tags:['grief']} shipped
intact.
SAFE_PROPERTY_KEYS exempts the NEW checks only; the pre-existing value and numeric
checks still apply to every key in it, so {screen_name:'grief'} is still blocked.
Two entries are load-bearing: screen_name (segments to ['screen','name'], and
'name' is an exact keyword hit -- without it screen_viewed becomes a whole-event
reject, and it fires in the same useFocusEffect as trackCrisisResourcesViewed),
and the SAFE_NUMERIC_KEYS members (a 13-digit Date.now() matches containsPHI's
long-identifier pattern, which is the MAINT-202 defect that dropped every
consent-passing event).
The event NAME is never scanned, and hotline_number is not shortened -- either
would silently self-block crisis_hotline_tapped forever. Both pinned by name.
logSecurity is aggregated to at most one call per validate(): it reaches
LogLevel.ERROR, a 1000-entry FIFO audit ring and a production console.error
synchronously, and the old nested recursion logged from the inner frame.
Strictly a tightening. 33 privacy suites / 624 tests green, and the differential
against the frozen d14d6178 baseline confirms nothing the old filter rejected is
now accepted.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01TYgDdLs3wxbxwr9M5Jts4p
---
.../phiFilterDifferential.privacy.test.ts | 29 ++-
.../phiFilterScanSurface.privacy.test.ts | 228 ++++++++++++++++++
app/src/core/analytics/PHIFilter.ts | 225 +++++++++++++----
3 files changed, 435 insertions(+), 47 deletions(-)
create mode 100644 app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts
diff --git a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
index 0f8a79da..f601206d 100644
--- a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
+++ b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
@@ -93,9 +93,10 @@ const CORPUS: ReadonlyArray = [
{ label: 'nested keyword value', eventType: 'app_opened', data: { meta: { detail: 'grief' } } },
{ label: 'nested numeric', eventType: 'app_opened', data: { meta: { total: 21 } } },
- // ---- V1 ACCEPTS these. They are inert under the one-sided relation by design β
- // it asserts nothing where the baseline passed β and they are here so the
- // corpus already covers the gaps the scan-surface tightening closes.
+ // ---- V1 ACCEPTS these; INFRA-535 rejects them. The one-sided assertion says
+ // nothing about them, which is the point β but the `TIGHTENED` group below
+ // asserts the live filter does in fact catch them, so the tightening cannot
+ // silently disappear.
{ label: 'array of keyword strings', eventType: 'app_opened', data: { tags: ['grief'] } },
{ label: 'array nested deeper', eventType: 'app_opened', data: { tags: [['career']] } },
{ label: 'array inside object', eventType: 'app_opened', data: { meta: { tags: ['suicidal'] } } },
@@ -104,6 +105,16 @@ const CORPUS: ReadonlyArray = [
{ label: 'journal key', eventType: 'app_opened', data: { journal_id: 'abc' } },
];
+/** Payloads V1 accepts but the tightened filter must now reject. */
+const TIGHTENED: ReadonlyArray = [
+ 'array of keyword strings',
+ 'array nested deeper',
+ 'array inside object',
+ 'PHI keyword as KEY',
+ 'PHI keyword as key segment',
+ 'journal key',
+];
+
/**
* Pinned literal, equal to the corpus's exact baseline-rejection count today
* (4 non-whitelisted names + 10 keyword values + 3 suspicious numerics + 2 nested).
@@ -160,6 +171,18 @@ describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () =>
);
});
+ describe('the tightening is real and did not silently disappear', () => {
+ it.each(TIGHTENED.map((label) => [label] as const))(
+ '%s: baseline accepts, live filter rejects',
+ (label) => {
+ const c = CORPUS.find((x) => x.label === label);
+ expect(c).toBeDefined();
+ expect(validateV1(c!.eventType, c!.data).valid).toBe(true);
+ expect(PHIFilter.validate(c!.eventType, c!.data).valid).toBe(false);
+ }
+ );
+ });
+
describe('the frozen baseline is unreachable from app/src (FEAT-376)', () => {
const SRC = path.resolve(__dirname, '../../src');
diff --git a/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts b/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts
new file mode 100644
index 00000000..7516518c
--- /dev/null
+++ b/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts
@@ -0,0 +1,228 @@
+/**
+ * PHIFilter scan-surface contract (INFRA-535, C2).
+ *
+ * Pins the three surfaces the filter did NOT scan before this change, all under
+ * UNCHANGED whole-event-reject semantics:
+ *
+ * 1. property KEYS (previously never scanned β "keys are controlled by us");
+ * 2. property values via `containsPHI` (never once armed: the predicate had zero
+ * production importers, and its own docblock's claim that it "gates every
+ * analytics event" was false β see DEBUG-553);
+ * 3. ARRAY members (the old step 4 excluded arrays, so `{tags:['grief']}` shipped
+ * intact).
+ *
+ * Every assertion here is a TIGHTENING. Nothing in this suite permits a payload the
+ * previous filter rejected β that direction is pinned mechanically by
+ * `phiFilterDifferential.privacy.test.ts`.
+ *
+ * The crisis rulings this suite exists to keep true are called out inline. They are
+ * not decoration: each one names a way the tightening could silently and
+ * permanently disable a working crisis or navigation event.
+ */
+
+jest.mock('@/core/services/logging', () => ({
+ logSecurity: jest.fn(),
+ logAnalytics: jest.fn(),
+}));
+
+import { PHIFilter } from '@/core/analytics/PHIFilter';
+import { logSecurity } from '@/core/services/logging';
+
+const mockLogSecurity = logSecurity as jest.MockedFunction;
+
+beforeEach(() => {
+ mockLogSecurity.mockClear();
+});
+
+describe('PHIFilter scan surface (INFRA-535)', () => {
+ describe('KEY scanning β new, and the allowlist that makes it survivable', () => {
+ it('blocks a PHI keyword appearing as a whole key', () => {
+ expect(PHIFilter.validate('check_in_completed', { mood: 'ok' }).valid).toBe(false);
+ expect(PHIFilter.validate('app_opened', { journal: 'x' }).valid).toBe(false);
+ });
+
+ it('blocks a PHI keyword appearing as one segment of a key', () => {
+ expect(PHIFilter.validate('assessment_completed', { phq_score: 'x' }).valid).toBe(false);
+ expect(PHIFilter.validate('app_opened', { journal_id: 'abc' }).valid).toBe(false);
+ expect(PHIFilter.validate('app_opened', { userEmail: 'x' }).valid).toBe(false);
+ });
+
+ it('blocks a stem keyword as a key prefix', () => {
+ // `suicid` and `harm` are deliberately stems, not whole words.
+ expect(PHIFilter.validate('app_opened', { suicidal_flag: 'x' }).valid).toBe(false);
+ expect(PHIFilter.validate('app_opened', { harmful: 'x' }).valid).toBe(false);
+ });
+
+ it('does NOT block a key that merely CONTAINS a keyword mid-segment', () => {
+ // This is what segment matching buys over substring matching. `campaign`
+ // contains "pain"; `notation` contains "note". Substring matching would
+ // block both, and a filter that blocks ordinary keys gets weakened by the
+ // next person who hits it.
+ expect(PHIFilter.validate('app_opened', { campaign_id: 'spring' }).valid).toBe(true);
+ expect(PHIFilter.validate('app_opened', { notation: 'x' }).valid).toBe(true);
+ });
+
+ it('CRISIS PIN: screen_name is allowlisted, so screen_viewed still emits', () => {
+ // `screen_name` segments to ['screen','name'] and `name` is an exact
+ // PHI_KEYWORDS hit. Without the allowlist this degrades to a whole-event
+ // reject β and screen_viewed fires in the SAME useFocusEffect as
+ // trackCrisisResourcesViewed, so crisis-screen reach becomes unmeasurable.
+ expect(PHIFilter.validate('screen_viewed', { screen_name: 'App' }).valid).toBe(true);
+ expect(PHIFilter.validate('screen_viewed', { screen_name: 'Home' }).valid).toBe(true);
+ });
+
+ it('the allowlist exempts only the NEW checks, never the existing VALUE scan', () => {
+ // An allowlisted key carrying a keyword VALUE must still be rejected, or the
+ // allowlist would be a loosening rather than a false-positive fix.
+ expect(PHIFilter.validate('screen_viewed', { screen_name: 'grief' }).valid).toBe(false);
+ });
+
+ it('every real tracker key survives the key scan', () => {
+ // Derived from the literal keys in useAnalytics.ts.
+ const real: Array<[string, Record]> = [
+ ['screen_viewed', { screen_name: 'App' }],
+ ['check_in_completed', { duration_ms: 5000 }],
+ ['learn_content_viewed', { module_id: 'm1' }],
+ ['learn_module_completed', { module_id: 'm1', duration_ms: 900 }],
+ ['onboarding_step_completed', { step: 3 }],
+ ['error_occurred', { error_type: 'network' }],
+ ];
+ for (const [evt, data] of real) {
+ expect(PHIFilter.validate(evt, data)).toEqual({ valid: true });
+ }
+ });
+ });
+
+ describe('the EVENT NAME is never key-scanned', () => {
+ it('CRISIS PIN: crisis_hotline_tapped validates despite containing "hotline"', () => {
+ // `hotline_number` is a PHI keyword. If the scan were ever applied to the
+ // event name, or if `hotline_number` were shortened to `hotline`, this event
+ // would self-block forever and the app would lose its 988-reach signal.
+ expect(PHIFilter.validate('crisis_hotline_tapped', {})).toEqual({ valid: true });
+ });
+
+ it('CRISIS PIN: crisis_resources_viewed validates', () => {
+ expect(PHIFilter.validate('crisis_resources_viewed', {})).toEqual({ valid: true });
+ });
+
+ it('the keyword list still contains the un-shortened hotline_number', () => {
+ // Guards the shortening directly, not just its symptom.
+ const src = require('fs').readFileSync(
+ require('path').resolve(__dirname, '../../src/core/analytics/PHIFilter.ts'),
+ 'utf8'
+ );
+ expect(src).toContain("'hotline_number'");
+ });
+ });
+
+ describe('containsPHI per property β armed for the first time', () => {
+ it('blocks an email in a property value', () => {
+ expect(PHIFilter.validate('settings_opened', { field: 'reach me at a@b.com' }).valid).toBe(false);
+ });
+
+ it('blocks a long numeric identifier in a property value', () => {
+ expect(PHIFilter.validate('app_opened', { ref: '1234567890123' }).valid).toBe(false);
+ });
+
+ it('blocks a UUID in a property value', () => {
+ expect(
+ PHIFilter.validate('app_opened', { ref: '123e4567-e89b-12d3-a456-426614174000' }).valid
+ ).toBe(false);
+ });
+
+ it('MAINT-202 PIN: an allowlisted numeric key is exempt from containsPHI', () => {
+ // A 13-digit Date.now() matches the \b\d{10,}\b identifier pattern. Arming
+ // containsPHI without exempting allowlisted keys reintroduces exactly the
+ // MAINT-202 defect, which silently dropped every consent-passing event.
+ expect(PHIFilter.validate('app_opened', { timestamp: 1755000000000 })).toEqual({ valid: true });
+ expect(PHIFilter.validate('app_opened', { timestamp: '1755000000000' })).toEqual({ valid: true });
+ });
+ });
+
+ describe('ARRAY scanning β closes a live hole', () => {
+ it('blocks a PHI keyword inside an array of strings', () => {
+ // Shipped intact before this change: the value branch tested
+ // `typeof value === 'string'` and step 4 excluded arrays.
+ expect(PHIFilter.validate('app_opened', { tags: ['grief'] }).valid).toBe(false);
+ });
+
+ it('blocks a keyword in a nested array', () => {
+ expect(PHIFilter.validate('app_opened', { tags: [['career']] }).valid).toBe(false);
+ });
+
+ it('blocks a keyword in an array inside an object', () => {
+ expect(PHIFilter.validate('app_opened', { meta: { tags: ['suicidal'] } }).valid).toBe(false);
+ });
+
+ it('allows a benign array', () => {
+ expect(PHIFilter.validate('app_opened', { tags: ['alpha', 'beta'] })).toEqual({ valid: true });
+ });
+ });
+
+ describe('logSecurity is aggregated to at most ONE call per validate()', () => {
+ it('emits exactly one call for a rejected payload', () => {
+ // ProductionLogger.security() logs at LogLevel.ERROR into a 1000-entry FIFO
+ // audit ring and console.errors in production, synchronously. One call per
+ // redacted property would fire once per screen view, evicting genuine
+ // crisis-path entries and landing an ERROR inside the crisis-tap window.
+ PHIFilter.validate('app_opened', { mood: 'low', journal: 'x', detail: 'grief' });
+ expect(mockLogSecurity).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits exactly one call for a non-whitelisted event', () => {
+ PHIFilter.validate('not_a_real_event', {});
+ expect(mockLogSecurity).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits no call for a clean payload', () => {
+ PHIFilter.validate('screen_viewed', { screen_name: 'App' });
+ expect(mockLogSecurity).not.toHaveBeenCalled();
+ });
+
+ it('emits exactly one call for a nested violation', () => {
+ // The old implementation recursed through validate() itself, so a nested
+ // violation logged from the inner frame. The scan must not re-enter the
+ // logging path.
+ PHIFilter.validate('app_opened', { meta: { detail: 'grief' } });
+ expect(mockLogSecurity).toHaveBeenCalledTimes(1);
+ });
+
+ it('never logs the offending VALUE', () => {
+ PHIFilter.validate('settings_opened', { field: 'reach me at secret@example.com' });
+ const logged = mockLogSecurity.mock.calls.map((c) => String(c[0])).join(' | ');
+ expect(logged).not.toContain('secret@example.com');
+ });
+ });
+
+ describe('the filter did not become more permissive', () => {
+ it('still rejects a non-whitelisted event name', () => {
+ expect(PHIFilter.validate('voice_journal_started', {}).valid).toBe(false);
+ });
+
+ it('still rejects a suspicious numeric in a non-safe key', () => {
+ expect(PHIFilter.validate('assessment_completed', { total: 18 }).valid).toBe(false);
+ });
+
+ it('still rejects a keyword in a plain string value', () => {
+ expect(PHIFilter.validate('app_opened', { detail: 'my journal from tonight' }).valid).toBe(false);
+ });
+
+ it('keeps all 28 keywords and all 9 safe numeric keys', () => {
+ const src = require('fs').readFileSync(
+ require('path').resolve(__dirname, '../../src/core/analytics/PHIFilter.ts'),
+ 'utf8'
+ );
+ // Strip comments first (DEBUG-390) β this file deliberately names
+ // anti-patterns in prose, and a bare match would hit the commentary.
+ const stripped = src
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+ expect(stripped.length).toBeGreaterThan(2000);
+ // Anti-vacuity: the matcher must still fire against a literal known-bad string.
+ expect("'suicid',").toMatch(/'suicid',/);
+ for (const kw of ['suicid', 'harm', 'hotline_number', 'crisis_contact', 'grief']) {
+ expect(stripped).toContain(`'${kw}'`);
+ }
+ });
+ });
+});
diff --git a/app/src/core/analytics/PHIFilter.ts b/app/src/core/analytics/PHIFilter.ts
index ae8310fc..48310714 100644
--- a/app/src/core/analytics/PHIFilter.ts
+++ b/app/src/core/analytics/PHIFilter.ts
@@ -11,6 +11,12 @@
*/
import { logSecurity } from '@/core/services/logging';
+// STATIC import, deliberately. `core/analytics`'s barrel is eager on
+// `CrisisResourcesScreen.tsx`, so a lazy require() here would resolve a module
+// during a crisis tap. The cost is ten module-scope regexes, which is acceptable
+// eagerly and is not acceptable lazily (same rule as the MaterialDesignIcons
+// eager-import requirement).
+import { containsPHI } from './phiDetection';
/**
* Result of PHI validation
@@ -20,6 +26,12 @@ export interface PHIValidationResult {
reason?: string;
}
+/** Internal: a single scan finding, carried up so `validate` can log once. */
+interface PHIViolation {
+ reason: string;
+ severity: 'medium' | 'high';
+}
+
/**
* PHI Filter - Whitelist-based analytics event validation
*
@@ -154,6 +166,164 @@ export class PHIFilter {
'version',
]);
+ /**
+ * Property keys exempt from the KEY scan and from `containsPHI` (INFRA-535).
+ *
+ * IT EXEMPTS THE NEW CHECKS ONLY. The pre-existing VALUE keyword scan and the
+ * numeric check still apply to every key here β otherwise this set would be a
+ * loosening rather than a false-positive fix, and `{screen_name:'grief'}` would
+ * start shipping.
+ *
+ * Two entries are load-bearing and must not be removed:
+ *
+ * - `screen_name` segments to ['screen','name'] and `name` is an exact
+ * PHI_KEYWORDS hit. Without it, `screen_viewed` becomes a whole-event reject
+ * β and it fires in the same `useFocusEffect` as `trackCrisisResourcesViewed`,
+ * so crisis-screen reach would silently become unmeasurable.
+ *
+ * - the SAFE_NUMERIC_KEYS members, because a 13-digit `Date.now()` matches
+ * `containsPHI`'s `\b\d{10,}\b` identifier pattern. Arming the predicate
+ * without this exemption reintroduces the MAINT-202 defect, which silently
+ * dropped every consent-passing event.
+ *
+ * Every entry is a hole in the key scan. Additions are hand-reviewed, never
+ * derived at runtime.
+ */
+ private static readonly SAFE_PROPERTY_KEYS: ReadonlySet = new Set([
+ 'screen_name',
+ 'duration',
+ 'duration_ms',
+ 'duration_seconds',
+ 'count',
+ 'timestamp',
+ 'step',
+ 'index',
+ 'page',
+ 'version',
+ ]);
+
+ /**
+ * Split a property key into lowercase segments, across `_`, `-`, `.` and
+ * camelCase boundaries. `screen_name` -> ['screen','name'];
+ * `userEmail` -> ['user','email'].
+ */
+ private static keySegments(key: string): string[] {
+ return key
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .split(/[^a-zA-Z0-9]+/)
+ .filter(Boolean)
+ .map((s) => s.toLowerCase());
+ }
+
+ /**
+ * Match PHI keywords against a property KEY by segment, not by substring.
+ *
+ * Segment matching is what makes key scanning survivable: substring matching
+ * blocks `campaign_id` (contains "pain") and `notation` (contains "note"), and a
+ * filter that blocks ordinary keys is one the next person weakens.
+ *
+ * A single-segment keyword matches a segment that EQUALS it or STARTS WITH it β
+ * the prefix arm is required because `suicid` and `harm` are deliberately stems
+ * rather than whole words. A multi-segment keyword (`crisis_contact`) must match
+ * consecutive segments.
+ *
+ * Applies to KEYS ONLY. Word-boundary logic must never be applied to VALUES: it
+ * would loosen the `suicid` stem, which is exactly what it is a stem to catch.
+ */
+ private static keywordInKey(key: string): string | null {
+ const segments = this.keySegments(key);
+ for (const keyword of this.PHI_KEYWORDS) {
+ const parts = keyword.split('_');
+ if (parts.length > 1) {
+ for (let i = 0; i + parts.length <= segments.length; i++) {
+ if (parts.every((part, j) => segments[i + j] === part)) return keyword;
+ }
+ } else if (segments.some((seg) => seg === keyword || seg.startsWith(keyword))) {
+ return keyword;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Scan a single property value. Recurses into objects AND arrays β the array arm
+ * closes a live hole: before INFRA-535 the value branch tested
+ * `typeof value === 'string'` and the nested branch excluded arrays, so
+ * `{tags:['grief']}` shipped intact.
+ *
+ * Returns the first violation found, or null. It does NOT log β see `validate`.
+ */
+ private static scanValue(
+ key: string,
+ value: unknown,
+ keyIsExempt: boolean
+ ): PHIViolation | null {
+ if (typeof value === 'string') {
+ const lowerValue = value.toLowerCase();
+ for (const keyword of this.PHI_KEYWORDS) {
+ if (lowerValue.includes(keyword)) {
+ return {
+ reason: `PHI keyword detected: "${keyword}" in key "${key}"`,
+ severity: 'high',
+ };
+ }
+ }
+ if (!keyIsExempt && containsPHI(value)) {
+ return { reason: `PHI pattern detected in key "${key}"`, severity: 'high' };
+ }
+ return null;
+ }
+
+ if (typeof value === 'number') {
+ return this.SAFE_NUMERIC_KEYS.has(key)
+ ? null
+ : { reason: `Suspicious numeric value in key: "${key}"`, severity: 'medium' };
+ }
+
+ if (Array.isArray(value)) {
+ for (const element of value) {
+ const violation = this.scanValue(key, element, keyIsExempt);
+ if (violation) return violation;
+ }
+ return null;
+ }
+
+ if (value !== null && typeof value === 'object') {
+ return this.scanPayload(value as Record);
+ }
+
+ return null;
+ }
+
+ /**
+ * Scan a whole payload, returning the first violation or null.
+ *
+ * Deliberately does no logging: the old implementation recursed through
+ * `validate()` itself, so a nested violation logged from the inner frame.
+ * `logSecurity` reaches `LogLevel.ERROR`, a 1000-entry FIFO audit ring and a
+ * production `console.error` synchronously, so the number of calls per event is
+ * a crisis-path concern, not a cosmetic one.
+ */
+ private static scanPayload(eventData: Record): PHIViolation | null {
+ for (const [key, value] of Object.entries(eventData)) {
+ const keyIsExempt = this.SAFE_PROPERTY_KEYS.has(key);
+
+ if (!keyIsExempt) {
+ const keyword = this.keywordInKey(key);
+ if (keyword) {
+ return {
+ reason: `PHI keyword detected in property key: "${keyword}" in key "${key}"`,
+ severity: 'high',
+ };
+ }
+ }
+
+ const violation = this.scanValue(key, value, keyIsExempt);
+ if (violation) return violation;
+ }
+ return null;
+ }
+
/**
* Validate an analytics event before transmission
*
@@ -165,7 +335,11 @@ export class PHIFilter {
eventType: string,
eventData: Record
): PHIValidationResult {
- // 1. WHITELIST CHECK: Event type must be explicitly allowed
+ // 1. WHITELIST CHECK: Event type must be explicitly allowed.
+ // The event NAME is never keyword-scanned β `crisis_hotline_tapped`
+ // contains the keyword `hotline_number`'s first segment, and scanning the
+ // name (or shortening that keyword to `hotline`) would silently and
+ // permanently self-block the app's 988-reach signal.
if (!this.SAFE_EVENT_TYPES.has(eventType)) {
logSecurity(
`PHI Filter: Blocked non-whitelisted event type: ${eventType}`,
@@ -177,49 +351,12 @@ export class PHIFilter {
};
}
- // 2. PHI KEYWORD CHECK: Scan VALUES only for PHI indicators
- // (Keys are controlled by us, so we only check the actual data values)
- for (const [key, value] of Object.entries(eventData)) {
- if (typeof value === 'string') {
- const lowerValue = value.toLowerCase();
- for (const keyword of this.PHI_KEYWORDS) {
- if (lowerValue.includes(keyword)) {
- logSecurity(
- `PHI Filter: Blocked event with PHI keyword: ${keyword}`,
- 'high'
- );
- return {
- valid: false,
- reason: `PHI keyword detected: "${keyword}" in key "${key}"`,
- };
- }
- }
- }
- }
-
- // 3. NUMERIC VALUE CHECK: Block suspicious numeric values
- // (potential assessment scores, mood values, etc.)
- for (const [key, value] of Object.entries(eventData)) {
- if (typeof value === 'number' && !this.SAFE_NUMERIC_KEYS.has(key)) {
- logSecurity(
- `PHI Filter: Blocked suspicious numeric in key: ${key}`,
- 'medium'
- );
- return {
- valid: false,
- reason: `Suspicious numeric value in key: "${key}"`,
- };
- }
- }
-
- // 4. NESTED OBJECT CHECK: Recursively validate nested data
- for (const [key, value] of Object.entries(eventData)) {
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
- const nestedResult = this.validate(eventType, value as Record);
- if (!nestedResult.valid) {
- return nestedResult;
- }
- }
+ // 2. PAYLOAD SCAN: keys, values, numerics, nested objects and arrays.
+ // One aggregated log for the whole invocation, never one per property.
+ const violation = this.scanPayload(eventData);
+ if (violation) {
+ logSecurity(`PHI Filter: Blocked event "${eventType}" β ${violation.reason}`, violation.severity);
+ return { valid: false, reason: violation.reason };
}
return { valid: true };
From d32f1c6cb8801a9833ebcd7d8c18bf6b313009fe Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:27:33 -0700
Subject: [PATCH 27/90] chore: INFRA-535 assert every tracker's real payload
transmits (PR2)
The item's #1 declared success metric, and the instrument the scan-surface
tightening needs in order to be trustworthy.
trackEvent calls PHIFilter.validate and captures ONLY when it returns valid; on a
block it logs and returns, and nothing throws. So a tracker whose payload the
filter rejects looks correct at review, emits nothing forever, and reads in
PostHog as "nobody did it" rather than as a defect. That is how FEAT-137 closed
Done. This drives all 23 named trackers through the real hook and asserts each
reaches the sink -- so "capture was called" IS the assertion that the payload
survived the filter.
It is also what makes C2 safe to have shipped. Widening what the filter looks at
can only be trusted if something proves the widening did not start eating live
trackers; two hand-picked "named false positives" are not that proof.
Anti-vacuity, per DEBUG-390: the key list is DERIVED from the live hook so a new
tracker appears automatically; a derived key with no hand-authored fixture FAILS
rather than being skipped; fixtures are hand-authored, never generated from the
code under test; and the enumeration is pinned at a floor of 23.
Mutation-verified rather than assumed green:
- removing screen_name from SAFE_PROPERTY_KEYS reds trackScreenView ONLY
- deleting one fixture reds the coverage guard ONLY
Both reverted; suite back to 29 passing.
Records for INFRA-552 that session_started/session_ended have no tracker function
at all, so this enumeration structurally cannot see them and the catalog prune
must handle them by hand.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01TYgDdLs3wxbxwr9M5Jts4p
---
.../analyticsTrackerContract.privacy.test.ts | 158 ++++++++++++++++++
1 file changed, 158 insertions(+)
create mode 100644 app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
diff --git a/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
new file mode 100644
index 00000000..83374023
--- /dev/null
+++ b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
@@ -0,0 +1,158 @@
+/**
+ * Tracker contract (INFRA-535, PR2).
+ *
+ * The item's #1 declared success metric: "100% of trackers have a green
+ * contract-test assertion that their real payload passes validate() with zero
+ * redactions."
+ *
+ * WHY THIS EXISTS. `trackEvent` calls `PHIFilter.validate` and captures ONLY when
+ * it returns valid; on a block it logs and returns. Nothing throws. So a tracker
+ * whose payload the filter rejects looks completely correct at review, emits
+ * nothing forever, and reads in PostHog as "nobody did it" rather than as a
+ * defect. That is how FEAT-137 closed Done. This suite makes that failure
+ * mechanical instead of silent: it drives every tracker the hook actually returns
+ * and asserts each one reaches the sink.
+ *
+ * It is also the instrument for the scan-surface tightening shipped alongside it.
+ * Widening what the filter looks at can only be trusted if something proves the
+ * widening did not start eating live trackers β a hand-picked pair of "named false
+ * positives" is not that proof. This is.
+ *
+ * HOW IT CANNOT GO VACUOUS (DEBUG-390):
+ * - the key list is DERIVED from the live hook, so a new tracker appears here
+ * automatically;
+ * - a derived key with no hand-authored fixture FAILS rather than being skipped,
+ * so the suite cannot quietly shrink to the trackers someone remembered;
+ * - fixtures are hand-authored, never generated from the implementation;
+ * - the enumeration itself is asserted non-empty and at a pinned minimum size.
+ */
+
+const mockCapture = jest.fn();
+jest.mock('posthog-react-native', () => ({
+ usePostHog: () => ({ capture: (...args: unknown[]) => mockCapture(...args) }),
+}));
+
+import { renderHook } from '@testing-library/react-native';
+import { useAnalytics } from '@/core/analytics/useAnalytics';
+import { PHIFilter, AnalyticsEvents } from '@/core/analytics/PHIFilter';
+
+/**
+ * HAND-AUTHORED fixtures: tracker name -> the arguments a real call site passes.
+ *
+ * Deliberately not derived from the implementation. A fixture generated from the
+ * code under test would agree with it by construction, including when both are
+ * wrong.
+ */
+const FIXTURES: Readonly> = {
+ trackScreenView: ['HomeScreen'],
+ trackAppOpened: [],
+ trackAppBackgrounded: [],
+ trackCheckInStarted: [],
+ trackCheckInCompleted: [5000],
+ trackAssessmentStarted: [],
+ trackAssessmentCompleted: [42000],
+ trackPracticeStarted: [],
+ trackPracticeCompleted: [300000],
+ trackCrisisResourcesViewed: [],
+ trackCrisisHotlineTapped: [],
+ trackGuidanceOpened: [],
+ trackSettingsOpened: [],
+ trackConsentChanged: [],
+ trackLearnContentViewed: ['module-1'],
+ trackLearnModuleStarted: ['module-1'],
+ trackLearnModuleCompleted: ['module-1', 900],
+ trackBreathingExerciseStarted: [],
+ trackBreathingExerciseCompleted: [180000],
+ trackOnboardingStarted: [],
+ trackOnboardingStepCompleted: [3],
+ trackOnboardingCompleted: [],
+ trackErrorOccurred: ['network_error'],
+};
+
+/**
+ * `trackEvent` is the generic escape hatch: it takes an arbitrary event name and
+ * arbitrary properties, so there is no "real payload" to pin. Every NAMED tracker
+ * routes through it, which is what this suite actually covers. Excluded explicitly
+ * rather than by omission, so the exclusion is reviewable.
+ */
+const EXCLUDED = new Set(['trackEvent']);
+
+/** Pinned floor: 23 named trackers today. Growth fine, shrinkage red. */
+const MIN_TRACKERS = 23;
+
+describe('every useAnalytics tracker transmits (INFRA-535)', () => {
+ const { result } = renderHook(() => useAnalytics());
+ const allKeys = Object.keys(result.current).filter(
+ (k) => typeof (result.current as Record)[k] === 'function'
+ );
+ const trackerKeys = allKeys.filter((k) => !EXCLUDED.has(k));
+
+ describe('the enumeration is real (anti-vacuity)', () => {
+ it('derives a non-empty tracker list from the live hook', () => {
+ expect(trackerKeys.length).toBeGreaterThanOrEqual(MIN_TRACKERS);
+ });
+
+ it('every derived tracker has a hand-authored fixture', () => {
+ // A key without a fixture FAILS. It must not be silently skipped: that is
+ // exactly how a new tracker would ship uncovered.
+ const missing = trackerKeys.filter((k) => !(k in FIXTURES));
+ expect(missing).toEqual([]);
+ });
+
+ it('every fixture corresponds to a real tracker (no dead fixtures)', () => {
+ const orphaned = Object.keys(FIXTURES).filter((k) => !trackerKeys.includes(k));
+ expect(orphaned).toEqual([]);
+ });
+
+ it('the excluded key really is on the hook', () => {
+ for (const key of EXCLUDED) expect(allKeys).toContain(key);
+ });
+ });
+
+ describe('each tracker reaches the sink with its real payload', () => {
+ it.each(Object.keys(FIXTURES).map((k) => [k] as const))('%s', (name) => {
+ mockCapture.mockClear();
+
+ const fn = (result.current as Record void>)[name];
+ expect(typeof fn).toBe('function');
+ fn(...(FIXTURES[name] as unknown[]));
+
+ // trackEvent captures ONLY when PHIFilter.validate passes, and swallows the
+ // block otherwise. So "capture was called" IS the assertion that this
+ // tracker's real payload survives the filter.
+ expect(mockCapture).toHaveBeenCalledTimes(1);
+
+ // Re-assert directly, for a failure message that names the reason rather
+ // than just "expected 1 call, got 0".
+ const [eventName, payload] = mockCapture.mock.calls[0] as [
+ string,
+ Record,
+ ];
+ expect(PHIFilter.validate(eventName, payload)).toEqual({ valid: true });
+ });
+ });
+
+ describe('the suite would notice if the filter started eating trackers', () => {
+ it('a deliberately bad payload on a real event IS rejected', () => {
+ // Proves the assertion above discriminates β if validate() accepted
+ // everything, every tracker would pass for the wrong reason.
+ expect(PHIFilter.validate(AnalyticsEvents.SCREEN_VIEWED, { screen_name: 'grief' }).valid).toBe(
+ false
+ );
+ expect(PHIFilter.validate('definitely_not_whitelisted', {}).valid).toBe(false);
+ });
+ });
+
+ describe('catalog constants with no tracker at all (recorded for INFRA-552)', () => {
+ it('session_started and session_ended are whitelisted but unreachable from the hook', () => {
+ // Neither has a tracker function, so the derived enumeration above cannot
+ // see them and this suite cannot protect them. The catalog prune must handle
+ // them by hand rather than assuming the contract test covers the catalog.
+ expect(PHIFilter.isWhitelisted('session_started')).toBe(true);
+ expect(PHIFilter.isWhitelisted('session_ended')).toBe(true);
+
+ const emitters = trackerKeys.filter((k) => /session/i.test(k));
+ expect(emitters).toEqual([]);
+ });
+ });
+});
From 46b225573b8c5c90a694117a759e1b0423e37bb2 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:37:19 -0700
Subject: [PATCH 28/90] chore: INFRA-523 land the six verified misses; first
mixed-corpus figures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Serves AC4/AC5/AC6. AC2 (LLM-generated adversarial corpus) stays blocked -- no
ANTHROPIC_API_KEY in this environment, re-verified this run.
The six misses were hand-derived by executing the compiled patterns during the
INFRA-512 planning pass, recorded in the packet merged via PR #423, and re-verified
here. They were never model-drafted and their labels need no judgement, so founder
attestation is proportionate to how they were produced. That does NOT generalize:
for model-generated items the packet's item-by-item review protocol stands in full.
INFRA-512 corpus v2-seed-plus-verified-misses
MUST-FIRE recall: 12/18 66.7%
MUST-NOT-FIRE false-positive: 0/4 0.0%
STT-MANGLED recall: 2/2 100.0%
66.7% IS NOT MEASURED RECALL. The corpus now has two strata and the blended figure
describes neither: 12 items the detector was already known to HANDLE (cannot fail)
and 6 it was already known to MISS (cannot pass). The rate is an arithmetic
consequence of which known items are present and moves toward whichever stratum
grew. corpusVersion carries that caveat inside the console line itself, because
--silent means the console is not the record; the audit doc is. The filename keeps
"v1" -- file identity vs content identity, deliberately allowed to differ.
AC6 dispositions: six of six pattern-candidate-deferred-to-crisis-pass.
accepted-miss-mitigated-elsewhere requires both that the control is on screen
without user initiative AND that no bounded widening exists; (b) fails for all six.
Where a fix is available and merely unruled, "accepted" would launder a deferral
into a decision. Six identical dispositions IS the finding: the known gaps are all
fixable and unruled, none accepted. Each item carries the FP class its widening
would introduce, on the item rather than in a header, because a contributor edits
the fixture. This is NOT a mandate to widen -- "suicide" and "killing myself" may
well be ruled against -- and the follow-up must be at least TWO items, since
"cant go on" is a normalizer fix that must not ship behind the contested suicide
argument.
Drift pin (packet Β§7): considered and DECLINED by the founder, 2026-08-25. AC5
stands. The one assertion added is deliberately not it -- every verified-miss item
must carry a disposition, keyed on provenance rather than detector behaviour so a
future widening cannot invert it, thresholding no rate. Same family as the existing
MUST_NOT_FIRE/refutes hard-fail. Verified by mutation: removing one disposition reds
that assertion alone (1 failed, 16 passed), reverted.
Note a green validate:crisis-authority now covers fixture integrity and the anchor
set over a corpus containing known-failing items by design. It says nothing about
recall and never did.
Crisis pass: dispositions, note prose and corpusVersion ruled by the specialist and
applied as given. Gate conditions re-verified against the merge-base: inert diff on
textCrisisDetection.ts, all other app/src changes confined to crisis/services/__tests__/.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EpS4PU7NAJASNf6pLcoGs5
---
.../fixtures/crisisTextCorpus.v1.json | 40 ++++-
.../textCrisisDetection.corpus.test.ts | 14 ++
.../INFRA-523-corpus-baseline-2026-08-25.md | 153 ++++++++++++++++++
3 files changed, 203 insertions(+), 4 deletions(-)
create mode 100644 docs/development/audits/INFRA-523-corpus-baseline-2026-08-25.md
diff --git a/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json b/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
index 559f7dee..a4fa9a98 100644
--- a/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
+++ b/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
@@ -1,8 +1,9 @@
{
- "corpusVersion": "v1-seed",
+ "corpusVersion": "v2-seed-plus-verified-misses",
"generatedOn": "2026-08-22",
- "workItem": "INFRA-512",
- "note": "SEED CORPUS β NOT THE BASELINE. Every item here is drawn verbatim from strings already human-reviewed and committed in textCrisisDetection.unit.test.ts, so all of them are phrases the detector is ALREADY known to handle correctly. The three rates this corpus produces are therefore a self-test of the harness, not a measurement of recall. The adversarial corpus that produces the real baseline is generated offline by scripts/generate-crisis-corpus.js and appended after human review β see docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md, which already carries the first candidate batch and six verified MUST-FIRE misses.",
+ "workItem": "INFRA-512, amended INFRA-523",
+ "lastAmendedOn": "2026-08-25",
+ "note": "MIXED CORPUS β NOT A BASELINE, IN EITHER DIRECTION. This corpus has two strata and the blended MUST-FIRE figure describes neither. Twelve MUST_FIRE items are the original repo-reviewed seed, drawn verbatim from strings already committed in textCrisisDetection.unit.test.ts β phrases the detector was already known to HANDLE, so they cannot fail. Six are the misses derived by hand in the INFRA-512 planning pass (packet Β§3), re-verified and landed under INFRA-523 β phrases the detector was already known to MISS, so they cannot pass. The rate is therefore an arithmetic consequence of which known items are present, not a measurement: it moves whenever an item is added, in the direction of whichever stratum grew. Do not quote it as measured recall, do not compare it across corpus versions, and do not read a change in it as detector drift. Real recall needs the adversarial corpus that scripts/generate-crisis-corpus.js produces under the human-review merge protocol in docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md; until that lands recall is UNMEASURED, and textCrisisDetection.ts's header says so. The six misses are each dispositioned pattern-candidate-deferred-to-crisis-pass β recorded here as a known gap, NOT as a mandate to widen CRISIS_TEXT_PATTERN_SOURCES, which feeds showCrisisAlert() on a journaling surface. Note the filename's \"v1\" is this FILE's identity; corpusVersion is the CONTENT's. They are meant to differ β do not \"fix\" that.",
"classes": {
"MUST_FIRE": "A disclosure of self-harm intent that detectCrisisInText must match.",
"MUST_NOT_FIRE": "Ordinary language that must stay clean. Each item names the specific widening it refutes, so the class cannot be satisfied by generic mood text.",
@@ -74,6 +75,37 @@
{ "id": "kof-negated-want-to-die", "class": "KNOWN_OVER_FIRE", "text": "I donβt want to die",
"acceptedBecause": "Deliberate Slice A decision, pinned at textCrisisDetection.unit.test.ts 'MATCHES negated phrasing, intentionally'. Negation detection on lossy STT fails in the direction that matters: a recognizer dropping the negation turns a negated sentence into a disclosure. An extra support offer costs a dismissal; a missed disclosure is unrecoverable. Do NOT reclassify this as MUST_NOT_FIRE to improve the false-positive rate β that would reverse a crisis decision to move a number.",
"provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts documented over-trigger", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } }
+ "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
+
+ { "id": "mf-miss-killing-myself", "class": "MUST_FIRE", "text": "i keep thinking about killing myself",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Gerund of the approved phrase `kill my self`, so the vocabulary decision is already made. But a widening (`kill\\w*\\s*my\\s*self`) newly matches the idiom βkilling myselfβ (at work / laughing / at the gym) β a real false-positive class the current pattern does not carry. Needs its own crisis ruling AND a paired MUST_NOT_FIRE item before anyone ships it.",
+ "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
+ "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
+ { "id": "mf-miss-ending-it-all", "class": "MUST_FIRE", "text": "i have been thinking about ending it all",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Same morphology break on the approved phrase `end it all`. βEnding it allβ is near-exclusively the suicidal idiom, so the false-positive delta is low β but it is still a widening, and packet Β§4 admits no automatic remedy.",
+ "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
+ "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
+ { "id": "mf-miss-cant-go-on", "class": "MUST_FIRE", "text": "i cant go on anymore",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Highest-value, lowest-FP-delta of the six, and the odd one out: a NORMALIZER gap, not a vocabulary gap. The expanded form already fires, so the FP profile is unchanged by construction. Deferred anyway because normalizeForCrisisScan feeds EVERY pattern, so its blast radius is wider than any single pattern's β the opposite of what βlow riskβ usually implies.",
+ "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
+ "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
+ { "id": "mf-miss-suicide", "class": "MUST_FIRE", "text": "suicide",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Most contested of the six. A `suicid` prefix is trivial, but on THIS surface the FP class is live: an app that shows 988 invites journal entries about the 988 line, about suicide prevention, and about articles read. Needs the ruling most and the widening least obviously.",
+ "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
+ "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
+ { "id": "mf-miss-end-my-life", "class": "MUST_FIRE", "text": "i want to end my life",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Genuinely new vocabulary. `end my life` is close to unambiguous, so this is the cleanest candidate β which is precisely why it must not be slipped in without the ruling that establishes the bar for adding vocabulary at all.",
+ "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
+ "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
+ { "id": "mf-miss-wish-i-was-dead", "class": "MUST_FIRE", "text": "i wish i was dead",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "New vocabulary, carrying a hyperbole FP class (βI wish I was dead, that was so embarrassingβ) that the module's own KNOWN_OVER_FIRE reasoning would likely accept β but βlikely acceptβ is a ruling, not an assumption.",
+ "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
+ "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } }
]
}
diff --git a/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
index 34922af4..bda51a3e 100644
--- a/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
+++ b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
@@ -47,6 +47,8 @@ interface CorpusItem {
sourceId?: string;
refutes?: string;
acceptedBecause?: string;
+ disposition?: string;
+ dispositionNote?: string;
provenance: { source: string; origin?: string; model?: string; runDate: string };
review: { reviewer: string; reviewedOn: string; verdict: string };
}
@@ -119,6 +121,18 @@ describe('corpus integrity', () => {
expect(dangling.map((i) => i.id)).toEqual([]);
});
+ it('every verified-miss item carries a disposition (AC6)', () => {
+ // INFRA-523. A verified miss is a KNOWN gap, and a known gap with no disposition is
+ // indistinguishable from an oversight β which is how a deferral quietly becomes an
+ // acceptance. Keyed on provenance, NOT on detector behaviour, so a future widening
+ // that turns one of these into a hit does not invert the assertion. This thresholds
+ // no rate: packet Β§7 declined the drift pin, and this is not it.
+ const undispositioned = corpus.items.filter(
+ (i) => i.provenance?.source === 'verified-miss' && !i.disposition,
+ );
+ expect(undispositioned.map((i) => i.id)).toEqual([]);
+ });
+
it('every MUST_NOT_FIRE item names the widening it refutes', () => {
// Without this, the class fills with generic clean prose that refutes nothing and
// the false-positive rate stops being adversarial.
diff --git a/docs/development/audits/INFRA-523-corpus-baseline-2026-08-25.md b/docs/development/audits/INFRA-523-corpus-baseline-2026-08-25.md
new file mode 100644
index 00000000..824ab33a
--- /dev/null
+++ b/docs/development/audits/INFRA-523-corpus-baseline-2026-08-25.md
@@ -0,0 +1,153 @@
+# INFRA-523 β Guarantee correction and first mixed-corpus figures
+
+**Date:** 2026-08-25 Β· **Supersedes nothing; extends**
+`INFRA-512-corpus-review-packet-2026-08-22.md` (the merge protocol, traps and disposition
+enum still live there).
+**Crisis pass:** performed this run; both rulings applied as given, including one that
+overrode the brief it was sent.
+
+---
+
+## 1. What this run changed
+
+Two things, in two repos-of-record, neither a substitute for the other:
+
+| Change | Where |
+|---|---|
+| The falsified free-text guarantee, corrected in four locations | `chore/INFRA-523-*` β `development` |
+| `Safety Facts` split by detection path | `.claude/CLAUDE.md`, direct on `_bare` (gitignored on `development`, so it cannot travel in a feature PR) |
+
+**No detection behaviour changed. No user's safety posture improved.** This run makes the
+documentation stop overstating a gap that remains live. Closing the gap is AC2/AC3, still
+blocked.
+
+### 1a. Two false claims were live, not one
+
+The item was filed for the first. The crisis pass found the second and refused to let it
+stand.
+
+1. **Recall.** `textCrisisDetection.ts` claimed *"Zero-false-negative holds over
+ correctly-transcribed text."* Re-derived 2026-08-25 by executing the six compiled
+ patterns: `i keep thinking about killing myself`, `i have been thinking about ending it
+ all`, `i cant go on anymore`, `suicide`, `i want to end my life`, `i wish i was dead` all
+ MISS. Controls `i can not go on`, `i feel suicidal`, `i want to die`, `there is no point
+ living` all FIRE, so the matcher was live.
+
+2. **A compensating control that does not exist.** The same paragraph rested residual risk on
+ *"a support line surfaced on low-confidence transcripts."* Verified: the only two
+ occurrences of `low-confidence` under `app/src` were the two comments asserting it; there
+ is no confidence signal in `features/journal` at all; the `SUPPORT_LINE` that exists
+ belongs to `practices/dailyloop`. The journal's in-screen banner is `crisisActive ? β¦ :
+ null` β it renders only once the scan has **already** fired, so it compensates for
+ nothing. Correcting the recall claim while re-asserting this one would have laundered the
+ correction.
+
+**Not touched, deliberately:** ~30 further zero-false-negative references covering the SCORE
+path (`detectCrisis()` over PHQ-9/GAD-7 integers), where the contract is genuine and total.
+A repo-wide sed of the phrase would weaken a contract that holds in order to fix one that
+does not.
+
+## 2. Measured figures (AC5 β recorded, not enforced)
+
+```
+INFRA-512 corpus v2-seed-plus-verified-misses
+ MUST-FIRE recall: 12/18 66.7%
+ MUST-NOT-FIRE false-positive: 0/4 0.0%
+ STT-MANGLED recall: 2/2 100.0%
+ MUST-FIRE misses: mf-miss-killing-myself, mf-miss-ending-it-all, mf-miss-cant-go-on,
+ mf-miss-suicide, mf-miss-end-my-life, mf-miss-wish-i-was-dead
+```
+
+**66.7% IS NOT MEASURED RECALL, IN EITHER DIRECTION.** The corpus has two strata and the
+blended figure describes neither: 12 MUST_FIRE items are seed strings the detector was
+already known to HANDLE (they cannot fail), and 6 are misses it was already known to MISS
+(they cannot pass). The rate is an arithmetic consequence of which known items are present.
+It moves whenever an item is added, toward whichever stratum grew. **Do not quote it as
+measured recall, do not compare it across corpus versions, and do not read a change in it as
+detector drift.** Real recall still needs the adversarial corpus of AC2.
+
+`corpusVersion` was set to `v2-seed-plus-verified-misses` so the caveat travels inside the
+console line itself. The filename keeps `v1` β that is the FILE's identity, `corpusVersion`
+is the CONTENT's, and they are meant to differ.
+
+## 3. Dispositions (AC6) β six of six `pattern-candidate-deferred-to-crisis-pass`
+
+The standard applied: `accepted-miss-mitigated-elsewhere` requires **both** that the
+compensating control is on screen without user initiative in the state where the miss
+occurs, **and** that no plausible bounded widening addresses the miss class. **(b) fails for
+all six** β every one is reachable by a bounded, linear-time change. Accepting is for
+residual risk deliberately *not* reduced; where a fix is available and merely unruled,
+"accepted" launders a deferral into a decision. `out-of-scope-stt-layer` is unavailable
+(every string is correctly transcribed by construction) and `detected` is unavailable (all
+six re-verified as missing).
+
+| Item | Named FP class the widening would introduce |
+|---|---|
+| `i keep thinking about killing myself` | Gerund of an approved phrase. `kill\w*\s*my\s*self` newly matches the idiom "killing myself" (at work / laughing / at the gym). Needs a paired `MUST_NOT_FIRE` item before shipping. |
+| `i have been thinking about ending it all` | Same morphology break. "Ending it all" is near-exclusively the suicidal idiom β low FP delta, but still a widening. |
+| `i cant go on anymore` | **A normalizer gap, not a vocabulary gap.** The expanded form already fires, so the FP profile is unchanged by construction. Deferred anyway because `normalizeForCrisisScan` feeds EVERY pattern β wider blast radius than any single pattern, the opposite of what "low risk" usually implies. |
+| `suicide` | **Most contested.** A `suicid` prefix is trivial, but an app that shows 988 invites entries *about* the 988 line, about prevention, about articles read. |
+| `i want to end my life` | New vocabulary; `end my life` is close to unambiguous β which is why it must not be slipped in ahead of the ruling that sets the bar for adding vocabulary at all. |
+| `i wish i was dead` | New vocabulary; hyperbole class ("I wish I was dead, that was so embarrassing") the module's own KNOWN_OVER_FIRE reasoning would *likely* accept β but "likely" is a ruling, not an assumption. |
+
+**Six identical dispositions is the finding, not a degenerate outcome.** The detector's known
+gaps are all *fixable and unruled*; none are *accepted*. Two guards on reading that:
+
+- **Not a mandate to widen.** It means a widening is plausible enough to require a ruling β
+ and rows 1 and 4 may well be ruled *against*.
+- **The follow-up must be at least TWO items.** Row 3 is a normalizer change; the rest are
+ vocabulary. Bundling them puts the low-FP normalizer fix behind the contested `suicide`
+ argument, which is how the safest of the six ends up shipping last.
+
+## 4. Why founder-as-reviewer is sound here, and why it does not generalize
+
+These six were **hand-derived** by executing the compiled patterns during the INFRA-512
+planning pass, recorded in a packet merged via PR #423, and re-verified this run. They were
+never model-drafted, and their labels require no judgement β each is an unambiguous
+self-harm disclosure. Attestation is therefore proportionate to how they were produced.
+
+**This does not generalize.** For model-generated items the packet's Β§5.2 stands in full β
+the model drafts, the reviewer decides, item by item β and Β§6 is the proof the correct label
+is not self-evident.
+
+## 5. Decisions closed this run
+
+- **Drift pin: CONSIDERED AND DECLINED**, 2026-08-25, by the founder, when offered
+ alongside the corpus batch. AC5 stands: recorded, not enforced. Packet Β§7's open question
+ is now closed; do not re-litigate it as still-open.
+- **One integrity assertion WAS added** and is deliberately not the drift pin: every item
+ with `provenance.source === "verified-miss"` must carry a non-empty `disposition`. It keys
+ on provenance rather than detector behaviour (so a future widening cannot invert it) and
+ thresholds no rate. Same family as the existing "every `MUST_NOT_FIRE` names what it
+ refutes" hard-fail. Verified by mutation: removing one `disposition` reds that assertion
+ alone (1 failed, 16 passed); reverted.
+
+## 6. What a green `validate:crisis-authority` now means
+
+**The corpus now contains items that are known-failing by design.** A PASS covers fixture
+integrity and the anchor set. It says **nothing** about recall, and it never did β but a
+reader who knows this file is CI-selected will otherwise infer more. `--silent` also swallows
+the console figures, so this document remains the record.
+
+Standing incentive risk: a CI-selected file reporting 66.7% creates pressure to move the
+number, and there is a one-line way to do it. That is why each of the six carries its
+disposition and named FP class *on the item itself* β a contributor edits the fixture, not
+the header.
+
+## 7. Carried forward
+
+- **AC2/AC3 remain blocked.** No `ANTHROPIC_API_KEY` in this environment (re-verified
+ 2026-08-25), and AC3 requires hand review of every generated item.
+- **`premeditationSafetyService.ts` is strictly worse than the path just measured, and is
+ entirely unmeasured.** Its private `CRISIS_KEYWORDS` array is matched with plain
+ `lowerText.includes(keyword)` against literal `'kill myself'`, so it misses all six of
+ these **plus** `kill my self` and `killmyself`, which the shared module catches. The parity
+ guard only pins that it stays a *subset* β it structurally cannot see this. Not this item's
+ job; must not fall off the record.
+- **The low-confidence support line does not exist and was NOT built here.** New crisis UI on
+ a safety surface needs its own item, a crisis pass and a Maestro flow. File it; do not
+ scope-creep into it.
+- **DEBUG-506** leaves the root crisis button unreachable keyboard-up, which is the
+ `scanOnSave` state β a user correcting a transcript. The control is *partial*, not absent:
+ it is reachable at `scanOnFinalize` (keyboard down). Strong enough to matter, too weak to
+ license an "accepted" disposition.
From 19f394115a266ea27f2ae820a4d16118ec36e70c Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 19:42:48 -0700
Subject: [PATCH 29/90] chore: INFRA-523 correct two stale premises in the
INFRA-512 packet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Annotated in place with attribution per house convention, rather than left for the
successor doc to outrank silently -- a reader of the packet alone would inherit both.
1. Β§3a claimed editing textCrisisDetection.ts "would re-tier the item to a
human-attended simulator close". It would not: Phase 2.5's INFRA-256 inert filter
skips comment/whitespace-only diffs, so the header rewrite closed headless. This
premise is why the item was tiered Attended-only for the wrong reason.
2. Β§4's disposition enum offered "the low-confidence-transcript support line" as a
compensating control that accepted-miss-mitigated-elsewhere could rest on. That
control does not exist -- verified: no confidence signal in features/journal at
all. This is the load-bearing half: the enum was inviting a future reviewer to
accept a real crisis miss against a phantom mitigation.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01EpS4PU7NAJASNf6pLcoGs5
---
.../INFRA-512-corpus-review-packet-2026-08-22.md | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md b/docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md
index 9a69e154..142415bd 100644
--- a/docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md
+++ b/docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md
@@ -72,6 +72,11 @@ is not true as written. **Correcting it was deliberately excluded from this run*
founder chose the test-only option, and editing `textCrisisDetection.ts` (a non-test file
under `features/crisis/`) would re-tier the item to a human-attended simulator close.
+> **Corrected INFRA-523 (2026-08-25):** the re-tiering half of that sentence is **wrong**.
+> `/b-close` Phase 2.5's INFRA-256 inert filter skips comment/whitespace-only diffs, so the
+> header rewrite closed headlessly. The scope decision itself still stands as recorded β but
+> read a "this would force an attended close" claim against the gate's own filter, not as given.
+
**This is the single most important item to carry forward.** A repo that ships a
measurement contradicting an adjacent guarantee is worse than one that ships neither. The
follow-up should rewrite the header to a claim that is true β most of the misses are
@@ -85,8 +90,12 @@ reads as covering this path.
Every miss triaged into the corpus takes exactly one:
- `detected` β no longer a miss.
-- `accepted-miss-mitigated-elsewhere` β names the compensating control it rests on (the
- always-reachable root crisis affordance; the low-confidence-transcript support line).
+- `accepted-miss-mitigated-elsewhere` β names the compensating control it rests on.
+ **Corrected INFRA-523:** this bullet used to offer "the low-confidence-transcript support
+ line" as one. **That control does not exist and never did** β there is no confidence signal
+ in `features/journal` at all. The only real control is the root crisis button, and
+ DEBUG-506 leaves it unreachable keyboard-up, which is the `scanOnSave` state. Do not accept
+ a miss against a control you have not opened the file and found.
- `pattern-candidate-deferred-to-crisis-pass` β a widening is plausible but needs its own
item and a `crisis` ruling.
- `out-of-scope-stt-layer` β recognizer error, outside this module's stated contract.
From 446ee6d1a006e075782afacf67f1b0da467d5966 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 20:08:42 -0700
Subject: [PATCH 30/90] fix: DEBUG-539 destroy the analytics identity on
account erasure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
deleteAccountAndWipe erased the server account and swept local wellness data but
never reset the PostHog identity, so `.posthog-rn.json` retained the pre-erasure
distinct_id and any already-queued batch shipped under it on the next flush β
@posthog/core's reset() explicitly PRESERVES both persisted queues.
THE BRANCH CONDITION IS "AN INSTANCE EXISTS", NOT "A CLIENT IS MOUNTED"
This is the correction that makes the fix real rather than a fake control.
Analytics is opt-in and default OFF, and PostHogProvider only renders
while consent is granted β so a user who consented, revoked, then
erased has no provider in the tree and usePostHog() returns undefined, while an
instance built during the consented period is still ALIVE: the library registers
AppState listeners in its constructor and never removes them, and its provider
builds the client in a bare useMemo with no shutdown() on unmount. That instance
holds a memoryCache, and persist() re-serialises the WHOLE cache on every write.
So deleting the storage files under a live instance restores the pre-erasure id
on the next AppState change, while a test asserting "the file is gone" passes.
That is the same shape SecureStorageService already records for storeMetadata:
sweeping the key alone "reads as coverage and provides none".
The client is therefore registered at MODULE scope from inside the provider, and
the reference deliberately outlives unmount:
instance exists -> reset THROUGH it, so the write-back re-serialises empty
never existed -> unlink the files, because nothing can rewrite them
Never both β doing both races the live instance's next persist.
WHAT ELSE CHANGED, AND WHAT DELIBERATELY DID NOT
AnalyticsDeletion.ts is MOVED to analyticsIdentityReset.ts, not deleted. Stripped:
DeletionRequestRecord, previousDistinctId, DELETION_REQUESTS_KEY,
storeDeletionRequest, getDeletionRequestHistory, hasPendingDeletionRequests and
their barrel exports. That record persisted to a key matching neither
SWEPT_EXACT_KEYS nor SWEPT_ASYNC_PREFIXES, so it SURVIVED erasure β it retained
the exact identifier the erasure destroys. It had zero production callers, so
nothing was ever written; wiring it up as documented would have introduced the
leak. handleAnalyticsDeletion is kept so a public name does not silently vanish
and a future control has a correct primitive to call.
The attestation is NOT extended. No previousDistinctId, no hashed id, no
reset-succeeded boolean β the compliance ceiling is unchanged, and that is also
what keeps consentStore.ts out of this diff and the item mechanically closable.
deleteAccountAndWipe takes posthog as a REQUIRED, explicitly nullable parameter
with no default, so a future caller must decide rather than silently inheriting
this defect. Three call sites, none in a Protected Path. usePostHog's type lies β
declared () => PostHog, but the context default is {client: undefined} β so the
screen normalises undefined to null explicitly.
The reset is best-effort and cannot gate the wipe. The WIPE stays non-best-effort:
swallowing its error would route a FAILED deletion to DeleteAccountScreen's
success path, and the existing pinned invariant forbids it.
Both queues are nulled, not just Queue β they route to two different files.
flush() is NOT called: that transmits the pre-erasure batch, strictly worse than
the defect.
The only core/services/security/ touch is a COMMENT recording why PostHog's
residuals must not be added to SWEPT_EXACT_KEYS (they are document-directory
filenames, so listing them deletes nothing, and the naive "no AsyncStorage key
matching /posthog/i" test passes before any fix and forever).
TESTS
A local STATEFUL expo-file-system double, because the global mock is stateless
(exists: false hardcoded, no backing store) and under it removing the fix changes
no assertion β the fake-control class this item exists to remove.
9/9 analyticsIdentityReset.privacy β includes the post-condition that the
identity does not COME BACK on a later persist, and a NEGATIVE CONTROL
proving the registry assertion can fail
9/9 AccountDeletionService.unit β ordering pinned via invocationCallOrder
(server < attestation < reset < wipe), plus reset-failure-does-not-gate-wipe
and no-reset-on-server-failure
4/4 posthogStorageBackend.tripwire β pins the BRANCH TAKEN, not the version:
if expo-file-system stops exporting Paths/File the adapter silently falls
to AsyncStorage, at which point SWEPT_EXACT_KEYS becomes the correct fix
and the unlink arm becomes dead code
Residuals flagged, not absorbed: an in-flight POST already handed to fetch still
lands (no public cancel API); and attestation durability across launches is
DEBUG-545, which this item's narrowed AC explicitly excludes.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01CMQzeUa97ZCj4mnE1ZMLzS
---
app/.eslint-baseline.json | 3 +-
...hogStorageBackend.tripwire.privacy.test.ts | 73 ++++++
app/src/core/analytics/AnalyticsDeletion.ts | 182 ---------------
app/src/core/analytics/PostHogProvider.tsx | 10 +
.../analyticsIdentityReset.privacy.test.ts | 186 +++++++++++++++
.../core/analytics/analyticsIdentityReset.ts | 211 ++++++++++++++++++
app/src/core/analytics/index.ts | 16 +-
.../privacy/AccountDeletionService.ts | 44 +++-
.../AccountDeletionService.unit.test.ts | 72 +++++-
.../services/security/SecureStorageService.ts | 12 +
.../profile/screens/DeleteAccountScreen.tsx | 18 +-
docs/architecture/analytics-architecture.md | 69 ++++--
12 files changed, 679 insertions(+), 217 deletions(-)
create mode 100644 app/__tests__/privacy/posthogStorageBackend.tripwire.privacy.test.ts
delete mode 100644 app/src/core/analytics/AnalyticsDeletion.ts
create mode 100644 app/src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts
create mode 100644 app/src/core/analytics/analyticsIdentityReset.ts
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index c1e723f3..d915aa1b 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -222,5 +222,6 @@
"src/features/profile/screens/__tests__/PrivacyDataScreen.accessibility.test.tsx": 1,
"src/features/profile/screens/__tests__/ProfileScreen.accessibility.test.tsx": 1,
"src/core/theme/__tests__/black-call-sites.accessibility.test.ts": 1,
- "src/features/journal/services/__tests__/journalPreview.unit.test.ts": 1
+ "src/features/journal/services/__tests__/journalPreview.unit.test.ts": 1,
+ "src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts": 1
}
diff --git a/app/__tests__/privacy/posthogStorageBackend.tripwire.privacy.test.ts b/app/__tests__/privacy/posthogStorageBackend.tripwire.privacy.test.ts
new file mode 100644
index 00000000..fd9ef26f
--- /dev/null
+++ b/app/__tests__/privacy/posthogStorageBackend.tripwire.privacy.test.ts
@@ -0,0 +1,73 @@
+/**
+ * DEBUG-539 AC7 β pin the PostHog storage BACKEND BRANCH, not the version.
+ *
+ * `resetAnalyticsIdentity` deletes `.posthog-rn.json` / `.posthog-rn-logs.json`
+ * from the document directory on the never-consented path. That is correct only
+ * while `posthog-react-native` resolves its optimistic storage to the
+ * `expo-file-system` FILE branch, where storage KEYS *are* filenames.
+ *
+ * `buildOptimisticAsyncStorage` is a THREE-TIER fallback:
+ * 1. expo-file-system exporting BOTH `Paths` and `File` -> file-backed (today)
+ * 2. expo-file-system legacy `readAsStringAsync` -> legacy store
+ * 3. @react-native-async-storage/async-storage -> AsyncStorage
+ *
+ * If a future resolution loses `Paths`/`File`, the adapter silently drops to
+ * tier 2 or 3. At that point the file-unlinking arm becomes dead code AND
+ * `SECURE_STORAGE_CONFIG.SWEPT_EXACT_KEYS` becomes the correct fix β the exact
+ * inversion this file exists to make loud.
+ *
+ * Pinning the VERSION would not catch it: the branch depends on what the module
+ * EXPORTS, which can change within a semver-compatible bump. So assert the
+ * export surface and the guard, and read them as TEXT β expo-file-system's entry
+ * is untransformed TS (which is why jest.setup.js mocks it at all) and posthog's
+ * dist is not reachable through the package `exports` map.
+ */
+
+import fs from 'fs';
+import path from 'path';
+
+import { POSTHOG_RN_STORAGE_FILES } from '@/core/analytics/analyticsIdentityReset';
+
+const NODE_MODULES = path.resolve(__dirname, '../../node_modules');
+const read = (rel: string): string => fs.readFileSync(path.join(NODE_MODULES, rel), 'utf8');
+
+describe('DEBUG-539 AC7: the PostHog storage backend branch is pinned', () => {
+ it('posthog-react-native still names the two storage files we delete', () => {
+ const storage = read('posthog-react-native/dist/storage.js');
+
+ // Matcher-fires control (DEBUG-390): a path typo or a moved dist file would
+ // make every assertion below vacuous against an empty string.
+ expect(storage.length).toBeGreaterThan(500);
+
+ for (const file of POSTHOG_RN_STORAGE_FILES) {
+ expect(storage).toContain(file);
+ }
+ });
+
+ it('expo-file-system still exports BOTH Paths and File β tier 1 stays reachable', () => {
+ const dts = read('expo-file-system/build/index.d.ts');
+ expect(dts.length).toBeGreaterThan(200);
+ expect(dts).toMatch(/\bPaths\b/);
+ expect(dts).toMatch(/\bFile\b/);
+ });
+
+ it('the tier-1 guard is still the FIRST branch taken', () => {
+ const deps = read('posthog-react-native/dist/native-deps.js');
+ expect(deps.length).toBeGreaterThan(200);
+
+ // The guard that selects file-backed storage. If this stops matching, the
+ // adapter has moved and the reset primitive's unlink arm is no longer sound.
+ expect(deps).toMatch(/Paths\s*&&[\s\S]{0,40}File/);
+
+ // And the file-backed construction itself, which is what makes a storage KEY
+ // a FILENAME rather than an AsyncStorage key.
+ expect(deps).toMatch(/new\s+\w*\.?File\(/);
+ });
+
+ it('CONSEQUENCE, stated so a future reader does not have to re-derive it', () => {
+ // Not an assertion about behaviour β a deliberate, executable note. If any
+ // test above goes red, the fix is NOT to relax it: it is to move the residue
+ // handling from file-unlinking to SWEPT_EXACT_KEYS and re-point this pin.
+ expect(POSTHOG_RN_STORAGE_FILES).toEqual(['.posthog-rn.json', '.posthog-rn-logs.json']);
+ });
+});
diff --git a/app/src/core/analytics/AnalyticsDeletion.ts b/app/src/core/analytics/AnalyticsDeletion.ts
deleted file mode 100644
index 0f54b80a..00000000
--- a/app/src/core/analytics/AnalyticsDeletion.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-/**
- * Analytics Data Deletion Workflow
- *
- * Implements GDPR Article 17 (Right to Erasure) and CCPA deletion requirements.
- *
- * Process:
- * 1. Log deletion request (audit trail for compliance)
- * 2. Reset PostHog identity (immediate unlinking)
- * 3. Provide user confirmation with regulatory-appropriate language
- *
- * Note: Full historical data deletion requires contacting privacy@being.fyi
- * PostHog API deletion is handled via their dashboard or support.
- *
- * @see docs/development/PostHog-Integration-Plan.md
- */
-
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { Alert } from 'react-native';
-import { logSecurity } from '@/core/services/logging';
-
-/**
- * Deletion request types for regulatory categorization
- */
-export type DeletionRequestType = 'gdpr' | 'ccpa' | 'user_request';
-
-/**
- * Audit record for deletion requests
- * Kept locally for compliance audit trail
- */
-interface DeletionRequestRecord {
- timestamp: number;
- type: DeletionRequestType;
- previousDistinctId: string;
- completed: boolean;
-}
-
-// Storage key for deletion request audit trail
-const DELETION_REQUESTS_KEY = '@being/analytics_deletion_requests';
-
-/**
- * Handle analytics data deletion request
- *
- * @param type - Type of deletion request (for regulatory categorization)
- * @param posthog - PostHog instance (optional, for testing)
- * @returns Promise resolving when deletion is processed
- */
-export async function handleAnalyticsDeletion(
- type: DeletionRequestType = 'user_request',
- posthog?: { getDistinctId: () => string; reset: () => void }
-): Promise<{ success: boolean; message: string }> {
- try {
- // Get previous distinct ID for audit trail (before reset)
- let previousDistinctId = 'unknown';
-
- if (posthog) {
- try {
- previousDistinctId = posthog.getDistinctId();
- } catch {
- // PostHog not initialized - continue with deletion
- }
- }
-
- // Create deletion request record for audit trail
- const deletionRecord: DeletionRequestRecord = {
- timestamp: Date.now(),
- type,
- previousDistinctId,
- completed: false,
- };
-
- // Store deletion request (audit trail for CCPA 45-day requirement)
- await storeDeletionRequest(deletionRecord);
-
- // Reset PostHog identity (immediate unlinking)
- if (posthog) {
- try {
- posthog.reset();
- } catch {
- // PostHog not initialized - continue
- }
- }
-
- // Mark deletion as completed
- deletionRecord.completed = true;
- await storeDeletionRequest(deletionRecord);
-
- // Log for security audit
- logSecurity(
- `Analytics deletion processed: type=${type}`,
- 'low',
- { type, timestamp: deletionRecord.timestamp }
- );
-
- return {
- success: true,
- message: 'Analytics identity reset successfully',
- };
- } catch (error) {
- logSecurity(
- 'Analytics deletion failed',
- 'high',
- { type, error: error instanceof Error ? error.message : 'Unknown error' }
- );
-
- return {
- success: false,
- message: 'Failed to process deletion request',
- };
- }
-}
-
-/**
- * Show deletion confirmation alert with regulatory-appropriate language
- *
- * @param type - Type of deletion request
- */
-export function showDeletionConfirmation(type: DeletionRequestType = 'user_request'): void {
- const title = 'Analytics Data Request Submitted';
-
- let message =
- 'Your analytics identity has been reset and previous data is no longer linked to you.';
-
- if (type === 'gdpr' || type === 'ccpa') {
- message +=
- '\n\nFor complete deletion of historical data, contact privacy@being.fyi. ' +
- 'We will process your request within 30 days (GDPR) or 45 days (CCPA).';
- } else {
- message +=
- '\n\nFor complete deletion of historical data, contact privacy@being.fyi.';
- }
-
- Alert.alert(title, message, [{ text: 'OK' }]);
-}
-
-/**
- * Store deletion request in audit trail
- */
-async function storeDeletionRequest(record: DeletionRequestRecord): Promise {
- try {
- // Get existing requests
- const existingJson = await AsyncStorage.getItem(DELETION_REQUESTS_KEY);
- const existing: DeletionRequestRecord[] = existingJson
- ? JSON.parse(existingJson)
- : [];
-
- // Add new request (or update if same timestamp)
- const index = existing.findIndex((r) => r.timestamp === record.timestamp);
- if (index >= 0) {
- existing[index] = record;
- } else {
- existing.push(record);
- }
-
- // Keep only last 100 requests (compliance audit trail)
- const trimmed = existing.slice(-100);
-
- await AsyncStorage.setItem(DELETION_REQUESTS_KEY, JSON.stringify(trimmed));
- } catch {
- // Storage failure shouldn't block deletion
- }
-}
-
-/**
- * Get deletion request history (for compliance audits)
- */
-export async function getDeletionRequestHistory(): Promise {
- try {
- const json = await AsyncStorage.getItem(DELETION_REQUESTS_KEY);
- return json ? JSON.parse(json) : [];
- } catch {
- return [];
- }
-}
-
-/**
- * Check if there are pending deletion requests
- * (requests made but not yet confirmed by PostHog)
- */
-export async function hasPendingDeletionRequests(): Promise {
- const history = await getDeletionRequestHistory();
- return history.some((r) => !r.completed);
-}
diff --git a/app/src/core/analytics/PostHogProvider.tsx b/app/src/core/analytics/PostHogProvider.tsx
index e964076c..955b3e6c 100644
--- a/app/src/core/analytics/PostHogProvider.tsx
+++ b/app/src/core/analytics/PostHogProvider.tsx
@@ -12,6 +12,7 @@
import React from 'react';
import { PostHogProvider as PHProvider, usePostHog } from 'posthog-react-native';
+import { registerAnalyticsClient } from './analyticsIdentityReset';
import { useConsentStore } from '@/core/stores/consentStore';
import { env } from '@/core/config/env';
@@ -38,6 +39,15 @@ function RegisterSurfaceProperty(): null {
React.useEffect(() => {
if (posthog) {
posthog.register({ surface: 'app' });
+ // DEBUG-539: hand the instance to module scope so account erasure can reset
+ // it even after this provider stops rendering. Revoking consent unmounts
+ // but does NOT destroy the client β it keeps AppState
+ // listeners and an in-memory cache that re-persists the pre-erasure
+ // distinct_id on the next write. Erasing by deleting the storage files
+ // under a live instance is therefore a fake control; the reset has to go
+ // THROUGH the instance, which means holding a reference that outlives the
+ // render tree.
+ registerAnalyticsClient(posthog);
}
}, [posthog]);
return null;
diff --git a/app/src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts b/app/src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts
new file mode 100644
index 00000000..3463e89d
--- /dev/null
+++ b/app/src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts
@@ -0,0 +1,186 @@
+/**
+ * DEBUG-539 β the analytics identity is destroyed on account erasure.
+ *
+ * WHY THIS FILE OVERRIDES THE GLOBAL expo-file-system MOCK
+ * -------------------------------------------------------
+ * `__tests__/setup/jest.setup.js` mocks `expo-file-system` STATELESSLY: `File`
+ * returns `{write: jest.fn(), exists: false}` with no backing store. Under it,
+ * deleting the reset step changes no assertion β the suite would be green on
+ * fixed AND unfixed code, which is the exact fake-control class this work item
+ * exists to remove. The module-scoped Map below is what lets the negative
+ * controls actually fail.
+ */
+
+// ---------------------------------------------------------------------------
+// Stateful expo-file-system double. `mock`-prefixed so babel-plugin-jest-hoist
+// permits the factory to close over it.
+// ---------------------------------------------------------------------------
+const mockDocumentFiles = new Map();
+
+jest.mock('expo-file-system', () => ({
+ Paths: { document: '/doc' },
+ File: class {
+ name: string;
+ constructor(_dir: unknown, name: string) {
+ this.name = name;
+ }
+ get exists(): boolean {
+ return mockDocumentFiles.has(this.name);
+ }
+ delete(): void {
+ // The real API THROWS when the target is absent. Reproducing that is what
+ // proves the production `if (file.exists)` guard is load-bearing rather
+ // than defensive β remove the guard and the never-consented path throws.
+ if (!mockDocumentFiles.has(this.name)) throw new Error('ENOENT');
+ mockDocumentFiles.delete(this.name);
+ }
+ },
+}));
+
+jest.mock('posthog-react-native', () => ({
+ PostHogPersistedProperty: { Queue: 'queue', LogsQueue: 'logs_queue' },
+}));
+
+import { PostHogPersistedProperty } from 'posthog-react-native';
+import {
+ resetAnalyticsIdentity,
+ registerAnalyticsClient,
+ POSTHOG_RN_STORAGE_FILES,
+ __resetRegisteredAnalyticsClientForTests,
+ type AnalyticsIdentityResetTarget,
+} from '../analyticsIdentityReset';
+
+const OLD_ID = 'pre-erasure-distinct-id-0001';
+
+/**
+ * A PostHog stand-in that reproduces the two behaviours the fix turns on:
+ * `persist()` re-serialises the WHOLE in-memory cache on every write, and
+ * `reset()` mints a fresh anonymous id rather than emptying the file.
+ */
+function makeFakeClient(): AnalyticsIdentityResetTarget & {
+ persist: () => void;
+ cache: Record;
+} {
+ const cache: Record = {
+ distinct_id: OLD_ID,
+ [PostHogPersistedProperty.Queue]: [{ event: 'queued_before_erasure', distinct_id: OLD_ID }],
+ [PostHogPersistedProperty.LogsQueue]: [{ msg: 'log_before_erasure' }],
+ };
+ const persist = (): void => {
+ mockDocumentFiles.set(POSTHOG_RN_STORAGE_FILES[0], JSON.stringify({ version: 'v1', content: cache }));
+ };
+ persist();
+ return {
+ cache,
+ persist,
+ reset: () => {
+ delete cache.distinct_id;
+ // reset() ends in reloadFeatureFlags(), which re-mints and RE-PERSISTS a
+ // fresh anonymous id. The file is not empty afterwards β which is why the
+ // assertions below are "the OLD id is absent", never "the file is gone".
+ cache.anonymous_id = 'freshly-minted-0002';
+ persist();
+ },
+ setPersistedProperty: (key, value) => {
+ if (value === null) delete cache[key as unknown as string];
+ else cache[key as unknown as string] = value;
+ persist();
+ },
+ };
+}
+
+const storedPayload = (): string => mockDocumentFiles.get(POSTHOG_RN_STORAGE_FILES[0]) ?? '';
+
+beforeEach(() => {
+ mockDocumentFiles.clear();
+ __resetRegisteredAnalyticsClientForTests();
+});
+
+describe('DEBUG-539: an instance that EXISTS is reset through, never unlinked around', () => {
+ it('drops the pre-erasure id and BOTH queues from the persisted payload', () => {
+ const client = makeFakeClient();
+ expect(storedPayload()).toContain(OLD_ID); // control: the leak is present first
+
+ resetAnalyticsIdentity({ posthog: client });
+
+ expect(storedPayload()).not.toContain(OLD_ID);
+ expect(client.cache[PostHogPersistedProperty.Queue]).toBeUndefined();
+ expect(client.cache[PostHogPersistedProperty.LogsQueue]).toBeUndefined();
+ });
+
+ it('nulls the LOGS queue too β the two route to different files', () => {
+ // Nulling only `Queue` leaves `.posthog-rn-logs.json` intact. This assertion
+ // is what makes that a regression rather than an oversight.
+ const client = makeFakeClient();
+ resetAnalyticsIdentity({ posthog: client });
+ expect(client.cache[PostHogPersistedProperty.LogsQueue]).toBeUndefined();
+ });
+
+ it('does NOT unlink storage while an instance is alive', () => {
+ // Unlinking under a live client is a fake control: its next persist writes
+ // the file straight back from the in-memory cache.
+ const client = makeFakeClient();
+ resetAnalyticsIdentity({ posthog: client });
+ expect(mockDocumentFiles.has(POSTHOG_RN_STORAGE_FILES[0])).toBe(true);
+ });
+
+ it('the identity does not COME BACK on a later persist', () => {
+ // THE POST-CONDITION. Asserting the state immediately after reset() is not
+ // enough β the defect class here is a write-back that restores the id on the
+ // next AppState change. Simulate that write and re-assert.
+ const client = makeFakeClient();
+ resetAnalyticsIdentity({ posthog: client });
+ client.persist();
+ expect(storedPayload()).not.toContain(OLD_ID);
+ });
+});
+
+describe('DEBUG-539: the registered instance is used when the caller has none', () => {
+ it('resets through a registered client even though posthog is null', () => {
+ // THE CENTRAL FIX. `PostHogProvider` stops rendering when consent is revoked,
+ // so `usePostHog()` returns undefined and the caller passes null β but the
+ // INSTANCE is still alive. Branching on "is a client mounted" would unlink
+ // the files and let that instance rewrite them.
+ const client = makeFakeClient();
+ registerAnalyticsClient(client);
+
+ resetAnalyticsIdentity({ posthog: null });
+
+ expect(storedPayload()).not.toContain(OLD_ID);
+ expect(client.cache[PostHogPersistedProperty.Queue]).toBeUndefined();
+ // and it did NOT take the unlink branch
+ expect(mockDocumentFiles.has(POSTHOG_RN_STORAGE_FILES[0])).toBe(true);
+ });
+
+ it('NEGATIVE CONTROL β without the registry the pre-erasure id survives', () => {
+ // Proves the assertion above can fail. With no registered instance the call
+ // takes the unlink arm, which cannot reach a live client's memory cache β so
+ // a real client would re-persist OLD_ID on its next write.
+ const client = makeFakeClient();
+ // deliberately NOT registered
+ resetAnalyticsIdentity({ posthog: null });
+ client.persist();
+ expect(storedPayload()).toContain(OLD_ID);
+ });
+});
+
+describe('DEBUG-539: with no instance ever built, the residue is removed', () => {
+ it('unlinks both storage files', () => {
+ for (const f of POSTHOG_RN_STORAGE_FILES) mockDocumentFiles.set(f, `{"distinct_id":"${OLD_ID}"}`);
+
+ resetAnalyticsIdentity({ posthog: null });
+
+ for (const f of POSTHOG_RN_STORAGE_FILES) expect(mockDocumentFiles.has(f)).toBe(false);
+ });
+
+ it('does not throw when the files were never written', () => {
+ // The never-consented path. `File.delete()` throws on a missing target, so
+ // this asserts the `exists` guard is doing real work.
+ expect(mockDocumentFiles.size).toBe(0);
+ expect(() => resetAnalyticsIdentity({ posthog: null })).not.toThrow();
+ });
+
+ it('names BOTH files β a single-file sweep leaves the logs behind', () => {
+ expect(POSTHOG_RN_STORAGE_FILES).toEqual(['.posthog-rn.json', '.posthog-rn-logs.json']);
+ });
+});
diff --git a/app/src/core/analytics/analyticsIdentityReset.ts b/app/src/core/analytics/analyticsIdentityReset.ts
new file mode 100644
index 00000000..27294c6c
--- /dev/null
+++ b/app/src/core/analytics/analyticsIdentityReset.ts
@@ -0,0 +1,211 @@
+/**
+ * Analytics identity reset on account erasure (DEBUG-539).
+ *
+ * `deleteAccountAndWipe` erases the server account and sweeps local wellness
+ * data, but nothing reset the PostHog analytics identity β so `.posthog-rn.json`
+ * retained the pre-erasure `distinct_id`, and any batch already queued shipped
+ * under it on the next flush. This module is the reset primitive that closes
+ * that, and nothing more.
+ *
+ * WHAT THIS FILE DELIBERATELY NO LONGER DOES
+ * ------------------------------------------
+ * It used to maintain a local "deletion request" audit trail keyed by
+ * `previousDistinctId`, persisted to `@being/analytics_deletion_requests`. That
+ * key matches neither `SWEPT_EXACT_KEYS` nor `SWEPT_ASYNC_PREFIXES`, so it
+ * SURVIVED erasure β meaning the audit trail retained the exact identifier the
+ * erasure exists to destroy. It had zero production callers, so nothing was ever
+ * written; wiring it up as documented would have introduced the leak. The record,
+ * its storage key and its readers are gone. The reset primitive is kept.
+ *
+ * The erasure attestation lives in `consentStore.recordAccountDeletionAttestation`
+ * and carries NO identifier β not a previous id, not a hash, not a reset-succeeded
+ * flag. See DEBUG-545 for its durability across launches.
+ *
+ * WHY A MODULE-LEVEL REGISTRY AND NOT `usePostHog()`
+ * -------------------------------------------------
+ * This is the whole correctness argument, so it is written down rather than
+ * inferred.
+ *
+ * Analytics is opt-in and default OFF, and `PostHogProvider` only renders
+ * `` while consent is granted. A user who consented, later revoked,
+ * then deleted their account therefore has NO provider in the tree β `usePostHog()`
+ * returns undefined β while a PostHog INSTANCE constructed during the consented
+ * period is still alive: the library registers `AppState` listeners in its
+ * constructor and never removes them, and its provider builds the client in a bare
+ * `useMemo` with no `shutdown()` on unmount.
+ *
+ * That instance holds a `memoryCache`, and `persist()` re-serialises the WHOLE
+ * cache on every write. So deleting the files under a live instance is a FAKE
+ * CONTROL: the next `AppState` change writes the pre-erasure id straight back, and
+ * a test asserting "the file is gone" passes while the identity survives. This is
+ * the same shape `SecureStorageService` records for `storeMetadata` β sweeping the
+ * key alone "reads as coverage and provides none".
+ *
+ * So the branch condition is NOT "is a client mounted" but "does an instance
+ * EXIST". `registerAnalyticsClient` is called from inside the provider and the
+ * reference is held at module scope, which deliberately OUTLIVES unmount:
+ *
+ * instance exists -> reset THROUGH it, so the write-back re-serialises empty
+ * never existed -> unlink the files, because nothing can rewrite them
+ *
+ * Unlinking is never both. Doing both would race the live instance's next persist.
+ */
+
+import { Alert } from 'react-native';
+import { File, Paths } from 'expo-file-system';
+import { PostHogPersistedProperty } from 'posthog-react-native';
+import { logSecurity } from '@/core/services/logging';
+
+/**
+ * Deletion request types for regulatory categorization.
+ */
+export type DeletionRequestType = 'gdpr' | 'ccpa' | 'user_request';
+
+/**
+ * The narrow surface `resetAnalyticsIdentity` needs.
+ *
+ * Structural rather than the concrete `PostHog` type so `AccountDeletionService`
+ * β a pure privacy service β does not acquire a `posthog-react-native` import,
+ * and so tests can pass a fake without constructing a real client.
+ */
+export interface AnalyticsIdentityResetTarget {
+ reset: () => void;
+ setPersistedProperty: (key: PostHogPersistedProperty, value: unknown | null) => void;
+}
+
+/**
+ * PostHog RN's storage KEYS, which under the `expo-file-system` backend are
+ * FILENAMES in the document directory β not AsyncStorage keys.
+ *
+ * Adding these to `SECURE_STORAGE_CONFIG.SWEPT_EXACT_KEYS` deletes NOTHING, and a
+ * test asserting "no AsyncStorage key matches /posthog/i" passes today, before any
+ * fix, and would pass forever. Both were verified and both are refused.
+ *
+ * `Queue` and `LogsQueue` route to DIFFERENT files, so nulling only the first
+ * leaves the second intact.
+ */
+export const POSTHOG_RN_STORAGE_FILES = [
+ '.posthog-rn.json',
+ '.posthog-rn-logs.json',
+] as const;
+
+/**
+ * Module-scope reference to the live PostHog instance, if one was ever built.
+ *
+ * Set by `PostHogProvider`. Deliberately NOT cleared on unmount: an unmounted
+ * provider does not destroy the instance, and it is precisely the
+ * consented-then-revoked-then-erased path that needs the reference after the
+ * provider has stopped rendering.
+ */
+let registeredClient: AnalyticsIdentityResetTarget | null = null;
+
+/** Record the live analytics client. Called from `PostHogProvider`. */
+export function registerAnalyticsClient(client: AnalyticsIdentityResetTarget | null): void {
+ if (client) registeredClient = client;
+}
+
+/** Test seam: forget the registered instance. Never called from production code. */
+export function __resetRegisteredAnalyticsClientForTests(): void {
+ registeredClient = null;
+}
+
+/**
+ * Destroy the analytics identity and drop anything queued under it.
+ *
+ * Structurally non-throwing: every arm is individually guarded. The caller runs
+ * this between a successful server erasure and the local wipe, and a failure here
+ * must never abort that wipe.
+ *
+ * @param posthog The client the caller holds, or `null` when it has none. The
+ * parameter is REQUIRED and explicitly nullable so a future caller must decide
+ * rather than silently inheriting today's defect. A registered instance is used
+ * as a fallback when the caller passes null.
+ */
+export function resetAnalyticsIdentity({
+ posthog,
+}: {
+ posthog: AnalyticsIdentityResetTarget | null;
+}): void {
+ const client = posthog ?? registeredClient;
+
+ if (client) {
+ try {
+ // reset() clears DistinctId/AnonymousId/SessionId and mints a FRESH
+ // anonymous id, so the file is neither empty nor absent afterwards. Assert
+ // "the old id is gone and the queues are empty", never "the file is gone".
+ client.reset();
+ // reset() explicitly PRESERVES both queues β it prepends them to its
+ // keep-list β so they must be nulled separately, and AFTER the reset so
+ // anything re-enqueued in the same tick goes too. Nulling routes through
+ // `removeItem` -> `persist()`, which re-serialises the cache empty.
+ //
+ // Do NOT call flush() to drain them: that TRANSMITS the pre-erasure batch,
+ // which is strictly worse than the defect being fixed.
+ client.setPersistedProperty(PostHogPersistedProperty.Queue, null);
+ client.setPersistedProperty(PostHogPersistedProperty.LogsQueue, null);
+ } catch (error) {
+ logSecurity('[AnalyticsIdentity] reset through the live client failed', 'high', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ // Deliberately no unlink here. The live instance's next persist would write
+ // the file straight back from its in-memory cache.
+ return;
+ }
+
+ // No instance was ever built β the never-consented and consented-then-revoked
+ // paths. Nothing can rewrite the files, so removing them is sound and is the
+ // only arm that reaches residue left by a previously-consented session.
+ for (const name of POSTHOG_RN_STORAGE_FILES) {
+ try {
+ const file = new File(Paths.document, name);
+ // `delete()` throws when the target is absent, and a never-consented user
+ // has no such file β so the guard is required, not defensive.
+ if (file.exists) file.delete();
+ } catch (error) {
+ logSecurity('[AnalyticsIdentity] could not remove PostHog storage file', 'high', {
+ file: name,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+}
+
+/**
+ * Reset the analytics identity on explicit user request.
+ *
+ * Retained as the public name it has always had, now free of the audit trail that
+ * carried `previousDistinctId`. It has no production callers today: DEBUG-534
+ * ruled that the privacy policy's "Delete Analytics Data" control is corrected in
+ * COPY rather than built, so no UI invokes this. Kept rather than deleted so the
+ * name does not silently disappear, and so a future control has a correct
+ * primitive to call instead of re-deriving a leaky one.
+ */
+export function handleAnalyticsDeletion(
+ type: DeletionRequestType = 'user_request',
+ posthog?: AnalyticsIdentityResetTarget | null
+): { success: boolean; message: string } {
+ resetAnalyticsIdentity({ posthog: posthog ?? null });
+ logSecurity(`Analytics identity reset: type=${type}`, 'low', { type });
+ return { success: true, message: 'Analytics identity reset successfully' };
+}
+
+/**
+ * Show deletion confirmation alert with regulatory-appropriate language.
+ */
+export function showDeletionConfirmation(type: DeletionRequestType = 'user_request'): void {
+ const title = 'Analytics Data Request Submitted';
+
+ let message =
+ 'Your analytics identity has been reset and previous data is no longer linked to you.';
+
+ if (type === 'gdpr' || type === 'ccpa') {
+ message +=
+ '\n\nFor complete deletion of historical data, contact privacy@being.fyi. ' +
+ 'We will process your request within 30 days (GDPR) or 45 days (CCPA).';
+ } else {
+ message += '\n\nFor complete deletion of historical data, contact privacy@being.fyi.';
+ }
+
+ Alert.alert(title, message, [{ text: 'OK' }]);
+}
diff --git a/app/src/core/analytics/index.ts b/app/src/core/analytics/index.ts
index 7af05fa6..b5c018e3 100644
--- a/app/src/core/analytics/index.ts
+++ b/app/src/core/analytics/index.ts
@@ -21,7 +21,7 @@
* - PHIFilter: Validates events before transmission
* - AnalyticsEvents: Type-safe event constants
* - useAnalytics: Hook for tracking events through PHIFilter β PostHog
- * - handleAnalyticsDeletion: GDPR/CCPA deletion workflow
+ * - resetAnalyticsIdentity: destroys the analytics identity on account erasure
*
* @see docs/architecture/analytics-architecture.md
*/
@@ -33,10 +33,16 @@ export { useAnalytics } from './useAnalytics';
// Runtime (PostHog-backed) feature-flag tier (INFRA-199)
export { useFeatureFlag, PRODUCT_FLAGS } from './useFeatureFlag';
export type { PHIValidationResult, AnalyticsEventType } from './PHIFilter';
+// DEBUG-539: `getDeletionRequestHistory` / `hasPendingDeletionRequests` are GONE,
+// along with the `previousDistinctId` audit record they read. That record was
+// persisted to a key no erasure sweep reaches, so it RETAINED the identifier the
+// erasure destroys. Both had zero production callers; exporting them left a
+// loaded gun one wire-up away from reintroducing the leak.
export {
+ resetAnalyticsIdentity,
+ registerAnalyticsClient,
handleAnalyticsDeletion,
showDeletionConfirmation,
- getDeletionRequestHistory,
- hasPendingDeletionRequests,
-} from './AnalyticsDeletion';
-export type { DeletionRequestType } from './AnalyticsDeletion';
+ POSTHOG_RN_STORAGE_FILES,
+} from './analyticsIdentityReset';
+export type { DeletionRequestType, AnalyticsIdentityResetTarget } from './analyticsIdentityReset';
diff --git a/app/src/core/services/privacy/AccountDeletionService.ts b/app/src/core/services/privacy/AccountDeletionService.ts
index 3b9ae65e..2b5fa1e1 100644
--- a/app/src/core/services/privacy/AccountDeletionService.ts
+++ b/app/src/core/services/privacy/AccountDeletionService.ts
@@ -17,6 +17,10 @@
import supabaseService from '@/core/services/supabase/SupabaseService';
import SecureStorageService from '@/core/services/security/SecureStorageService';
+import {
+ resetAnalyticsIdentity,
+ type AnalyticsIdentityResetTarget,
+} from '@/core/analytics/analyticsIdentityReset';
import { useConsentStore } from '@/core/stores/consentStore';
import { clearLogAuditTrail, logError, logSecurity, LogCategory } from '@/core/services/logging';
@@ -32,7 +36,24 @@ export type AccountDeletionResult =
* (a second deleteAccount() on an already-erased account returns true via the
* no-account fast path).
*/
-export async function deleteAccountAndWipe(): Promise {
+export async function deleteAccountAndWipe({
+ posthog,
+}: {
+ /**
+ * The live PostHog client, or `null` when the caller has none.
+ *
+ * DEBUG-539: REQUIRED and explicitly nullable, with no default. A future
+ * caller must decide what to pass rather than silently inheriting the defect
+ * this parameter exists to fix β an optional parameter would let a new
+ * deletion entry point erase the account and leave the analytics identity
+ * intact, which is exactly what happened here.
+ *
+ * `null` is not a fallback to "skip the reset": the primitive falls back to a
+ * module-registered instance, and only unlinks storage when no instance was
+ * ever built.
+ */
+ posthog: AnalyticsIdentityResetTarget | null;
+}): Promise {
// 1. Server erasure FIRST. On failure, abort before touching local storage.
const serverErased = await supabaseService.deleteAccount();
if (!serverErased) {
@@ -55,13 +76,30 @@ export async function deleteAccountAndWipe(): Promise {
);
}
- // 3. On-device wipe incl. master key. Non-retryable once reached: if this
+ // 3. Analytics identity reset. AFTER the server delete (a failed one aborts
+ // with local state untouched) and BEFORE the wipe (so a wipe failure cannot
+ // strand a still-linked analytics identity).
+ //
+ // Best-effort, mirroring the attestation above: a reset failure must never
+ // gate the wipe. Note the WIPE itself stays non-best-effort β swallowing its
+ // error would route a FAILED deletion to DeleteAccountScreen's success path.
+ try {
+ resetAnalyticsIdentity({ posthog });
+ } catch (error) {
+ logSecurity(
+ '[AccountDeletion] analytics identity reset failed (continuing with wipe)',
+ 'high',
+ { error: error instanceof Error ? error.message : 'Unknown error' },
+ );
+ }
+
+ // 4. On-device wipe incl. master key. Non-retryable once reached: if this
// throws, do NOT loop back to the server call β the account is already
// gone server-side and a retry of the whole sequence remains safe.
await SecureStorageService.clearAllWellnessData({ deleteMasterKey: true });
logSecurity('[AccountDeletion] local wellness data wiped after server erasure', 'low');
- // 4. Drop the in-memory log audit trail LAST (DEBUG-355), so the entry the
+ // 5. Drop the in-memory log audit trail LAST (DEBUG-355), so the entry the
// line above just pushed goes with it. Synchronous and structurally
// non-throwing by design β a rejection here, after both erasures have
// already succeeded, would be caught by DeleteAccountScreen and reported to
diff --git a/app/src/core/services/privacy/__tests__/AccountDeletionService.unit.test.ts b/app/src/core/services/privacy/__tests__/AccountDeletionService.unit.test.ts
index e4e831aa..e533668f 100644
--- a/app/src/core/services/privacy/__tests__/AccountDeletionService.unit.test.ts
+++ b/app/src/core/services/privacy/__tests__/AccountDeletionService.unit.test.ts
@@ -5,12 +5,22 @@
* (compliance + crisis sign-off): the server-side account must be erased
* BEFORE any local data is touched. A failed server delete must NOT wipe local
* data β the user can retry with their data intact. These tests pin that rule.
+ *
+ * DEBUG-539 added a third step between the attestation and the wipe: the
+ * analytics identity reset. Its position is load-bearing in BOTH directions β
+ * after the server delete (a failed one aborts with local state untouched) and
+ * before the wipe (so a wipe failure cannot strand a still-linked identity).
*/
import { deleteAccountAndWipe } from '../AccountDeletionService';
import supabaseService from '@/core/services/supabase/SupabaseService';
import SecureStorageService from '@/core/services/security/SecureStorageService';
import { useConsentStore } from '@/core/stores/consentStore';
+import { resetAnalyticsIdentity } from '@/core/analytics/analyticsIdentityReset';
+
+jest.mock('@/core/analytics/analyticsIdentityReset', () => ({
+ resetAnalyticsIdentity: jest.fn(),
+}));
jest.mock('@/core/services/supabase/SupabaseService', () => ({
__esModule: true,
@@ -29,6 +39,7 @@ jest.mock('@/core/stores/consentStore', () => ({
const mockDeleteAccount = supabaseService.deleteAccount as jest.Mock;
const mockClearAllWellnessData = SecureStorageService.clearAllWellnessData as jest.Mock;
const mockRecordAttestation = jest.fn();
+const mockResetAnalyticsIdentity = resetAnalyticsIdentity as jest.Mock;
describe('AccountDeletionService β deleteAccountAndWipe ordering invariant', () => {
beforeEach(() => {
@@ -43,7 +54,7 @@ describe('AccountDeletionService β deleteAccountAndWipe ordering invariant', (
it('aborts and reports retryable when the server delete fails β NEVER wipes local data', async () => {
mockDeleteAccount.mockResolvedValue(false);
- const result = await deleteAccountAndWipe();
+ const result = await deleteAccountAndWipe({ posthog: null });
expect(result).toEqual({ ok: false, retryable: true });
expect(mockClearAllWellnessData).not.toHaveBeenCalled();
@@ -53,7 +64,7 @@ describe('AccountDeletionService β deleteAccountAndWipe ordering invariant', (
it('on server success, wipes local data with deleteMasterKey:true and returns ok', async () => {
mockDeleteAccount.mockResolvedValue(true);
- const result = await deleteAccountAndWipe();
+ const result = await deleteAccountAndWipe({ posthog: null });
expect(result).toEqual({ ok: true });
expect(mockClearAllWellnessData).toHaveBeenCalledTimes(1);
@@ -63,7 +74,7 @@ describe('AccountDeletionService β deleteAccountAndWipe ordering invariant', (
it('records the audit attestation BEFORE the wipe, and both AFTER the server delete', async () => {
mockDeleteAccount.mockResolvedValue(true);
- await deleteAccountAndWipe();
+ await deleteAccountAndWipe({ posthog: null });
const serverOrder = mockDeleteAccount.mock.invocationCallOrder[0];
const attestationOrder = mockRecordAttestation.mock.invocationCallOrder[0];
@@ -77,7 +88,7 @@ describe('AccountDeletionService β deleteAccountAndWipe ordering invariant', (
mockDeleteAccount.mockResolvedValue(true);
mockRecordAttestation.mockRejectedValue(new Error('secure-store unavailable'));
- const result = await deleteAccountAndWipe();
+ const result = await deleteAccountAndWipe({ posthog: null });
expect(result).toEqual({ ok: true });
expect(mockClearAllWellnessData).toHaveBeenCalledWith({ deleteMasterKey: true });
@@ -87,7 +98,58 @@ describe('AccountDeletionService β deleteAccountAndWipe ordering invariant', (
mockDeleteAccount.mockResolvedValue(true);
mockClearAllWellnessData.mockRejectedValue(new Error('wipe failed mid-flight'));
- await expect(deleteAccountAndWipe()).rejects.toThrow(/wipe failed/);
+ await expect(deleteAccountAndWipe({ posthog: null })).rejects.toThrow(/wipe failed/);
expect(mockDeleteAccount).toHaveBeenCalledTimes(1);
});
+
+ it('DEBUG-539: resets the analytics identity AFTER the attestation and BEFORE the wipe', async () => {
+ mockDeleteAccount.mockResolvedValue(true);
+
+ await deleteAccountAndWipe({ posthog: null });
+
+ // invocationCallOrder is monotonic across mocks, so this pins the actual
+ // sequence rather than merely asserting each step happened.
+ const serverOrder = mockDeleteAccount.mock.invocationCallOrder[0];
+ const attestOrder = mockRecordAttestation.mock.invocationCallOrder[0];
+ const resetOrder = mockResetAnalyticsIdentity.mock.invocationCallOrder[0];
+ const wipeOrder = mockClearAllWellnessData.mock.invocationCallOrder[0];
+
+ expect(serverOrder).toBeLessThan(attestOrder);
+ expect(attestOrder).toBeLessThan(resetOrder);
+ expect(resetOrder).toBeLessThan(wipeOrder);
+ });
+
+ it('DEBUG-539: forwards the caller-supplied client rather than inventing one', async () => {
+ mockDeleteAccount.mockResolvedValue(true);
+ const client = { reset: jest.fn(), setPersistedProperty: jest.fn() };
+
+ await deleteAccountAndWipe({ posthog: client });
+
+ expect(mockResetAnalyticsIdentity).toHaveBeenCalledWith({ posthog: client });
+ });
+
+ it('DEBUG-539: a failed reset does NOT gate the wipe', async () => {
+ // Best-effort, mirroring the attestation above. An erasure that stops because
+ // analytics could not be reset would leave the full local wellness store on a
+ // device whose server account is already gone β strictly worse than the bug.
+ mockDeleteAccount.mockResolvedValue(true);
+ mockResetAnalyticsIdentity.mockImplementationOnce(() => {
+ throw new Error('posthog exploded');
+ });
+
+ const result = await deleteAccountAndWipe({ posthog: null });
+
+ expect(mockClearAllWellnessData).toHaveBeenCalledWith({ deleteMasterKey: true });
+ expect(result).toEqual({ ok: true });
+ });
+
+ it('DEBUG-539: does NOT reset when the server delete failed', async () => {
+ // Nothing local may be touched on the abort path, and the analytics identity
+ // is local state like any other.
+ mockDeleteAccount.mockResolvedValue(false);
+
+ await deleteAccountAndWipe({ posthog: null });
+
+ expect(mockResetAnalyticsIdentity).not.toHaveBeenCalled();
+ });
});
diff --git a/app/src/core/services/security/SecureStorageService.ts b/app/src/core/services/security/SecureStorageService.ts
index 9b364913..69fcae8e 100644
--- a/app/src/core/services/security/SecureStorageService.ts
+++ b/app/src/core/services/security/SecureStorageService.ts
@@ -114,6 +114,18 @@ export const SECURE_STORAGE_CONFIG = {
* Note the two entries fail differently, which is why one comment cannot serve
* both: `crisis_analytics_queue` is a passive buffer that nothing rewrites
* after erasure, so for IT the list membership genuinely is the whole control.
+ *
+ * DEBUG-539 β do NOT add PostHog's residuals here. `.posthog-rn.json` and
+ * `.posthog-rn-logs.json` are storage KEYS that resolve to FILENAMES in the
+ * document directory under the `expo-file-system` backend, not AsyncStorage
+ * keys, so listing them deletes nothing and a test asserting "no AsyncStorage
+ * key matches /posthog/i" passes before any fix and would pass forever. They
+ * are handled in `core/analytics/analyticsIdentityReset.ts`, which resets
+ * THROUGH the live instance where one exists β deleting the files under a live
+ * client is the same write-back fake control described above for
+ * `storeMetadata`. A tripwire pins the backend branch, because if a future
+ * dependency resolution loses `Paths`/`File` the adapter silently becomes
+ * AsyncStorage-backed and THIS list becomes the correct fix.
*/
SWEPT_EXACT_KEYS: [
'@being/supabase/crisis_analytics_queue',
diff --git a/app/src/features/profile/screens/DeleteAccountScreen.tsx b/app/src/features/profile/screens/DeleteAccountScreen.tsx
index 1893391c..00c0d93f 100644
--- a/app/src/features/profile/screens/DeleteAccountScreen.tsx
+++ b/app/src/features/profile/screens/DeleteAccountScreen.tsx
@@ -14,10 +14,13 @@
*
* ORDERING: AccountDeletionService.deleteAccountAndWipe() erases the server
* account FIRST; a failed server delete surfaces a retryable error and leaves
- * local data intact (no wipe). See AccountDeletionService for the invariant.
+ * local data intact (no wipe). DEBUG-539 inserted the analytics-identity reset
+ * between that erasure and the local wipe. See AccountDeletionService for the
+ * invariant.
*/
import React, { useState, useCallback } from 'react';
+import { usePostHog } from 'posthog-react-native';
import {
View,
Text,
@@ -32,6 +35,7 @@ import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { colorSystem, spacing, borderRadius, typography, semantic } from '@/core/theme';
import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator';
+import type { AnalyticsIdentityResetTarget } from '@/core/analytics/analyticsIdentityReset';
import { deleteAccountAndWipe } from '@/core/services/privacy/AccountDeletionService';
import { crisisAccessoryProps } from '@/features/crisis/constants/crisisInputAccessory';
@@ -57,12 +61,20 @@ const DeleteAccountScreen: React.FC = () => {
const canDelete = confirmText === CONFIRM_WORD && !isDeleting;
+ // DEBUG-539: the package types LIE here β `usePostHog` is declared
+ // `() => PostHog`, but PostHogContext's default value is `{client: undefined}`
+ // and the hook only warns before returning it. So this is genuinely
+ // `PostHog | undefined` on the very path that matters (analytics is opt-in and
+ // default OFF, so no provider is mounted for most users), and `undefined` is
+ // not `null`. Normalise once, here.
+ const posthog = usePostHog() as AnalyticsIdentityResetTarget | undefined;
+
const handleDelete = useCallback(async () => {
if (confirmText !== CONFIRM_WORD) return;
setIsDeleting(true);
setErrorMessage(null);
try {
- const result = await deleteAccountAndWipe();
+ const result = await deleteAccountAndWipe({ posthog: posthog ?? null });
if (result.ok) {
// Reset to the clean onboarding state in the same tick the wipe
// completes β Onboarding mounts its own crisis button + 988 line.
@@ -82,7 +94,7 @@ const DeleteAccountScreen: React.FC = () => {
} finally {
setIsDeleting(false);
}
- }, [confirmText, rootNavigation]);
+ }, [confirmText, rootNavigation, posthog]);
return (
diff --git a/docs/architecture/analytics-architecture.md b/docs/architecture/analytics-architecture.md
index 586d4f1f..12629968 100644
--- a/docs/architecture/analytics-architecture.md
+++ b/docs/architecture/analytics-architecture.md
@@ -214,14 +214,36 @@ Use `AnalyticsEvents.EVENT_NAME` instead of raw strings for compile-time safety.
- Events containing PHI keywords in data
- Numeric values in non-safe keys (potential assessment scores)
-### AnalyticsDeletion
-**Location:** `src/core/analytics/AnalyticsDeletion.ts`
-
-GDPR/CCPA compliant deletion workflow:
-- Logs deletion requests with audit trail (CCPA 45-day requirement)
-- Resets PostHog identity (immediate unlinking)
+### analyticsIdentityReset
+**Location:** `src/core/analytics/analyticsIdentityReset.ts`
+
+Destroys the analytics identity as part of account erasure:
+- Resets the PostHog identity and nulls BOTH persisted queues (`Queue` and
+ `LogsQueue` route to different files, so nulling one leaves the other intact)
+- Where no instance was ever built, removes `.posthog-rn.json` /
+ `.posthog-rn-logs.json` from the document directory
- Provides regulatory-appropriate user messaging
+**No local deletion-request audit trail (DEBUG-539).** This module previously
+persisted a record keyed by `previousDistinctId` to
+`@being/analytics_deletion_requests` β a key no erasure sweep reaches, so it
+RETAINED the identifier the erasure exists to destroy. It had zero production
+callers, so nothing was ever written; wiring it up as documented would have
+introduced the leak. The record and its readers are gone.
+
+**The reset is invoked automatically inside full-account erasure**
+(`AccountDeletionService.deleteAccountAndWipe`), between the terminal attestation
+and the local wipe. It is NOT a standalone user-facing analytics control:
+DEBUG-534 ruled the privacy policy's "Delete Analytics Data" wording is corrected
+in copy rather than built.
+
+**It resets THROUGH a live instance, never around one.** Revoking consent
+unmounts `` but does not destroy the client, which keeps an in-memory
+cache that re-persists on its next write β so deleting the storage files under a
+live instance restores the pre-erasure id and reads as a fix. The client is
+registered at module scope so the reset can reach an instance that exists but is
+no longer rendered.
+
---
## Exports
@@ -236,14 +258,15 @@ export { PostHogProvider, usePostHogConfigured } from './PostHogProvider';
export { PHIFilter, AnalyticsEvents } from './PHIFilter';
export type { PHIValidationResult, AnalyticsEventType } from './PHIFilter';
-// Deletion Workflow
+// Analytics identity reset (account erasure)
export {
+ resetAnalyticsIdentity,
+ registerAnalyticsClient,
handleAnalyticsDeletion,
showDeletionConfirmation,
- getDeletionRequestHistory,
- hasPendingDeletionRequests,
-} from './AnalyticsDeletion';
-export type { DeletionRequestType } from './AnalyticsDeletion';
+ POSTHOG_RN_STORAGE_FILES,
+} from './analyticsIdentityReset';
+export type { DeletionRequestType, AnalyticsIdentityResetTarget } from './analyticsIdentityReset';
```
---
@@ -302,14 +325,21 @@ if (PHIFilter.isWhitelisted(AnalyticsEvents.CHECK_IN_COMPLETED)) {
3. Ensure no PHI is included in event properties
4. Update this documentation
-### Deletion Requests
+### Analytics identity reset
+
+Normally you do not call this: `deleteAccountAndWipe` invokes it as part of
+erasure. Pass the client explicitly β the parameter is required and explicitly
+nullable so a new caller must decide rather than silently inheriting the defect
+DEBUG-539 fixed.
```typescript
-import { handleAnalyticsDeletion, showDeletionConfirmation } from '@/core/analytics';
+import { resetAnalyticsIdentity } from '@/core/analytics';
+import { usePostHog } from 'posthog-react-native';
-// User requests deletion
-await handleAnalyticsDeletion('user_request');
-showDeletionConfirmation('user_request');
+// `usePostHog()` is typed non-nullable but is undefined when no provider is
+// mounted β which is the common case, since analytics is opt-in and default OFF.
+const posthog = usePostHog() ?? null;
+resetAnalyticsIdentity({ posthog });
```
---
@@ -326,8 +356,11 @@ No BAA required because no PHI is transmitted. The PHIFilter enforces this at th
- **Data minimization**: Only feature usage tracked
### CCPA
-- **Deletion requests**: Logged with audit trail
-- **45-day response**: Audit log supports compliance verification
+- **Deletion requests**: handled through full-account erasure, which resets the
+ analytics identity and drops anything queued under it
+- **45-day response**: evidenced by the terminal attestation in
+ `consent_history_v1`, which carries NO identifier β deliberately not by a local
+ log keyed to the erased `distinct_id` (DEBUG-539)
### App Store Privacy Labels
From 2c90a3cf99aaa3db299ff34433dcc2a00eadc5cb Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 20:21:27 -0700
Subject: [PATCH 31/90] fix: DEBUG-545 make the account-deletion attestation
durable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
recordAccountDeletionAttestation wrote the Art. 17(3)(b) record to
consent_history_v1, which IS erasure-excluded β correct placement, and the reason
this was never questioned. But LEGACY_CONSENT_HISTORY_KEY and
CONSENT_HISTORY_BLOB_KEY are the SAME literal, so that key is also the consent
chain's legacy migration source, and loadConsentHistoryWithMigration runs on every
consent read. It routes through readWithLegacyFallback, which relocates the
plaintext value into wellness_async_*, writes a wellness_migrated: marker, and
DELETES the SecureStore copy β both prefixes being in SWEPT_ASYNC_PREFIXES.
So the evidence left its protected substrate on the first post-erasure consent
read and became sweepable by any later clearAllWellnessData, including a partial
one. Nothing errored. The record was simply absent a launch later.
A PAYLOAD SPLIT, NOT A NEW EXEMPTION
Exempting consent_history_v1 from migration was never available: that migration
is required INFRA-144 behaviour for the history itself, and suppressing it would
be a far larger regression than the defect. The attestation moves to a dedicated
key, account_deletion_attestation_v1.
Two separate lists, deliberately not merged. ERASURE_EXCLUDED says the sweep
leaves a key alone; the new MIGRATION_ISOLATED says no code path may relocate it.
consent_history_v1 needs the first WITHOUT the second, so conflating them would
break consent-history migration for every install. readWithLegacyFallback now
REFUSES an isolated key β throws in __DEV__, logs high-severity and returns null
in release β so migration-isolation is enforced rather than conventional. A
control test proves the guard stays narrow and consent history still migrates.
THE LEGACY WRITE IS RETAINED, NOT MOVED
On an install that has not yet migrated, consent_history_v1 still holds the FULL
plaintext consent chain, and overwriting it with the single attestation entry is
what minimises that chain at erasure (Art. 5(1)(e)). Dropping that write to
"move" the attestation would have silently preserved the entire pre-deletion
history β a minimisation regression introduced by a durability fix.
Back-compat is a write-if-absent backfill covering both shipped states: an
install that has not relaunched since deletion (plaintext still at the legacy key,
read DIRECTLY via SecureStore β routing it through retrieveWellnessBlob is what
destroys it) and one that has (already inside the migrated blob). The copy is
verbatim; no field is added on the migration path, so the no-identifier ceiling
extends to the recovery code itself.
RETENTION IS NOW BOUNDED AND PUBLISHED
privacy-policy.md Β§7.3 gains the account-deletion record at 3 years, on-device,
explicitly identifier-free β anchored to the existing published 3-year crisis and
audit-log period rather than a newly invented number. An erasure-excluded record
retained indefinitely and undisclosed is a storage-limitation problem however
little it contains. Per the FEAT-399 / DPIA v2.2 precedent a stated-but-not-yet-
automatically-enforced bound is acceptable; an unstated one is not. Β§7.4 also now
states that deletion resets the analytics identity (DEBUG-539) and discards
anything queued but unsent. Policy 1.10 -> 1.11, DPIA -> v2.10.
DELIBERATELY EXCLUDED FROM THE DSR EXPORT
exportConsentRecords does not read the new key. The record documents a TERMINATED
subject's erasure; surfacing it in a later occupant's export on the same device
would disclose a prior user's deletion to a different person. This reverses the
architecture lens's recommendation to merge it into the exported history, and the
reasoning is recorded in code and in the DPIA so the absence is not later read as
an oversight and "fixed".
Ceiling unchanged and now test-pinned: booleans, a timestamp and a count. No
previousDistinctId, no hash, no device or auth id. Plaintext at rest is
deliberate β the record must survive deleteMasterKey:true, and AES-256-GCM under
a deleted master key is unrecoverable.
Tests: 10 new in accountDeletionAttestationDurability.privacy, placed at the
SecureStorageService integration level with real in-memory stores because
consentStore.test.ts mocks SecureStorageService wholesale and could not reach
readWithLegacyFallback at all β it would have passed without the fix. Includes a
negative control proving the sweep actually runs in the harness. 680 passed
across test:privacy; consentStore 68/68.
Recorded, not fixed: no Maestro flow exercises account deletion, so the safety
gate cannot observe this path. The durability property is pinned by jest over the
real migration path instead, and a green gate must not be read as evidence the
attestation survives.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01CMQzeUa97ZCj4mnE1ZMLzS
---
app/.eslint-baseline.json | 3 +-
.../services/security/SecureStorageService.ts | 56 ++++
...etionAttestationDurability.privacy.test.ts | 248 ++++++++++++++++++
app/src/core/stores/consentStore.ts | 109 ++++++++
docs/legal/dpia-sensitive-wellness-data.md | 1 +
docs/legal/privacy-policy.md | 7 +-
6 files changed, 422 insertions(+), 2 deletions(-)
create mode 100644 app/src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index d915aa1b..71fb105f 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -223,5 +223,6 @@
"src/features/profile/screens/__tests__/ProfileScreen.accessibility.test.tsx": 1,
"src/core/theme/__tests__/black-call-sites.accessibility.test.ts": 1,
"src/features/journal/services/__tests__/journalPreview.unit.test.ts": 1,
- "src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts": 1
+ "src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts": 1,
+ "src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts": 1
}
diff --git a/app/src/core/services/security/SecureStorageService.ts b/app/src/core/services/security/SecureStorageService.ts
index 69fcae8e..d25e8a08 100644
--- a/app/src/core/services/security/SecureStorageService.ts
+++ b/app/src/core/services/security/SecureStorageService.ts
@@ -213,12 +213,50 @@ export const WELLNESS_SECURE_STORE_KEYS = [
* de-authenticate the device with no recovery path and holds no wellness
* content.
*/
+/**
+ * DEBUG-545 β the account-deletion attestation's own key.
+ *
+ * It exists as a SEPARATE key from `consent_history_v1` because that key is
+ * shared with the consent-history chain, whose migration into AES-256-GCM
+ * AsyncStorage is REQUIRED behaviour and cannot be switched off. The attestation
+ * rode along with it: `readWithLegacyFallback` relocated the plaintext hit into
+ * `wellness_async_*` and deleted the SecureStore copy, and both that prefix and
+ * the migration marker are in `SWEPT_ASYNC_PREFIXES` β so the Art. 17(3)(b)
+ * evidence left its erasure-excluded home on the next consent read and became
+ * sweepable by any later `clearAllWellnessData`, including a partial one.
+ *
+ * Plaintext at rest is deliberate, not an oversight: the record must survive
+ * `deleteMasterKey: true`, and AES-256-GCM under a deleted master key is
+ * unrecoverable. It carries no identifier β booleans, a timestamp and a count β
+ * which is what makes plaintext acceptable, and that ceiling is load-bearing.
+ */
+export const ACCOUNT_DELETION_ATTESTATION_KEY = 'account_deletion_attestation_v1';
+
export const ERASURE_EXCLUDED_SECURE_STORE_KEYS = [
'consent_record_v1',
'consent_history_v1',
'legal_gate_consents_v1',
'age_verification_v1',
'auth_device_id',
+ ACCOUNT_DELETION_ATTESTATION_KEY,
+] as const;
+
+/**
+ * DEBUG-545 β keys that must NEVER be routed through the legacy-migration path.
+ *
+ * A SEPARATE list from `ERASURE_EXCLUDED_SECURE_STORE_KEYS`, and conflating the
+ * two would break consent history outright: `consent_history_v1` is erasure-
+ * excluded AND is legitimately passed to `readWithLegacyFallback` on every load.
+ * Erasure-exclusion says "the sweep leaves this alone"; migration-isolation says
+ * "no code path may relocate this into sweepable storage". Only the second is
+ * what the attestation needs.
+ *
+ * Enforced in `readWithLegacyFallback` rather than left as a convention, because
+ * the defect it prevents is silent: the write succeeds, the read succeeds, and
+ * the record is simply gone one launch later.
+ */
+export const MIGRATION_ISOLATED_SECURE_STORE_KEYS = [
+ ACCOUNT_DELETION_ATTESTATION_KEY,
] as const;
/**
@@ -1121,6 +1159,24 @@ export class SecureStorageService {
return null;
}
+ // DEBUG-545 β refuse to migrate a migration-isolated key.
+ //
+ // Migrating one relocates it into `wellness_async_*` (swept) and deletes the
+ // SecureStore copy, which for the account-deletion attestation destroys the
+ // Art. 17(3)(b) evidence it exists to preserve. Loud in development so a
+ // future caller's mistake is unmissable; in release it degrades to "no legacy
+ // value" plus a high-severity log, because throwing here would abort a
+ // consent load β and a deletion flow β in the field.
+ if ((MIGRATION_ISOLATED_SECURE_STORE_KEYS as readonly string[]).includes(legacySecureStoreKey)) {
+ const message =
+ `[SecureStorage] ${legacySecureStoreKey} is migration-isolated and must not be ` +
+ 'passed to readWithLegacyFallback β migrating it would move the record into ' +
+ 'sweepable storage. Read it directly via SecureStore.';
+ if (__DEV__) throw new Error(message);
+ logSecurity(message, 'high');
+ return null;
+ }
+
const legacyData = await SecureStore.getItemAsync(legacySecureStoreKey);
if (legacyData === null) {
await this.markMigrated(legacySecureStoreKey);
diff --git a/app/src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts b/app/src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts
new file mode 100644
index 00000000..57d55634
--- /dev/null
+++ b/app/src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts
@@ -0,0 +1,248 @@
+/**
+ * DEBUG-545 β the account-deletion attestation survives the launches that follow
+ * an erasure.
+ *
+ * THE DEFECT
+ *
+ * `recordAccountDeletionAttestation` wrote the Art. 17(3)(b) record to
+ * `consent_history_v1`, which IS erasure-excluded β correct placement, and the
+ * reason this looked safe. But `LEGACY_CONSENT_HISTORY_KEY` and
+ * `CONSENT_HISTORY_BLOB_KEY` are the SAME literal, so that key is also the
+ * consent-history chain's legacy source, and `loadConsentHistoryWithMigration`
+ * runs on every consent read. It routes through `readWithLegacyFallback`, which
+ * relocates the plaintext hit into `wellness_async_*`, writes a
+ * `wellness_migrated:` marker, and DELETES the SecureStore copy. Both of those
+ * prefixes are in `SWEPT_ASYNC_PREFIXES`.
+ *
+ * Net: the evidence left its protected home on the first post-erasure consent
+ * read and became sweepable by any later `clearAllWellnessData` β including a
+ * partial one. Nothing failed; the record was simply gone a launch later.
+ *
+ * WHY A SEPARATE KEY RATHER THAN AN EXEMPTION
+ *
+ * The obvious fix β "never migrate `consent_history_v1`" β is unavailable.
+ * Migrating that key is REQUIRED behaviour for the consent history itself
+ * (INFRA-144). The two payloads had to be split, which is why this suite tests a
+ * NEW key rather than a new exclusion.
+ *
+ * WHY NOT IN consentStore.test.ts
+ *
+ * That file mocks `@/core/services/security/SecureStorageService` wholesale, so a
+ * test placed there cannot reach `readWithLegacyFallback` at all and would pass
+ * without the fix β a fake control. Here both stores are real in-memory Maps and
+ * the service is real, because the migration path IS the mechanism under test.
+ */
+
+const mockSecureStoreMap = new Map();
+const mockAsyncStorageMap = new Map();
+
+jest.mock('expo-secure-store', () => ({
+ setItemAsync: jest.fn(async (key: string, value: string) => {
+ mockSecureStoreMap.set(key, value);
+ }),
+ getItemAsync: jest.fn(async (key: string) => mockSecureStoreMap.get(key) ?? null),
+ deleteItemAsync: jest.fn(async (key: string) => {
+ mockSecureStoreMap.delete(key);
+ }),
+}));
+
+jest.mock('@react-native-async-storage/async-storage', () => ({
+ setItem: jest.fn(async (key: string, value: string) => {
+ mockAsyncStorageMap.set(key, value);
+ }),
+ getItem: jest.fn(async (key: string) => mockAsyncStorageMap.get(key) ?? null),
+ removeItem: jest.fn(async (key: string) => {
+ mockAsyncStorageMap.delete(key);
+ }),
+ getAllKeys: jest.fn(async () => Array.from(mockAsyncStorageMap.keys())),
+ multiGet: jest.fn(async (keys: string[]) =>
+ keys.map((k) => [k, mockAsyncStorageMap.get(k) ?? null])
+ ),
+ multiRemove: jest.fn(async (keys: string[]) => {
+ keys.forEach((k) => mockAsyncStorageMap.delete(k));
+ }),
+ clear: jest.fn(async () => {
+ mockAsyncStorageMap.clear();
+ }),
+}));
+
+// Passthrough EncryptionService. The property under test is KEY ROUTING β which
+// key a payload lands in and which sweeps reach it β not cipher correctness,
+// which `EncryptionService.realcrypto.test.ts` owns. Without this the real
+// service demands a master key that no test provisions, and the migration path
+// fails for the wrong reason.
+jest.mock('../EncryptionService', () => {
+ const wrap = (data: unknown, sensitivityLevel: string) => ({
+ encryptedData: Buffer.from(JSON.stringify(data), 'utf-8').toString('base64'),
+ iv: 'mock-iv',
+ tag: 'mock-tag',
+ salt: 'mock-salt',
+ metadata: {
+ algorithm: 'AES-GCM',
+ keyVersion: 1,
+ ivLength: 12,
+ tagLength: 16,
+ encryptedAt: 0,
+ sensitivityLevel,
+ performanceMetrics: { encryptionTimeMs: 1, dataSize: 0, encryptedSize: 0 },
+ },
+ checksum: 'mock-checksum',
+ });
+ const stub = {
+ encryptData: jest.fn(async (data: unknown, level: string) => wrap(data, level)),
+ decryptData: jest.fn(async (pkg: { encryptedData: string }) =>
+ JSON.parse(Buffer.from(pkg.encryptedData, 'base64').toString('utf-8'))
+ ),
+ encryptCrisisData: jest.fn(async (d: unknown) => wrap(d, 'level_1_crisis_responses')),
+ encryptAssessmentData: jest.fn(async (d: unknown) => wrap(d, 'level_2_assessment_data')),
+ initialize: jest.fn(async () => undefined),
+ destroy: jest.fn(async () => undefined),
+ deleteMasterKey: jest.fn(async () => undefined),
+ getInstance: jest.fn(),
+ };
+ stub.getInstance.mockReturnValue(stub);
+ return { __esModule: true, default: stub };
+});
+
+import SecureStorageService, {
+ ACCOUNT_DELETION_ATTESTATION_KEY,
+ ERASURE_EXCLUDED_SECURE_STORE_KEYS,
+ MIGRATION_ISOLATED_SECURE_STORE_KEYS,
+ WELLNESS_SECURE_STORE_KEYS,
+} from '../SecureStorageService';
+
+const LEGACY_CONSENT_HISTORY_KEY = 'consent_history_v1';
+
+/** The exact shape `recordAccountDeletionAttestation` writes. */
+const attestation = {
+ action: 'revoked' as const,
+ changes: { analyticsEnabled: false },
+ timestamp: 1_756_000_000_000,
+ note: 'account_deletion_requested; prior_entries=3',
+};
+
+beforeEach(() => {
+ mockSecureStoreMap.clear();
+ mockAsyncStorageMap.clear();
+});
+
+describe('DEBUG-545: the attestation key is isolated from the migration path', () => {
+ it('is migration-isolated', () => {
+ expect(MIGRATION_ISOLATED_SECURE_STORE_KEYS as readonly string[]).toContain(
+ ACCOUNT_DELETION_ATTESTATION_KEY
+ );
+ });
+
+ it('is erasure-excluded, so the sweep leaves it alone', () => {
+ expect(ERASURE_EXCLUDED_SECURE_STORE_KEYS as readonly string[]).toContain(
+ ACCOUNT_DELETION_ATTESTATION_KEY
+ );
+ });
+
+ it('is NOT in the wellness manifest β the only list the sweep actually deletes from', () => {
+ // `WELLNESS_SECURE_STORE_KEYS` is what `clearAllWellnessData` enumerates.
+ // Erasure-exclusion is documentation and assertion coverage; ABSENCE here is
+ // the real protection, so it gets its own assertion rather than being
+ // inferred from the one above.
+ expect(WELLNESS_SECURE_STORE_KEYS as readonly string[]).not.toContain(
+ ACCOUNT_DELETION_ATTESTATION_KEY
+ );
+ });
+
+ it('the two lists are NOT the same list', () => {
+ // Conflating them would break consent history outright: `consent_history_v1`
+ // is erasure-excluded AND is legitimately migrated on every load. This
+ // assertion is what stops a later reader "simplifying" one into the other.
+ expect(ERASURE_EXCLUDED_SECURE_STORE_KEYS as readonly string[]).toContain(
+ LEGACY_CONSENT_HISTORY_KEY
+ );
+ expect(MIGRATION_ISOLATED_SECURE_STORE_KEYS as readonly string[]).not.toContain(
+ LEGACY_CONSENT_HISTORY_KEY
+ );
+ });
+});
+
+describe('DEBUG-545: the attestation survives erasure and the launches after it', () => {
+ it('is still readable after clearAllWellnessData({deleteMasterKey:true})', async () => {
+ mockSecureStoreMap.set(ACCOUNT_DELETION_ATTESTATION_KEY, JSON.stringify(attestation));
+
+ await SecureStorageService.clearAllWellnessData({ deleteMasterKey: true });
+
+ expect(mockSecureStoreMap.get(ACCOUNT_DELETION_ATTESTATION_KEY)).toBe(
+ JSON.stringify(attestation)
+ );
+ });
+
+ it('survives a SECOND erasure', async () => {
+ mockSecureStoreMap.set(ACCOUNT_DELETION_ATTESTATION_KEY, JSON.stringify(attestation));
+ await SecureStorageService.clearAllWellnessData({ deleteMasterKey: true });
+ await SecureStorageService.clearAllWellnessData({ deleteMasterKey: true });
+ expect(mockSecureStoreMap.has(ACCOUNT_DELETION_ATTESTATION_KEY)).toBe(true);
+ });
+
+ it('NEGATIVE CONTROL β a NON-excluded SecureStore wellness key does NOT survive', async () => {
+ // Proves the sweep actually runs in this harness. Without it, every
+ // assertion above would pass against a `clearAllWellnessData` that did
+ // nothing at all β the fake-control shape this codebase keeps rediscovering.
+ const sweptKey = (WELLNESS_SECURE_STORE_KEYS as readonly string[])[0];
+ mockSecureStoreMap.set(sweptKey, 'should not survive');
+ mockSecureStoreMap.set(ACCOUNT_DELETION_ATTESTATION_KEY, JSON.stringify(attestation));
+
+ await SecureStorageService.clearAllWellnessData({ deleteMasterKey: true });
+
+ expect(mockSecureStoreMap.has(sweptKey)).toBe(false);
+ expect(mockSecureStoreMap.has(ACCOUNT_DELETION_ATTESTATION_KEY)).toBe(true);
+ });
+});
+
+describe('DEBUG-545: the migration path REFUSES the isolated key', () => {
+ it('throws in development rather than relocating it', async () => {
+ // The guard is what turns migration-isolation from a convention into a
+ // property. Reaching the migration for this key is the defect.
+ mockSecureStoreMap.set(ACCOUNT_DELETION_ATTESTATION_KEY, JSON.stringify(attestation));
+
+ await expect(
+ SecureStorageService.retrieveWellnessBlob(
+ `wellness_async_${ACCOUNT_DELETION_ATTESTATION_KEY}`,
+ ACCOUNT_DELETION_ATTESTATION_KEY,
+ { legacyFormat: 'plaintext_json', sensitivityLevel: 'level_2_assessment_data' }
+ )
+ ).rejects.toThrow(/migration-isolated/);
+
+ // and crucially it did NOT delete the SecureStore copy on the way out
+ expect(mockSecureStoreMap.has(ACCOUNT_DELETION_ATTESTATION_KEY)).toBe(true);
+ });
+
+ it('CONTROL β the consent-history key is still migrated normally', async () => {
+ // The guard must be narrow. If it fired on `consent_history_v1` it would
+ // break INFRA-144's migration for every install, which is a far larger
+ // regression than the bug being fixed.
+ mockSecureStoreMap.set(LEGACY_CONSENT_HISTORY_KEY, JSON.stringify([attestation]));
+
+ const result = await SecureStorageService.retrieveWellnessBlob(
+ 'wellness_async_consent_history_v1',
+ LEGACY_CONSENT_HISTORY_KEY,
+ { legacyFormat: 'plaintext_json', sensitivityLevel: 'level_2_assessment_data' }
+ );
+
+ expect(result).not.toBeNull();
+ // migration completed: the legacy copy is gone, which is exactly the
+ // behaviour that destroyed the attestation while it shared this key.
+ expect(mockSecureStoreMap.has(LEGACY_CONSENT_HISTORY_KEY)).toBe(false);
+ });
+});
+
+describe('DEBUG-545: the attestation carries no identifier', () => {
+ it('is booleans, a timestamp and a count β nothing more', () => {
+ const serialized = JSON.stringify(attestation);
+ // The ceiling compliance set, asserted as a shape rather than trusted to
+ // prose. A plaintext record that survives every wipe may not carry an id.
+ expect(Object.keys(attestation).sort()).toEqual(['action', 'changes', 'note', 'timestamp']);
+ for (const value of Object.values(attestation.changes)) {
+ expect(typeof value).toBe('boolean');
+ }
+ expect(serialized).not.toMatch(/distinct_?id/i);
+ expect(serialized).not.toMatch(/device_?id/i);
+ expect(serialized).not.toMatch(/user_?id|auth_?uid/i);
+ });
+});
diff --git a/app/src/core/stores/consentStore.ts b/app/src/core/stores/consentStore.ts
index 7dca8de4..eab09337 100644
--- a/app/src/core/stores/consentStore.ts
+++ b/app/src/core/stores/consentStore.ts
@@ -26,6 +26,7 @@ import { generateRandomString } from '@/core/utils/id';
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import SecureStorageService from '@/core/services/security/SecureStorageService';
+import { ACCOUNT_DELETION_ATTESTATION_KEY } from '@/core/services/security/SecureStorageService';
import { getCurrentUserId } from '@/core/constants/devMode';
import { logSecurity } from '@/core/services/logging';
// INFRA-377: read directly rather than importing `isE2EOnboardingSeedEnabled`
@@ -770,7 +771,85 @@ async function persistConsentHistory(history: ConsentHistoryEntry[]): Promise {
+ try {
+ const existing = await SecureStore.getItemAsync(ACCOUNT_DELETION_ATTESTATION_KEY);
+ if (existing !== null) return;
+
+ const legacy = await SecureStore.getItemAsync(LEGACY_CONSENT_HISTORY_KEY);
+ if (legacy === null) return;
+
+ const parsed: unknown = JSON.parse(legacy);
+ if (!Array.isArray(parsed)) return;
+ const attestation = (parsed as ConsentHistoryEntry[]).find(isDeletionAttestation);
+ if (!attestation) return;
+
+ // Copied VERBATIM β the no-identifier ceiling extends to the migration path
+ // itself, so no field (not even a "backfilled" marker) may be added here.
+ await SecureStore.setItemAsync(
+ ACCOUNT_DELETION_ATTESTATION_KEY,
+ JSON.stringify(attestation)
+ );
+ } catch {
+ // Never block a consent load on evidence recovery.
+ }
+}
+
+/**
+ * DEBUG-545 β arm (b): recover an attestation already relocated into the
+ * migrated history blob. Called with the freshly-loaded history.
+ */
+async function backfillDeletionAttestationFromHistory(
+ history: ConsentHistoryEntry[]
+): Promise {
+ try {
+ const existing = await SecureStore.getItemAsync(ACCOUNT_DELETION_ATTESTATION_KEY);
+ if (existing !== null) return;
+ const attestation = [...history].reverse().find(isDeletionAttestation);
+ if (!attestation) return;
+ await SecureStore.setItemAsync(
+ ACCOUNT_DELETION_ATTESTATION_KEY,
+ JSON.stringify(attestation)
+ );
+ } catch {
+ // Best-effort.
+ }
+}
+
async function loadConsentHistoryWithMigration(): Promise {
+ // DEBUG-545 β FIRST statement, and the ordering is the whole point: the
+ // retrieveWellnessBlob call below migrates the legacy key and DELETES the
+ // SecureStore copy, so any recovery has to happen before it. One line here
+ // covers all three callers of this function.
+ await backfillDeletionAttestation();
+
const migrationFlag = await AsyncStorage.getItem(CONSENT_HISTORY_MIGRATION_FLAG);
const isFirstRun = migrationFlag !== '1';
@@ -785,6 +864,9 @@ async function loadConsentHistoryWithMigration(): Promise
if (isFirstRun) {
await AsyncStorage.setItem(CONSENT_HISTORY_MIGRATION_FLAG, '1');
}
+ // DEBUG-545 arm (b): an install that already relaunched since deletion has
+ // its attestation inside the migrated blob rather than at the legacy key.
+ await backfillDeletionAttestationFromHistory(history ?? []);
return history ?? [];
}
@@ -798,6 +880,7 @@ async function loadConsentHistoryWithMigration(): Promise
const annotated = [...history, migrationEntry];
await persistConsentHistory(annotated);
await AsyncStorage.setItem(CONSENT_HISTORY_MIGRATION_FLAG, '1');
+ await backfillDeletionAttestationFromHistory(annotated);
return annotated;
}
@@ -1486,7 +1569,23 @@ export const useConsentStore = create((set, get) => ({
timestamp: Date.now(),
note: `account_deletion_requested; prior_entries=${consentHistory.length}`,
};
+ // DUAL-WRITE (DEBUG-545). Both writes are required and neither is redundant.
+ //
+ // The legacy overwrite is RETAINED, not replaced: on an install that has not
+ // yet migrated, `consent_history_v1` still holds the FULL plaintext consent
+ // chain in an erasure-excluded key, and collapsing it to a single entry is
+ // what minimises that chain at erasure (Art. 5(1)(e)). Dropping this write
+ // would silently preserve the whole pre-deletion history.
+ //
+ // The isolated key is what makes the record DURABLE. The legacy copy is
+ // migrated into sweepable storage on the next consent read β correct
+ // behaviour for the history, fatal for the attestation β so the Art. 17(3)(b)
+ // evidence lives at a key no migration path may touch.
await SecureStore.setItemAsync(LEGACY_CONSENT_HISTORY_KEY, JSON.stringify([attestation]));
+ await SecureStore.setItemAsync(
+ ACCOUNT_DELETION_ATTESTATION_KEY,
+ JSON.stringify(attestation)
+ );
},
/**
@@ -1595,6 +1694,16 @@ export const useConsentStore = create((set, get) => ({
/**
* Export consent records (CCPA compliance)
*/
+ // DEBUG-545 β the account-deletion attestation is DELIBERATELY not read here.
+ //
+ // It documents a TERMINATED subject's erasure. Surfacing it in a later
+ // occupant's data-subject export on the same device would disclose a previous
+ // user's deletion to a different person, which is a worse privacy outcome than
+ // the gap it would close. The architecture review proposed merging it into the
+ // exported history so it could not "silently disappear from the export that
+ // evidences the erasure"; compliance ruled the other way and that ruling is
+ // recorded in the DPIA (v2.10) so this absence is not later read as an
+ // oversight and 'fixed'.
exportConsentRecords: async () => {
const { currentConsent } = get();
diff --git a/docs/legal/dpia-sensitive-wellness-data.md b/docs/legal/dpia-sensitive-wellness-data.md
index 6d8171c8..183edf6a 100644
--- a/docs/legal/dpia-sensitive-wellness-data.md
+++ b/docs/legal/dpia-sensitive-wellness-data.md
@@ -247,6 +247,7 @@ Recorded here rather than left silent because Β§3 and `lia-crisis-telemetry.md`
| 2.7 | 2026-08-20 | Palouse Labs LLC | FEAT-475: the Art. 9(2)(a) unbundling is extended to `ReConsentScreen`, closing the divergence v2.6 recorded. (1) **The v2.6/item-4 forward pointer is discharged.** v2.6 noted that not bumping `CONSENT_VERSION` "keeps the tracked `ReConsentScreen` divergence (FEAT-475) unreachable" β a deliberate deferral, not an omission. That divergence is now closed at the source: Submit on the re-consent path is gated on the three contract acceptances (ToS, Privacy Policy, wellness disclaimer) only, and the Art. 9(2)(a) tick is captured and written to both records at whatever value it holds. Both consent-collection surfaces now carry control 22's shape, so a `CONSENT_VERSION` bump no longer re-opens the Art. 7(4) defect on the second entry point. (2) **Control 22's interim-enforcement-window disclosure is restated as covering TWO surfaces, not one.** The window itself is unchanged and is still not asserted to be short: `canPerformOperation('mental_health_processing')` still has zero production consumers as a gate, so a refusal arriving by the re-consent path is recorded and then honoured by no code path, exactly as one arriving at the legal gate is. FEAT-318 remains the closing item for both. (3) **A reading of control 20 is corrected at its remaining source.** v2.6 narrowed control 20's "all five operations" to *lapsed* consent; the same over-broad reading was still embedded in the `ReConsentScreen` invariant suite's own header, which attributed the breadth to `declineReConsent`. It is attributable to neither that function nor a refusing user: `declineReConsent` writes an audit entry and mutates no consent state, and the breadth comes from `canPerformOperation` failing closed on `consentStatus !== 'valid'`. Corrected in place rather than deleted with the note. (4) **`declineReConsent` deliberately unchanged.** Its stale-on-purpose behaviour is load-bearing for the next-launch re-prompt; a refusing user now has a route that does not depend on it, which is the actual remedy. (5) **No re-consent and `CONSENT_VERSION` again NOT bumped** β same reasoning as v2.6: this expands a right rather than altering agreed data practices. **Material-change assessment:** NOT a Β§1 trigger β no new sensitive wellness data category, no new sub-processor, no architecture change, no new jurisdiction. Control-parity completion across a second entry point, same class as v1.3, v1.7, v2.2, v2.4 and v2.6. Founder self-certification. No 16 CFR Part 318 trigger: nothing was exposed. |
| 2.8 | 2026-08-21 | Palouse Labs LLC | DEBUG-474: server-side subscription re-verification against Apple, and a scope silence corrected. (1) **Β§2 gains the App Store Server API channel**, which this DPIA had never named. It was not new processing β `verify-apple-receipt` has queried Apple for entitlement verification since INFRA-467 β but Β§2 listed Stripe for billing metadata and was silent on Apple, so a reader could not have found the channel from this document. DEBUG-474 adds a second, *scheduled* trigger for the same purpose, which is the natural occasion to correct the omission rather than leave it to be rediscovered. (2) **A scheduled trigger is not a new processing activity.** The purpose (subscription entitlement verification), the legal basis (contract performance), the recipient (Apple), and the datum sent (`original_transaction_id`, Apple's own identifier) are all unchanged; only the trigger moves from user-initiated to cron. Β§4's purpose entry is not qualified as user-initiated, so a scheduled reconciliation of the same fact is the same activity. No new disclosure is owed and `privacy-policy.md` is deliberately unedited β Β§5.1 already discloses Apple, and that file sits on the INFRA-348 legal-site sync path. (3) **A `subscriptions.environment` column is added** (Production|Sandbox, read from the verified Apple claim). It is one more attribute inside Β§3 category 7, already classified *sensitive* since v1.0 β not a new category. It is Apple routing metadata about the transaction and asserts nothing about the user; it must not be cited as a user- or account-verification control. (4) **Automated entitlement changes are audited.** A cron-driven status change writes `subscription_events` via the DEBUG-446 shared writer, using event types already permitted by the CHECK constraint β no migration, and Β§7 control 8's credited "audit logging on subscription events" therefore covers the no-user-present path too rather than silently excluding it. (5) **The heartbeat table's PII-free claim is preserved by construction.** `grace_period_automation_runs.errors` is jsonb with no size CHECK, so per-subscription failures are aggregated to class-and-count lines emitted from a closed vocabulary; no transaction id, row id, `user_id`, bundle id or Apple response text can reach it. Identified detail goes to `subscription_events` (RLS-protected, ownership-checked, 2KB-capped) instead. (6) **Retention unchanged** β no new obligation; `grace_period_automation_runs`'s existing 90-day self-prune (`20260808000000`) is unaffected given (5). (7) **Census at authoring time:** `subscriptions` 0 rows, `subscription_events` 0 rows, `grace_period_automation_runs` 11 rows. **Material-change assessment:** NOT a Β§1 trigger β no new category of sensitive wellness data, no new sub-processor (Apple already in scope as a recipient), no local-first architecture change, no new jurisdiction. A scope-documentation correction plus a control-coverage extension, same class as v1.3, v1.7, v2.2 and v2.6. Founder self-certification per Β§10. No 16 CFR Part 318 (FTC HBNR) trigger: nothing was exposed. **One finding filed rather than absorbed:** Β§2's Stripe bullet describes "subscription billing metadata processed via Stripe", but `subscriptions` is commented IAP-only (Apple/Google) and no edge function calls Stripe β so that bullet may describe a path that no longer exists. Not corrected here, because verifying it is a question about the payments architecture rather than about this change, and a legal document should not be edited on an unverified premise. |
| 2.9 | 2026-08-24 | Palouse Labs LLC | DEBUG-534: a named control that does not exist, removed from the published copy. (1) **`privacy-policy.md` Β§5.2 corrected** β it directed users to "Settings > Privacy > Delete Analytics Data", a control removed as a non-functional stub (MAINT-173) and absent from `app/src`. That is an affirmative representation about a control the app does not provide. Remedy: describe what the app actually does β analytics is opt-in default OFF, toggling it off unmounts the PostHog provider and stops collection immediately, server-side analytics events are deleted after 90 days (Β§7.2), and earlier deletion is available via privacy@being.fyi, honored within 30 days (Β§7.3). **No new control was built**: compliance ruled that CCPA/GDPR/TDPSA require a designated request method, not a self-service in-app control, and that the pseudonymous product-analytics identifier at issue is proportionate to a manual channel. (2) **The identical claim was mirrored, untracked by any legal tooling, at `docs/architecture/analytics-architecture.md`** and is corrected in the same commit; leaving it would have re-seeded the claim from an internal doc presenting itself as the policy's mirror. (3) **Five further stale in-app navigation paths corrected** across `privacy-policy.md`, `california-privacy.md` and `support.md` β the app has no "Settings" navigation root at all (the tab is Profile, the screen is "Privacy & Data"). Export and Delete Account do exist, so those were path-naming defects rather than missing controls. The iOS system path "Settings > Apple ID > Subscriptions" is not app navigation and is unchanged. (4) **A drift check now enforces the relation none of the three existing guards covered** (`app/__tests__/privacy/analyticsControlClaims.privacy.test.ts`, run by CI's Safety + privacy gates): a control path named in the legal copy must resolve to a real label in `PrivacyDataScreen.tsx`. (5) Material-change assessment recorded below. |
+| 2.10 | 2026-08-25 | Palouse Labs LLC | DEBUG-545: the account-deletion attestation is made durable, and its retention is published for the first time. (1) **The defect.** `recordAccountDeletionAttestation` wrote the Art. 17(3)(b) record to `consent_history_v1`, which IS in `ERASURE_EXCLUDED_SECURE_STORE_KEYS` β correct placement, and the reason this was never questioned. But that literal is ALSO `CONSENT_HISTORY_BLOB_KEY`, the consent-history chain's legacy migration source, and `loadConsentHistoryWithMigration` runs on every consent read. It routes through `readWithLegacyFallback`, which relocates the plaintext value into `wellness_async_*`, writes a `wellness_migrated:` marker, and deletes the SecureStore copy β both prefixes being in `SWEPT_ASYNC_PREFIXES`. So the evidence left its protected substrate on the first post-erasure consent read and became sweepable by any later `clearAllWellnessData`, including a partial one. Nothing errored; the record was simply absent a launch later. **This is recorded as a control that silently stopped being one**, which is the same disclosure posture as v1.8 and v2.0. (2) **Remedy is a payload SPLIT, not a new exemption.** Exempting `consent_history_v1` from migration was unavailable: that migration is required INFRA-144 behaviour for the history itself, and suppressing it would be a far larger regression than the defect. The attestation moves to a dedicated key, `account_deletion_attestation_v1`, added to the erasure exclusions and to a NEW and deliberately separate `MIGRATION_ISOLATED_SECURE_STORE_KEYS`. The two lists are not merged: erasure-exclusion says the sweep leaves a key alone, migration-isolation says no code path may relocate it β and `consent_history_v1` needs the first without the second. `readWithLegacyFallback` now REFUSES an isolated key (throws in development, logs high-severity and returns null in release), so the property is enforced rather than conventional. (3) **The legacy write is RETAINED, not moved.** On an install that has not yet migrated, `consent_history_v1` still holds the full plaintext consent chain, and overwriting it with the single attestation entry is what minimises that chain at erasure (Art. 5(1)(e)). Dropping that write to 'move' the attestation would have silently preserved the entire pre-deletion history β a minimisation regression introduced by a durability fix. (4) **Back-compat by write-if-absent backfill**, covering both shipped states: an install that has not relaunched since deletion (plaintext still at the legacy key, read directly via SecureStore β routing it through `retrieveWellnessBlob` is what destroys it) and one that has (attestation already inside the migrated blob). The copy is verbatim; **no field is added on the migration path**, so the no-identifier ceiling extends to the recovery code itself. (5) **Retention is now BOUNDED AND PUBLISHED** β `privacy-policy.md` Β§7.3 gains the account-deletion record at 3 years, on-device, explicitly identifier-free, anchored to the existing published 3-year crisis/audit-log period rather than a newly invented number. An erasure-excluded record retained indefinitely and undisclosed is an Art. 5(1)(e) storage-limitation problem regardless of how little it contains; per the FEAT-399 / v2.2 precedent a stated-but-not-yet-automatically-enforced bound is acceptable, an unstated one is not. (6) **Deliberately EXCLUDED from the data-subject export.** `exportConsentRecords` does not read the new key. The record documents a TERMINATED subject's deletion event; surfacing it in a later occupant's DSR export on the same device would disclose a prior user's erasure to a different person. This reverses the architecture lens's recommendation to merge it into the exported history, and the reasoning is recorded so it is not re-litigated as an oversight. (7) **Ceiling unchanged and now test-pinned** β booleans, a timestamp and a count; no `previousDistinctId`, no hash, no device or auth id. Plaintext at rest is deliberate: the record must survive `deleteMasterKey: true`, and AES-256-GCM under a deleted master key is unrecoverable. **Material-change assessment:** NOT a Β§1 trigger β no new category of sensitive wellness data, no new sub-processor, no local-first architecture change, no new jurisdiction, and no change to what is retained. A control-durability correction plus a first-time retention disclosure, same class as v1.3, v1.7, v2.2 and v2.6. Founder self-certification per Β§10. No 16 CFR Part 318 (FTC HBNR) trigger: nothing was exposed β the failure direction was evidence LOSS, not disclosure. **One consequence recorded rather than fixed:** no Maestro flow exercises account deletion at all, so the safety gate cannot observe this path; the durability property is pinned by jest integration tests over the real migration path instead. |
**DEBUG-534 material-change assessment (2026-08-24).** Assessed against the Β§1 triggers: *new derived category of sensitive wellness data* β no; *new sub-processor or recipient* β no; *new purpose of processing* β no; *change to a lawful basis* β no; *change to retention* β no. **No processing changed.** This is a correction to how existing processing is DESCRIBED: the policy named a deletion control the app never provided in working form, and the corrected text restates the retention and request-channel commitments already published at Β§7.2 and Β§7.3. The categories of data collected, the sinks, the legal bases and the retention periods are all unchanged. **No re-consent is triggered** β re-consent is driven by `CONSENT_VERSION` (`consentStore.ts`), which is independent of the policy document's `Version:` header; the header bump to 1.10 exists for the legal-site freshness check. The correction does not narrow any user right: no in-app analytics-deletion control existed to remove, and the request channel it now names (privacy@being.fyi) was already the documented rights channel.
diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md
index 590cb8ef..9429c6b4 100644
--- a/docs/legal/privacy-policy.md
+++ b/docs/legal/privacy-policy.md
@@ -1,6 +1,6 @@
# Privacy Policy
-**Version:** 1.10
+**Version:** 1.11
**Effective Date:** December 12, 2025
**Last Updated:** August 24, 2026
@@ -262,11 +262,14 @@ This extended retention supports safety-monitoring continuity and protects both
- **Data Deletion Requests:** Honored within 30 days of request
- **Audit Logs:** 3 years (for security and compliance)
- **Consent Records:** Retained indefinitely as proof of lawful data processing
+- **Account-Deletion Record:** 3 years, on your device only. When you delete your account we keep a small confirmation that the deletion happened β the date, and the privacy choices that were in effect at that moment. It contains **no identifier of any kind**: nothing that names you, your device, or your account, and nothing that could be used to re-link you to anything you did before. We keep it because we have to be able to show that a deletion request was honored, and it survives the wipe for that reason alone.
### 7.4 Your Right to Delete
You can delete your data at any time in Settings, including crisis-related data. Deletion removes your data both on your device and on our servers: it erases your anonymous account identifier (Β§4.1), which automatically and permanently deletes every record tied to it (any settings backup, subscription records, and crisis-detection events). We will honor deletion requests within 30 days, though we may retain anonymized records for legal compliance.
+Deletion also resets the analytics identity on your device and discards anything queued but not yet sent, so nothing captured before the deletion is transmitted afterward. The one thing deliberately kept is the non-identifying account-deletion record described in Β§7.3.
+
---
## 8. Children's Privacy
@@ -289,6 +292,8 @@ We may update this Privacy Policy from time to time. We will notify you of mater
**Recent revisions**
+- **v1.11 (August 25, 2026):** Β§7.3 now names the account-deletion record and states its retention (3 years, on-device, no identifier). Nothing about what is kept has changed β the record already existed and already survived erasure β but it was not disclosed, and an undisclosed retention with no stated bound is not a defensible one. Β§7.4 additionally states that deletion resets the analytics identity and discards anything queued but unsent.
+
- **v1.9 (August 6, 2026):** Clarified Β§7.1 and Β§7.2 to state where each category of retained data lives and what enforces its time limit. The stated retention periods are unchanged β 90 days for general wellness and analytics data, 3 years for crisis-related data. Β§7.1 now explicitly names server-side product analytics, which the 90-day period already covered but did not list.
---
From e07a504a352bd5e16f0297b865975c1a34deb8ab Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Tue, 25 Aug 2026 20:29:29 -0700
Subject: [PATCH 32/90] fix: DEBUG-547 move both Home rows out of the crisis
FAB's contested column
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The crisis FAB carries zIndex 9999, so in any overlap it takes the tap. A user
reaching for "Explore >" at the Practices row's right end reached CrisisResources
instead of PracticeLibrary. Measured on device (iPhone SE 3, 375x667):
crisis-button-root [331,523][375,567]
home-practices-entry [24,516][351,561] overlapping on BOTH axes
THE FILED FIX CANNOT WORK, AND THE TESTS ARE SHAPED TO SAY SO
AC2 specified `paddingRight` on `practicesEntry`. testID and that style are on
the SAME Pressable, so padding sits inside its border box: the frame stays
[24..351], the FAB keeps winning every tap in the overlap, and only the glyph
moves. AC1 (bounds disjoint) and AC2 (paddingRight) were mutually unsatisfiable.
Every cited precedent pads a non-interactive WRAPPER around the control β the
inverted topology.
The lever is `marginRight`, which moves the Pressable's own frame. Right edge
becomes 375-24-72 = 279 <= 303, clearing the exclusion rect on x at every y.
Value comes from CRISIS_BUTTON_EXCLUSION_RECT.left, not a fourth hand-copied
spacing[72] literal: the constant DERIVES 72 as size(44) + hitSlop(12) +
clearance(16), so it keeps tracking the FAB if any of the three moves. Note the
FAB's real touch band starts at x=319, not the 331 the hierarchy reports β
clearing only the painted bounds under-fixes by 12pt, which is why the criterion
is intersectsCrisisButtonExclusion() rather than a bounds comparison.
THE GUIDANCE ROW IS IN SCOPE, AND THAT IS WHAT SET THE SCOPE
Not incidental. eas.json's e2e-sim profile sets domain_guidance:true, so
RightNowAffordance is LIT IN EVERY GATE BUILD. With it rendered it sits inside
the exclusion region itself AND pushes the Practices row down to roughly
[591..636] β inside the FAB's band, worse than the filed defect. A Home-only fix
would have shipped a screen whose only armed witness (crisis-button-reachability,
375x667) renders a still-broken row on every run, and production enablement is a
one-line env change away.
`philosopher` deliberately NOT invoked despite features/guidance/ listing it: it
owns the tier CONTENT, and this changes a style object, no string and no tier.
TESTS, AND A DEBUG-390 TRAP WALKED INTO AND FIXED
The source-shape assertions initially FAILED on correct code, because the new
comments say "Must NOT be `paddingRight`" and warn about a `marginHorizontal`
override β a bare not.toContain matched the WARNING. Both halves of the
documented remedy are applied: strip comments before matching, and use
prop-shaped patterns (/paddingRight\s*:/) rather than bare identifiers. The
liveness control additionally asserts the stripped block still contains
executable text and no longer contains the prose, because comment-stripping plus
a narrow regex is exactly what can silently match nothing.
Pins, per row: the inset is on the SAME element as the testID and paddingRight is
absent (the assertion the filed fix would fail); the derived constant is used;
marginRight is declared LAST (RN is last-key-wins, and marginHorizontal's absence
is the contract); reverting to paddingRight red-lines; and the computed frame is
disjoint at y=516 AND y=591 β the flag-dark and flag-lit positions. The guidance
row additionally keeps its 44pt minimum height, so the clearance cannot be bought
with vertical space.
31/31 CleanHomeScreen.accessibility, 13/13 RightNowAffordance.accessibility,
448/448 npm run test:accessibility.
NOT DONE HERE, DELIBERATELY
The Maestro witness is NOT authored. It must be a POINT tap in the contested
column β `tapOn: id:` hits the element centre (x~151 after the fix) and passes on
the UNFIXED build, a false green β and the coordinate depends on the row's y in a
domain_guidance-LIT build, which the recorded 7402d8e3 capture (consistent with
DARK) does not give. Authoring one from an unverified coordinate would produce a
flow that has never meaningfully run, which CLAUDE.md is explicit is not coverage.
It is queued for the attended session with the hierarchy read that settles it.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01CMQzeUa97ZCj4mnE1ZMLzS
---
.../components/RightNowAffordance.tsx | 14 ++
.../RightNowAffordance.accessibility.test.tsx | 62 +++++++++
.../features/home/screens/CleanHomeScreen.tsx | 14 ++
.../CleanHomeScreen.accessibility.test.tsx | 125 ++++++++++++++++++
4 files changed, 215 insertions(+)
diff --git a/app/src/features/guidance/components/RightNowAffordance.tsx b/app/src/features/guidance/components/RightNowAffordance.tsx
index f5678e02..a5e66b5a 100644
--- a/app/src/features/guidance/components/RightNowAffordance.tsx
+++ b/app/src/features/guidance/components/RightNowAffordance.tsx
@@ -70,6 +70,7 @@ import type { StackNavigationProp } from '@react-navigation/stack';
import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator';
import { colorSystem, semantic, spacing, typography } from '@/core/theme';
+import { CRISIS_BUTTON_EXCLUSION_RECT } from '@/features/crisis/constants/crisisButtonGeometry';
import { TOUCH_TARGETS } from '@/core/theme/accessibility';
import { useAnalytics } from '@/core/analytics';
import { DOMAIN_BINDINGS } from '../constants/domainBindings';
@@ -141,6 +142,19 @@ const styles = StyleSheet.create({
marginTop: spacing[8],
borderTopWidth: 1,
borderTopColor: colorSystem.gray[200],
+ // DEBUG-547: moves the Pressable's OWN FRAME out of the crisis FAB's
+ // contested column. Must NOT be `paddingRight`: the testID and this style are
+ // on the same Pressable, so padding sits inside its border box β the frame
+ // stays put, the FAB at zIndex 9999 keeps winning every tap in the overlap,
+ // and only the glyph moves. Measured on device before the fix:
+ // crisis-button-root [331,523][375,567] vs the sibling Practices row [24,516][351,561].
+ // The correctness criterion is `intersectsCrisisButtonExclusion(...) === false`
+ // β right edge <= 303 β NOT "the label moved". Note the FAB's real touch band
+ // starts at x=319, not the 331 the hierarchy reports, because of its 12pt
+ // hitSlop; clearing only the painted bounds under-fixes by 12pt.
+ // Declared LAST because RN StyleSheet is last-key-wins: a `marginHorizontal`
+ // added below this line would silently override it.
+ marginRight: CRISIS_BUTTON_EXCLUSION_RECT.left,
},
labels: {
flex: 1,
diff --git a/app/src/features/guidance/components/__tests__/RightNowAffordance.accessibility.test.tsx b/app/src/features/guidance/components/__tests__/RightNowAffordance.accessibility.test.tsx
index 7d459d14..85b7c96e 100644
--- a/app/src/features/guidance/components/__tests__/RightNowAffordance.accessibility.test.tsx
+++ b/app/src/features/guidance/components/__tests__/RightNowAffordance.accessibility.test.tsx
@@ -15,6 +15,8 @@
* Β· the analytics call carries NO argument
*/
+import fsNode from 'fs';
+import pathNode from 'path';
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
@@ -141,3 +143,63 @@ function StyleSheetFlatten(style: unknown): Record {
}
return (style ?? {}) as Record;
}
+
+/**
+ * DEBUG-547: the guidance row's FRAME clears the crisis FAB's exclusion region.
+ *
+ * This row is NOT incidental to the Home fix β it is the reason the fix could not
+ * be scoped to `features/home/`. `eas.json`'s `e2e-sim` profile sets
+ * `domain_guidance: true`, so this row is LIT in every gate build. With it
+ * rendered it sits inside the exclusion region itself AND pushes the Practices
+ * row below it down into the FAB's band, which is worse than the filed defect.
+ * A Home-only fix would have shipped a screen whose only armed witness
+ * (`crisis-button-reachability`, 375x667) renders a still-broken row on every run.
+ */
+describe('DEBUG-547: the guidance row clears the crisis FAB exclusion region', () => {
+ const SRC = fsNode.readFileSync(pathNode.join(__dirname, '../RightNowAffordance.tsx'), 'utf-8');
+
+ /**
+ * COMMENT-STRIPPED (DEBUG-390). The block below deliberately names the
+ * anti-patterns it must avoid β "Must NOT be `paddingRight`", and a warning
+ * about `marginHorizontal` β so a bare `not.toContain` would match the warning
+ * and fail on correct code.
+ */
+ const styleBlock = (name: string): string => {
+ const start = SRC.indexOf(` ${name}: {`);
+ if (start === -1) throw new Error(`style "${name}" not found in RightNowAffordance`);
+ return SRC.slice(start, SRC.indexOf('\n },', start))
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+ };
+
+ it('sets the inset on the SAME element that carries the testID', () => {
+ const { getByTestId } = render( );
+ const style = StyleSheetFlatten(getByTestId('home-guidance-entry').props.style);
+ expect(style.marginRight).toBe(72);
+ expect(style.paddingRight).toBeUndefined();
+ });
+
+ it('keeps its 44pt minimum touch height β the inset is horizontal only', () => {
+ // The clearance must not be bought with vertical space; this row's own
+ // `minHeight: TOUCH_TARGETS.minimum` pin is the one that could regress.
+ const { getByTestId } = render( );
+ const style = StyleSheetFlatten(getByTestId('home-guidance-entry').props.style);
+ expect(style.minHeight).toBeGreaterThanOrEqual(44);
+ });
+
+ it('uses the derived constant and declares it LAST', () => {
+ const block = styleBlock('row');
+ expect(block).toContain('marginRight: CRISIS_BUTTON_EXCLUSION_RECT.left');
+ expect(block).not.toMatch(/marginHorizontal\s*:/);
+ expect(block).not.toMatch(/paddingRight\s*:/);
+ expect(block.indexOf('marginRight:')).toBeGreaterThan(block.indexOf('marginTop:'));
+ });
+
+ it('PROOF OF LIVENESS β the matchers can still go red (DEBUG-390)', () => {
+ expect(() => styleBlock('noSuchStyleBlock')).toThrow(/not found/);
+ const stripped = styleBlock('row');
+ expect(stripped).toMatch(/marginRight\s*:/);
+ expect(stripped).not.toContain('Must NOT be');
+ expect(' paddingRight: 72,').toMatch(/paddingRight\s*:/);
+ });
+});
diff --git a/app/src/features/home/screens/CleanHomeScreen.tsx b/app/src/features/home/screens/CleanHomeScreen.tsx
index c9a8d066..671cce51 100644
--- a/app/src/features/home/screens/CleanHomeScreen.tsx
+++ b/app/src/features/home/screens/CleanHomeScreen.tsx
@@ -13,6 +13,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import type { StackNavigationProp } from '@react-navigation/stack';
import { colorSystem, semantic, getTheme, spacing, borderRadius, typography } from '@/core/theme';
+import { CRISIS_BUTTON_EXCLUSION_RECT } from '@/features/crisis/constants/crisisButtonGeometry';
import type { RootStackParamList } from '@/core/navigation/CleanRootNavigator';
import { useStoicPracticeStore } from '@/features/practices/stores/stoicPracticeStore';
import { useSettingsStore, useAccessibilitySettings } from '@/core/stores/settingsStore';
@@ -403,6 +404,19 @@ const styles = StyleSheet.create({
marginTop: spacing[8],
borderTopWidth: 1,
borderTopColor: colorSystem.gray[200],
+ // DEBUG-547: moves the Pressable's OWN FRAME out of the crisis FAB's
+ // contested column. Must NOT be `paddingRight`: the testID and this style are
+ // on the same Pressable, so padding sits inside its border box β the frame
+ // stays put, the FAB at zIndex 9999 keeps winning every tap in the overlap,
+ // and only the glyph moves. Measured on device before the fix:
+ // crisis-button-root [331,523][375,567] vs the Practices row [24,516][351,561].
+ // The correctness criterion is `intersectsCrisisButtonExclusion(...) === false`
+ // β right edge <= 303 β NOT "the label moved". Note the FAB's real touch band
+ // starts at x=319, not the 331 the hierarchy reports, because of its 12pt
+ // hitSlop; clearing only the painted bounds under-fixes by 12pt.
+ // Declared LAST because RN StyleSheet is last-key-wins: a `marginHorizontal`
+ // added below this line would silently override it.
+ marginRight: CRISIS_BUTTON_EXCLUSION_RECT.left,
},
practicesEntryLabel: {
fontSize: typography.bodyRegular.size,
diff --git a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
index a8e87a7b..c57f6336 100644
--- a/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
+++ b/app/src/features/home/screens/__tests__/CleanHomeScreen.accessibility.test.tsx
@@ -16,6 +16,10 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { ScrollView, StyleSheet } from 'react-native';
+import fs from 'fs';
+import path from 'path';
+import { spacing } from '@/core/theme';
+import { intersectsCrisisButtonExclusion } from '@/features/crisis/constants/crisisButtonGeometry';
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
@@ -394,3 +398,124 @@ describe('DEBUG-548: the card announces what the practice is', () => {
.not.toContain(described);
});
});
+
+/**
+ * DEBUG-547: the Practices row's FRAME clears the crisis FAB's exclusion region.
+ *
+ * The crisis FAB carries `zIndex: 9999`, so in any overlap it takes the tap β a
+ * user reaching for "Explore βΊ" at the row's right end reached CrisisResources
+ * instead of PracticeLibrary. Measured on device (iPhone SE 3, 375x667):
+ *
+ * crisis-button-root [331,523][375,567]
+ * home-practices-entry [24,516][351,561] overlapping on BOTH axes
+ *
+ * WHY marginRight AND NOT paddingRight
+ *
+ * The filed fix was `paddingRight`. It cannot work, and the tests below are
+ * shaped to make that unmissable: `testID="home-practices-entry"` and
+ * `styles.practicesEntry` are on the SAME Pressable, so padding sits INSIDE its
+ * border box. The frame stays [24..351], the FAB keeps winning the tap, and only
+ * the glyph moves. Every cited precedent pads a non-interactive WRAPPER around
+ * the control β the inverted topology.
+ *
+ * WHAT THESE TESTS CANNOT DO
+ *
+ * jsdom has no layout engine, so the frame is COMPUTED from tokens rather than
+ * measured. Falsifying the value 72 on a real device is Maestro's job, and the
+ * flow must use a POINT tap in the contested column β `tapOn: id:` hits the
+ * element centre (x~151 after the fix) and passes on the UNFIXED build.
+ */
+describe('DEBUG-547: the Practices row clears the crisis FAB exclusion region', () => {
+ const SCREEN_SRC = fs.readFileSync(
+ path.join(__dirname, '../CleanHomeScreen.tsx'),
+ 'utf-8'
+ );
+
+ /**
+ * The stylesheet block for one named style, COMMENT-STRIPPED.
+ *
+ * The stripping is load-bearing, not tidiness (DEBUG-390). This codebase
+ * deliberately names anti-patterns in prose to warn the next reader off them β
+ * the block below says "Must NOT be `paddingRight`" and warns about a
+ * `marginHorizontal` override β so a bare `not.toContain('paddingRight')`
+ * matches the WARNING and fails on correct code. The assertions are about what
+ * the file DOES, so they must read only executable text.
+ */
+ const styleBlock = (name: string): string => {
+ const start = SCREEN_SRC.indexOf(` ${name}: {`);
+ if (start === -1) throw new Error(`style "${name}" not found in CleanHomeScreen`);
+ return SCREEN_SRC.slice(start, SCREEN_SRC.indexOf('\n },', start))
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+ };
+
+ it('sets the inset on the SAME element that carries the testID', () => {
+ // THE ASSERTION THE FILED FIX WOULD FAIL. `paddingRight` here would leave the
+ // frame β and therefore the tap target β exactly where it was.
+ const { getByTestId } = render( );
+ const style = flat(getByTestId('home-practices-entry').props.style) as Record;
+ expect(style.marginRight).toBe(72);
+ expect(style.paddingRight).toBeUndefined();
+ });
+
+ it('uses the DERIVED constant, not a fourth hand-copied literal', () => {
+ // crisisButtonGeometry already derives 72 as size(44) + hitSlop(12) +
+ // clearance(16). A local `spacing[72]` would silently stop tracking the FAB
+ // if any of those three ever moved.
+ expect(styleBlock('practicesEntry')).toContain('marginRight: CRISIS_BUTTON_EXCLUSION_RECT.left');
+ expect(SCREEN_SRC).toMatch(/from '@\/features\/crisis\/constants\/crisisButtonGeometry'/);
+ });
+
+ it('declares the inset LAST β RN StyleSheet is last-key-wins', () => {
+ const block = styleBlock('practicesEntry');
+ // `marginHorizontal` is the one key that could silently override the right
+ // inset, so its ABSENCE is the contract, not merely today's shape.
+ expect(block).not.toMatch(/marginHorizontal\s*:/);
+ expect(block.indexOf('marginRight:')).toBeGreaterThan(block.indexOf('marginTop:'));
+ });
+
+ it('ANTI-REGRESSION: reverting to paddingRight red-lines here', () => {
+ expect(styleBlock('practicesEntry')).not.toMatch(/paddingRight\s*:/);
+ });
+
+ it('the computed frame is disjoint from the exclusion region, flag DARK and LIT', () => {
+ const { getByTestId } = render( );
+ const inset = (flat(getByTestId('home-practices-entry').props.style) as Record)
+ .marginRight;
+ const width = 375 - spacing[24] - inset - spacing[24];
+ // y=516 is the measured resting position with domain_guidance DARK; y=591 is
+ // where the row lands once the guidance row above it renders. The FAB's band
+ // covers the second, which is why both are asserted.
+ for (const y of [516, 591]) {
+ expect(
+ intersectsCrisisButtonExclusion(
+ { x: spacing[24], y, width, height: 45 },
+ { width: 375, height: 667 }
+ )
+ ).toBe(false);
+ }
+ });
+
+ it('PROOF OF LIVENESS β these matchers can still go red (DEBUG-390)', () => {
+ // A source-shape assertion plus a narrow matcher is exactly the combination
+ // that can silently match nothing at all. Prove each instrument fires.
+ expect(() => styleBlock('noSuchStyleBlock')).toThrow(/not found/);
+ expect(SCREEN_SRC.length).toBeGreaterThan(1000);
+ // Comment-stripping plus a narrow regex is the combination that can silently
+ // match NOTHING. Prove the stripped block still has executable content, and
+ // that it really did lose the prose naming the anti-patterns.
+ const stripped = styleBlock('practicesEntry');
+ expect(stripped).toMatch(/marginRight\s*:/);
+ expect(stripped).not.toContain('Must NOT be');
+ // and the prop-shaped matchers fire against known-bad literals
+ expect(' paddingRight: 72,').toMatch(/paddingRight\s*:/);
+ expect(' marginHorizontal: spacing[24],').toMatch(/marginHorizontal\s*:/);
+ // the arithmetic assertion above is not vacuous: at inset 0 it must be TRUE
+ expect(
+ intersectsCrisisButtonExclusion(
+ { x: spacing[24], y: 516, width: 375 - spacing[24] * 2, height: 45 },
+ { width: 375, height: 667 }
+ )
+ ).toBe(true);
+ });
+});
From a9daa11ee3dcade07c6deaf065d67bdec8fff578 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Thu, 27 Aug 2026 09:23:12 -0700
Subject: [PATCH 33/90] fix: DEBUG-553 correct phiDetection's docblocks to
describe the armed control
The module documented itself as shared by two scan sites, `sanitizeEvent` and
`AnalyticsPrivacyEngine.validatePrivacyProtection`. Neither exists anywhere in
the repo. They were real when MAINT-202 authored this module and lived only on
the custom-API AnalyticsService path that INFRA-214 deleted, so the prose was
orphaned by that deletion rather than wrong when written.
The item was filed claiming `containsPHI` had zero production importers. That is
no longer true: INFRA-535 (103797db) armed it at PHIFilter.ts:271, inside
scanValue, reached from validate <- useAnalytics.trackEvent. Writing the docblock
the item literally asked for would have recreated the defect it exists to fix, so
the correction describes the ARMED state instead.
Recorded decisions:
- KEEP phiDetection.ts. Deleting it removes a live analytics gate; merging it
into the security layer is forbidden by that layer's no-import-from-analytics
constraint.
- LEAVE BOTH pattern sets, PHI_DETECTION_PATTERNS authoritative. The security
copy is an exact 7-of-10 subset missing international phone, IPv4 and UUID -
named in both files so nobody re-diffs them. The gap has no live effect:
sanitizeWellnessData's only consumer, SecurityMonitoringService, has zero
runtime importers. Revisit if that changes.
- Corrected wellnessDataPatterns' "pinned by passing tests" justification, which
was vacuous when written and is now true for a different reason.
- Dropped the "HIPAA Safe Harbor" framing on the pattern set. Being is not a
HIPAA-covered entity; the set is a product commitment.
Adds a wiring guard to the contract suite. Everything else in that file tests the
predicate in isolation and would stay green if nothing called it - which was the
case until INFRA-535. The new pair asserts PHIFilter.validate rejects a payload
only the pattern set can catch, matching the "PHI pattern detected" branch
specifically, plus a clean-value control. Mutation-proved: cutting the call at
PHIFilter.ts:271 reds the first and leaves the control green.
Diff under core/services/security/ is comment-only, so Phase 2.5's inert filter
skips it. Closing the pattern divergence in code would arm the gate and buy an
attended simulator session for a documentation item.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01SRYegVzjeZjxM2P4meDDLD
---
.../unit/phi-detection-contract.test.ts | 46 +++++++++++++-
app/src/core/analytics/phiDetection.ts | 60 ++++++++++++++++---
.../services/security/wellnessDataPatterns.ts | 20 ++++++-
3 files changed, 114 insertions(+), 12 deletions(-)
diff --git a/app/__tests__/unit/phi-detection-contract.test.ts b/app/__tests__/unit/phi-detection-contract.test.ts
index 993c91e5..b9a5cfd1 100644
--- a/app/__tests__/unit/phi-detection-contract.test.ts
+++ b/app/__tests__/unit/phi-detection-contract.test.ts
@@ -1,7 +1,15 @@
/**
* PHI detection contract (MAINT-202).
*
- * Guards the `containsPHI(data)` predicate that gates every analytics event.
+ * Pins the PATTERN COVERAGE of the `containsPHI(data)` predicate. It does not pin
+ * the wiring, and the predicate does not gate every analytics event β this
+ * docblock used to say it did (DEBUG-553). What actually happens: `PHIFilter`
+ * calls `containsPHI` from `scanValue`, on string property VALUES only, and skips
+ * it entirely when the key is in `SAFE_PROPERTY_KEYS`. Numeric values and property
+ * keys are checked by other branches. That wiring is pinned by
+ * `__tests__/privacy/phiFilterScanSurface.privacy.test.ts`; the one assertion here
+ * that touches it is the wiring guard at the bottom of this file.
+ *
* Two obligations:
* 1. The fix: PHI scanning is scoped to the user-supplied `data` payload, so a
* service-injected 13-digit timestamp in the ENVELOPE no longer false-flags
@@ -14,7 +22,13 @@
* native-module setup.
*/
+jest.mock('@/core/services/logging', () => ({
+ logSecurity: jest.fn(),
+ logAnalytics: jest.fn(),
+}));
+
import { containsPHI } from '@/core/analytics/phiDetection';
+import { PHIFilter } from '@/core/analytics/PHIFilter';
describe('containsPHI β analytics PHI detection (MAINT-202)', () => {
describe('blocks PHI inside the data payload (no weakening)', () => {
@@ -81,4 +95,34 @@ describe('containsPHI β analytics PHI detection (MAINT-202)', () => {
}
});
});
+
+ describe('wiring guard: PHIFilter actually calls containsPHI (DEBUG-553)', () => {
+ // Everything above this block tests the predicate in isolation, and would stay
+ // green if nothing called it β which was true of this whole suite until
+ // INFRA-535. This block is the part that reds if the wire is cut.
+ //
+ // The payload is chosen so ONLY containsPHI can reject it: `field` is not a
+ // PHI_KEYWORDS segment, and the value 'a@b.com' contains no keyword substring
+ // either, so neither the key scan nor the value keyword scan fires. The email
+ // regex in PHI_DETECTION_PATTERNS is the only thing that can catch it.
+ const WIRED_PAYLOAD = { field: 'a@b.com' };
+
+ it('rejects a payload only the pattern set can catch', () => {
+ const result = PHIFilter.validate('settings_opened', WIRED_PAYLOAD);
+ expect(result.valid).toBe(false);
+ // Assert the containsPHI BRANCH specifically. `scanValue` reports keyword
+ // hits as "PHI keyword detected" and containsPHI hits as "PHI pattern
+ // detected", so matching the reason is what distinguishes a live wire from
+ // an unrelated rejection. Deleting the containsPHI call at the value branch
+ // flips this to valid:true.
+ expect(result.reason).toMatch(/PHI pattern detected/);
+ });
+
+ it('admits the same shape when the value is clean', () => {
+ // The negative half. Without it the assertion above would still pass if
+ // validate() rejected everything, which is the vacuous-green shape this
+ // item exists to correct.
+ expect(PHIFilter.validate('settings_opened', { field: 'clean' }).valid).toBe(true);
+ });
+ });
});
diff --git a/app/src/core/analytics/phiDetection.ts b/app/src/core/analytics/phiDetection.ts
index 82fb6cfc..9ce6ff01 100644
--- a/app/src/core/analytics/phiDetection.ts
+++ b/app/src/core/analytics/phiDetection.ts
@@ -1,11 +1,34 @@
/**
* PHI detection for analytics events (MAINT-202).
*
- * Scans a user-supplied analytics `data` payload for Protected Health
- * Information / personal-identifier patterns. Extracted from AnalyticsService
- * so the pure detection logic can be unit-tested in isolation (no singleton /
- * native-module graph) and shared by both scan sites: `sanitizeEvent` and
- * `AnalyticsPrivacyEngine.validatePrivacyProtection`.
+ * Scans a user-supplied analytics `data` payload for wellness-data and
+ * personal-identifier patterns. Extracted from AnalyticsService so the pure
+ * detection logic can be unit-tested in isolation (no singleton /
+ * native-module graph).
+ *
+ * STATUS β one production consumer (DEBUG-553). `containsPHI` is imported by
+ * `PHIFilter` and called from `PHIFilter.scanValue`, per string property value,
+ * skipped when the key is in `SAFE_PROPERTY_KEYS`. That is reached from
+ * `PHIFilter.validate` <- `useAnalytics.trackEvent`, so it runs on every tracked
+ * event. INFRA-535 armed it; before that it had no production importer at all.
+ * Note the predicate does NOT by itself "gate" an event: it is one of several
+ * checks inside `scanValue`, and it never sees numeric values or property keys.
+ *
+ * This docblock previously named two scan sites, `sanitizeEvent` and
+ * `AnalyticsPrivacyEngine.validatePrivacyProtection`. Neither exists anywhere in
+ * the repo. They were real when this module was written (MAINT-202) and lived
+ * only on the custom-API `AnalyticsService` path that INFRA-214 deleted; the
+ * prose was orphaned by that deletion rather than being wrong when authored.
+ * Recorded so the next reader does not repeat the archaeology.
+ *
+ * KEEP β do not delete or merge (DEBUG-553, AC5). Deleting this module removes a
+ * live analytics gate. Merging it into the security layer's
+ * `wellnessDataPatterns.ts` is forbidden by that file's own layering constraint:
+ * a security leaf must not import from analytics.
+ *
+ * The import in `PHIFilter` must stay STATIC β the `core/analytics` barrel is
+ * eager on `CrisisResourcesScreen.tsx` (FEAT-376), so a lazy import here would
+ * resolve a module during a crisis tap.
*
* SCAN SURFACE (non-negotiable): callers pass the event `data` payload ONLY,
* never the service-injected envelope (`eventType`, `timestamp`, `sessionId`).
@@ -21,9 +44,30 @@
*/
/**
- * COMPREHENSIVE PHI DETECTION PATTERNS
- * Enhanced patterns with Unicode normalization and broader coverage.
- * HIPAA Safe Harbor: block transmission of these identifiers.
+ * WELLNESS-DATA AND IDENTIFIER PATTERNS
+ *
+ * Block transmission of these identifiers. Being is a consumer-wellness app and
+ * NOT a HIPAA-covered entity, so this set is a product commitment rather than a
+ * Safe Harbor obligation β the previous "HIPAA Safe Harbor" framing named a rule
+ * that does not apply to us (DEBUG-553).
+ *
+ * AUTHORITATIVE SET (DEBUG-553, AC4). The security layer carries a deliberate
+ * near-duplicate, `WELLNESS_DATA_PATTERNS` in
+ * `core/services/security/wellnessDataPatterns.ts`. The two have diverged: that
+ * copy holds an exact 7-of-10 subset of this one, byte-identical where present,
+ * missing exactly these three β international phone, IPv4, and UUID. Named here
+ * so nobody has to re-diff them.
+ *
+ * Decision: LEAVE BOTH, this set authoritative. The duplication exists because a
+ * security leaf must not import from analytics, and closing the gap in code has
+ * no live effect today β the only consumer of the security copy is
+ * `sanitizeWellnessData` <- `SecurityMonitoringService`, which has zero runtime
+ * importers. Widening it would also collapse more log payloads to
+ * `{sanitized:true}`, a real observability change that deserves its own item.
+ * Revisit if `SecurityMonitoringService` ever gains a runtime caller.
+ *
+ * The `PHI_DETECTION_PATTERNS` / `containsPHI` identifiers are left as-is; the
+ * terminology rename is tracked separately.
*/
export const PHI_DETECTION_PATTERNS: RegExp[] = [
// Assessment scores (PHQ-9/GAD-7) - with Unicode normalization support
diff --git a/app/src/core/services/security/wellnessDataPatterns.ts b/app/src/core/services/security/wellnessDataPatterns.ts
index 66dd07a1..47d3f1ba 100644
--- a/app/src/core/services/security/wellnessDataPatterns.ts
+++ b/app/src/core/services/security/wellnessDataPatterns.ts
@@ -6,9 +6,23 @@
* the analytics layer (which would create a security β analytics β security cycle).
*
* Terminology: "wellness data" β Being is a consumer-wellness app, not a HIPAA entity.
- * The patterns intentionally overlap with AnalyticsService's pre-existing
- * `PHI_DETECTION_PATTERNS`; that older identifier is left untouched (it is pinned by
- * passing tests) and slated for a separate terminology-cleanup ticket.
+ *
+ * The patterns intentionally overlap with the analytics layer's
+ * `PHI_DETECTION_PATTERNS`, which is left untouched. The reason recorded here used to
+ * be "it is pinned by passing tests" β true only vacuously when written, because
+ * nothing then called the function those tests exercised. It is now genuinely pinned,
+ * for a different reason: INFRA-535 wired `containsPHI` into `PHIFilter.scanValue`, so
+ * it is a live production identifier on the analytics egress path. The terminology
+ * rename remains slated for a separate cleanup ticket (DEBUG-553).
+ *
+ * DIVERGENCE, DELIBERATE (DEBUG-553, AC4). This set is an exact 7-of-10 subset of the
+ * analytics one, byte-identical where present and missing international phone, IPv4
+ * and UUID. `PHI_DETECTION_PATTERNS` is authoritative; this copy exists because a
+ * security leaf must not import from analytics (see the cycle note above). The gap has
+ * no live effect: `sanitizeWellnessData`'s only consumer is `SecurityMonitoringService`,
+ * which has zero runtime importers. Reconcile if that ever changes β and note that
+ * widening this set collapses more log payloads to `{sanitized:true}`, an observability
+ * change rather than a free tightening.
*/
/** Sensitive structured keys stripped from any event-data object before logging. */
From c98af81fedcec39712837d1917d226d6ab767422 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:57:37 -0700
Subject: [PATCH 34/90] chore: INFRA-554 measure crisis-text recall against a
blind adversarial corpus
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thirty adversarial phrasings, drafted blind to CRISIS_TEXT_PATTERN_SOURCES and
hand-reviewed item by item, land in the corpus. All thirty miss.
The blended MUST-FIRE figure is DELETED, not updated. It would have moved
12/18 66.7% -> 12/48 25.0% while the numerator stayed frozen at 12 β an artefact
of corpus composition, which is the exact failure INFRA-523 existed to correct.
Recall is now reported per reachability stratum:
in-vocabulary 12/12 100.0%
morphological 0/5 0.0%
new-vocabulary 0/10 0.0%
out-of-reach-of-substring-matching 0/21 0.0%
addressable (morph + new-vocab) 0/15 0.0%
The finding: the vocabulary gap is real but bounded at 15 items; the
architectural ceiling is 21 and is the larger result. No widening reaches
"I've been giving away things I care about and I know why I'm doing it".
One number presents one deficit, and one deficit admits one remedy β widening
a constant that feeds showCrisisAlert() on a journaling surface. The strata are
what answer "widen what?" honestly. No widening is mandated here.
Closes an enforcement gap found in review: the disposition assertion keyed on
provenance.source === 'verified-miss', so model-drafted misses could land
undispositioned and AC5 was honour-system. The script's own 'anthropic-api'
stamp had the identical hole. Fixed with a second, behaviour-keyed arrival
guard rather than by renaming a provenance string; the pair is monotone.
All three new guards mutation-verified β each reds exactly one test.
AC1 deviation, recorded in the audit doc: no ANTHROPIC_API_KEY exists in this
environment, so generation used buildPrompt('MUST_FIRE', 40) rendered verbatim
in a clean-room session with no repo access. Prompt byte-identical, transport
differs, generator blindness preserved.
Still no CI threshold. Packet Β§7's drift pin stays declined.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01TYgDdLs3wxbxwr9M5Jts4p
---
.../fixtures/crisisTextCorpus.v1.json | 1082 +++++++++++++++--
.../textCrisisDetection.corpus.test.ts | 82 +-
.../crisis/services/textCrisisDetection.ts | 10 +-
.../INFRA-554-corpus-baseline-2026-08-28.md | 185 +++
4 files changed, 1264 insertions(+), 95 deletions(-)
create mode 100644 docs/development/audits/INFRA-554-corpus-baseline-2026-08-28.md
diff --git a/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json b/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
index a4fa9a98..65fd7ec0 100644
--- a/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
+++ b/app/src/features/crisis/services/__tests__/fixtures/crisisTextCorpus.v1.json
@@ -1,111 +1,1027 @@
{
- "corpusVersion": "v2-seed-plus-verified-misses",
+ "corpusVersion": "v3-seed-verified-misses-adversarial-batch-1",
"generatedOn": "2026-08-22",
"workItem": "INFRA-512, amended INFRA-523",
- "lastAmendedOn": "2026-08-25",
- "note": "MIXED CORPUS β NOT A BASELINE, IN EITHER DIRECTION. This corpus has two strata and the blended MUST-FIRE figure describes neither. Twelve MUST_FIRE items are the original repo-reviewed seed, drawn verbatim from strings already committed in textCrisisDetection.unit.test.ts β phrases the detector was already known to HANDLE, so they cannot fail. Six are the misses derived by hand in the INFRA-512 planning pass (packet Β§3), re-verified and landed under INFRA-523 β phrases the detector was already known to MISS, so they cannot pass. The rate is therefore an arithmetic consequence of which known items are present, not a measurement: it moves whenever an item is added, in the direction of whichever stratum grew. Do not quote it as measured recall, do not compare it across corpus versions, and do not read a change in it as detector drift. Real recall needs the adversarial corpus that scripts/generate-crisis-corpus.js produces under the human-review merge protocol in docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md; until that lands recall is UNMEASURED, and textCrisisDetection.ts's header says so. The six misses are each dispositioned pattern-candidate-deferred-to-crisis-pass β recorded here as a known gap, NOT as a mandate to widen CRISIS_TEXT_PATTERN_SOURCES, which feeds showCrisisAlert() on a journaling surface. Note the filename's \"v1\" is this FILE's identity; corpusVersion is the CONTENT's. They are meant to differ β do not \"fix\" that.",
+ "lastAmendedOn": "2026-08-28",
+ "note": "MIXED CORPUS, THREE STRATA β NOT A BASELINE, AND THE BLENDED MUST-FIRE FIGURE HAS BEEN REMOVED BECAUSE IT DESCRIBES NONE OF THEM. (1) Twelve MUST_FIRE items are the repo-reviewed seed, drawn verbatim from strings committed in textCrisisDetection.unit.test.ts β phrases the detector was already known to HANDLE, so they cannot fail. (2) Six are the misses hand-derived in the INFRA-512 planning pass and re-verified under INFRA-523 β known to MISS, so they cannot pass. (3) Thirty are model-drafted, human-reviewed adversarial items landed under INFRA-554; the generator had no access to the pattern list, and the batch is deliberately weighted toward the indirect and euphemistic tail, so it is NOT representative of the distribution of real journal entries. All thirty missed. Report and read recall PER `reachability` STRATUM only. Do not compute a blended MUST-FIRE rate, do not quote any figure as measured recall, do not compare across corpus versions, and do not read a change in any figure as detector drift β every figure still moves with which items are present. The out-of-reach stratum's 0% is a stated ceiling on what a substring matcher can do on this surface, NOT a regression and NOT a number to improve; widening CRISIS_TEXT_PATTERN_SOURCES cannot move it. The fifteen addressable items each carry a named false-positive class on the item itself; that is a requirement for a ruling, NOT a mandate to widen, and some will be ruled against. That constant feeds journalCrisisScanner.scan β showCrisisAlert() on a journaling surface, where a false alarm degrades the response to true positives. Real recall against a representative distribution remains UNMEASURED, and textCrisisDetection.ts's header says so. Note the filename's \"v1\" is this FILE's identity; corpusVersion is the CONTENT's. They are meant to differ β do not \"fix\" that.",
"classes": {
- "MUST_FIRE": "A disclosure of self-harm intent that detectCrisisInText must match.",
+ "MUST_FIRE": "Ground truth about the TEXT: a disclosure of suicidal ideation, self-harm intent, or active preparation, as judged by a human reviewer reading it as a private journal entry. A null result on a MUST_FIRE item is a false negative BY DEFINITION, whether or not a substring matcher could plausibly have caught it. Whether the detector should change in response is never answered by this label β it is answered per item in `disposition`, and what kind of change could even apply is recorded in `reachability`.",
"MUST_NOT_FIRE": "Ordinary language that must stay clean. Each item names the specific widening it refutes, so the class cannot be satisfied by generic mood text.",
"KNOWN_OVER_FIRE": "Phrasings that DO match and are accepted as deliberate over-triggers. Reported separately and excluded from the false-positive denominator, so a narrowing cannot game the rate by reclassifying them.",
"STT_MANGLED": "Realistic speech-to-text corruptions of a MUST_FIRE item, linked by sourceId. Reported as its own recall figure because the module scopes its guarantee to correctly-transcribed text."
},
"items": [
- { "id": "mf-suicidal", "class": "MUST_FIRE", "text": "suicidal",
- "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-kill-myself", "class": "MUST_FIRE", "text": "kill myself",
- "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-end-it-all", "class": "MUST_FIRE", "text": "end it all",
- "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-cannot-go-on", "class": "MUST_FIRE", "text": "cannot go on",
- "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-want-to-die", "class": "MUST_FIRE", "text": "want to die",
- "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-no-point-living", "class": "MUST_FIRE", "text": "no point living",
- "provenance": { "source": "repo-reviewed-seed", "origin": "textCrisisDetection.unit.test.ts PHRASES", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-case-variation", "class": "MUST_FIRE", "text": "Kill My Self",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects across case variation'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-trailing-punctuation", "class": "MUST_FIRE", "text": "i want to die.",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects with trailing punctuation'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-string-start", "class": "MUST_FIRE", "text": "suicidal thoughts again",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects at string start and string end'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-string-end", "class": "MUST_FIRE", "text": "honestly i feel suicidal",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects at string start and string end'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-smart-apostrophe", "class": "MUST_FIRE", "text": "i canβt stop thinking i want to die",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects with smart apostrophes in surrounding text'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mf-embedded-midsentence", "class": "MUST_FIRE", "text": "today was hard and end it all honestly",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects %p embedded mid-sentence'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
-
- { "id": "stt-no-word-boundary", "class": "STT_MANGLED", "text": "killmyself", "sourceId": "mf-kill-myself",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects when STT drops the word boundary entirely'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "stt-split-word-boundary", "class": "STT_MANGLED", "text": "kill my self", "sourceId": "mf-kill-myself",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts 'detects when STT drops the word boundary entirely'", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
-
- { "id": "mnf-made-it-through", "class": "MUST_NOT_FIRE", "text": "today was hard but i made it through",
+ {
+ "id": "mf-suicidal",
+ "class": "MUST_FIRE",
+ "text": "suicidal",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "textCrisisDetection.unit.test.ts PHRASES",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-kill-myself",
+ "class": "MUST_FIRE",
+ "text": "kill myself",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "textCrisisDetection.unit.test.ts PHRASES",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-end-it-all",
+ "class": "MUST_FIRE",
+ "text": "end it all",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "textCrisisDetection.unit.test.ts PHRASES",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-cannot-go-on",
+ "class": "MUST_FIRE",
+ "text": "cannot go on",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "textCrisisDetection.unit.test.ts PHRASES",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-want-to-die",
+ "class": "MUST_FIRE",
+ "text": "want to die",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "textCrisisDetection.unit.test.ts PHRASES",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-no-point-living",
+ "class": "MUST_FIRE",
+ "text": "no point living",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "textCrisisDetection.unit.test.ts PHRASES",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-case-variation",
+ "class": "MUST_FIRE",
+ "text": "Kill My Self",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects across case variation'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-trailing-punctuation",
+ "class": "MUST_FIRE",
+ "text": "i want to die.",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects with trailing punctuation'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-string-start",
+ "class": "MUST_FIRE",
+ "text": "suicidal thoughts again",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects at string start and string end'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-string-end",
+ "class": "MUST_FIRE",
+ "text": "honestly i feel suicidal",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects at string start and string end'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-smart-apostrophe",
+ "class": "MUST_FIRE",
+ "text": "i canβt stop thinking i want to die",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects with smart apostrophes in surrounding text'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "mf-embedded-midsentence",
+ "class": "MUST_FIRE",
+ "text": "today was hard and end it all honestly",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects %p embedded mid-sentence'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ },
+ "reachability": "in-vocabulary"
+ },
+ {
+ "id": "stt-no-word-boundary",
+ "class": "STT_MANGLED",
+ "text": "killmyself",
+ "sourceId": "mf-kill-myself",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects when STT drops the word boundary entirely'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ }
+ },
+ {
+ "id": "stt-split-word-boundary",
+ "class": "STT_MANGLED",
+ "text": "kill my self",
+ "sourceId": "mf-kill-myself",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts 'detects when STT drops the word boundary entirely'",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ }
+ },
+ {
+ "id": "mnf-made-it-through",
+ "class": "MUST_NOT_FIRE",
+ "text": "today was hard but i made it through",
"refutes": "Treating a hard-day / despair sentiment as a crisis signal. Ordinary difficulty is the modal journal entry; matching it would make the alert meaningless.",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mnf-dye-my-hair", "class": "MUST_NOT_FIRE", "text": "i want to dye my hair tomorrow",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts true negatives",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ }
+ },
+ {
+ "id": "mnf-dye-my-hair",
+ "class": "MUST_NOT_FIRE",
+ "text": "i want to dye my hair tomorrow",
"refutes": "A bare /die/ substring, and any phonetic or fuzzy matcher folding 'dye' to 'die'. The module rejects phonetic matching by name for exactly this case.",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mnf-meeting-ran-long", "class": "MUST_NOT_FIRE", "text": "the meeting ran long and i was tired",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts true negatives",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ }
+ },
+ {
+ "id": "mnf-meeting-ran-long",
+ "class": "MUST_NOT_FIRE",
+ "text": "the meeting ran long and i was tired",
"refutes": "Exhaustion or fatigue vocabulary as a proxy for hopelessness.",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
- { "id": "mnf-resisted-the-urge", "class": "MUST_NOT_FIRE", "text": "i resisted the urge to snap at him",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts true negatives",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ }
+ },
+ {
+ "id": "mnf-resisted-the-urge",
+ "class": "MUST_NOT_FIRE",
+ "text": "i resisted the urge to snap at him",
"refutes": "Treating 'urge' as a self-harm signal. This is a virtue-practice sentence and is exactly what the app asks users to write.",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts true negatives", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
-
- { "id": "kof-negated-want-to-die", "class": "KNOWN_OVER_FIRE", "text": "I donβt want to die",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts true negatives",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ }
+ },
+ {
+ "id": "kof-negated-want-to-die",
+ "class": "KNOWN_OVER_FIRE",
+ "text": "I donβt want to die",
"acceptedBecause": "Deliberate Slice A decision, pinned at textCrisisDetection.unit.test.ts 'MATCHES negated phrasing, intentionally'. Negation detection on lossy STT fails in the direction that matters: a recognizer dropping the negation turns a negated sentence into a disclosure. An extra support offer costs a dismissal; a missed disclosure is unrecoverable. Do NOT reclassify this as MUST_NOT_FIRE to improve the false-positive rate β that would reverse a crisis decision to move a number.",
- "provenance": { "source": "repo-reviewed-seed", "origin": "unit.test.ts documented over-trigger", "runDate": "2026-08-22" },
- "review": { "reviewer": "committed-in-repo", "reviewedOn": "2026-08-22", "verdict": "accepted" } },
-
- { "id": "mf-miss-killing-myself", "class": "MUST_FIRE", "text": "i keep thinking about killing myself",
+ "provenance": {
+ "source": "repo-reviewed-seed",
+ "origin": "unit.test.ts documented over-trigger",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "committed-in-repo",
+ "reviewedOn": "2026-08-22",
+ "verdict": "accepted"
+ }
+ },
+ {
+ "id": "mf-miss-killing-myself",
+ "class": "MUST_FIRE",
+ "text": "i keep thinking about killing myself",
"disposition": "pattern-candidate-deferred-to-crisis-pass",
"dispositionNote": "Gerund of the approved phrase `kill my self`, so the vocabulary decision is already made. But a widening (`kill\\w*\\s*my\\s*self`) newly matches the idiom βkilling myselfβ (at work / laughing / at the gym) β a real false-positive class the current pattern does not carry. Needs its own crisis ruling AND a paired MUST_NOT_FIRE item before anyone ships it.",
- "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
- "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
- { "id": "mf-miss-ending-it-all", "class": "MUST_FIRE", "text": "i have been thinking about ending it all",
+ "provenance": {
+ "source": "verified-miss",
+ "origin": "INFRA-512 packet Β§3, re-verified INFRA-523",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-25",
+ "verdict": "accepted"
+ },
+ "reachability": "morphological"
+ },
+ {
+ "id": "mf-miss-ending-it-all",
+ "class": "MUST_FIRE",
+ "text": "i have been thinking about ending it all",
"disposition": "pattern-candidate-deferred-to-crisis-pass",
"dispositionNote": "Same morphology break on the approved phrase `end it all`. βEnding it allβ is near-exclusively the suicidal idiom, so the false-positive delta is low β but it is still a widening, and packet Β§4 admits no automatic remedy.",
- "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
- "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
- { "id": "mf-miss-cant-go-on", "class": "MUST_FIRE", "text": "i cant go on anymore",
+ "provenance": {
+ "source": "verified-miss",
+ "origin": "INFRA-512 packet Β§3, re-verified INFRA-523",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-25",
+ "verdict": "accepted"
+ },
+ "reachability": "morphological"
+ },
+ {
+ "id": "mf-miss-cant-go-on",
+ "class": "MUST_FIRE",
+ "text": "i cant go on anymore",
"disposition": "pattern-candidate-deferred-to-crisis-pass",
"dispositionNote": "Highest-value, lowest-FP-delta of the six, and the odd one out: a NORMALIZER gap, not a vocabulary gap. The expanded form already fires, so the FP profile is unchanged by construction. Deferred anyway because normalizeForCrisisScan feeds EVERY pattern, so its blast radius is wider than any single pattern's β the opposite of what βlow riskβ usually implies.",
- "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
- "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
- { "id": "mf-miss-suicide", "class": "MUST_FIRE", "text": "suicide",
+ "provenance": {
+ "source": "verified-miss",
+ "origin": "INFRA-512 packet Β§3, re-verified INFRA-523",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-25",
+ "verdict": "accepted"
+ },
+ "reachability": "morphological"
+ },
+ {
+ "id": "mf-miss-suicide",
+ "class": "MUST_FIRE",
+ "text": "suicide",
"disposition": "pattern-candidate-deferred-to-crisis-pass",
"dispositionNote": "Most contested of the six. A `suicid` prefix is trivial, but on THIS surface the FP class is live: an app that shows 988 invites journal entries about the 988 line, about suicide prevention, and about articles read. Needs the ruling most and the widening least obviously.",
- "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
- "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
- { "id": "mf-miss-end-my-life", "class": "MUST_FIRE", "text": "i want to end my life",
+ "provenance": {
+ "source": "verified-miss",
+ "origin": "INFRA-512 packet Β§3, re-verified INFRA-523",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-25",
+ "verdict": "accepted"
+ },
+ "reachability": "morphological"
+ },
+ {
+ "id": "mf-miss-end-my-life",
+ "class": "MUST_FIRE",
+ "text": "i want to end my life",
"disposition": "pattern-candidate-deferred-to-crisis-pass",
"dispositionNote": "Genuinely new vocabulary. `end my life` is close to unambiguous, so this is the cleanest candidate β which is precisely why it must not be slipped in without the ruling that establishes the bar for adding vocabulary at all.",
- "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
- "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } },
- { "id": "mf-miss-wish-i-was-dead", "class": "MUST_FIRE", "text": "i wish i was dead",
+ "provenance": {
+ "source": "verified-miss",
+ "origin": "INFRA-512 packet Β§3, re-verified INFRA-523",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-25",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary"
+ },
+ {
+ "id": "mf-miss-wish-i-was-dead",
+ "class": "MUST_FIRE",
+ "text": "i wish i was dead",
"disposition": "pattern-candidate-deferred-to-crisis-pass",
"dispositionNote": "New vocabulary, carrying a hyperbole FP class (βI wish I was dead, that was so embarrassingβ) that the module's own KNOWN_OVER_FIRE reasoning would likely accept β but βlikely acceptβ is a ruling, not an assumption.",
- "provenance": { "source": "verified-miss", "origin": "INFRA-512 packet Β§3, re-verified INFRA-523", "runDate": "2026-08-22" },
- "review": { "reviewer": "MP2EZ", "reviewedOn": "2026-08-25", "verdict": "accepted" } }
+ "provenance": {
+ "source": "verified-miss",
+ "origin": "INFRA-512 packet Β§3, re-verified INFRA-523",
+ "runDate": "2026-08-22"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-25",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary"
+ },
+ {
+ "id": "mf-adv-hoping-wont-wake",
+ "class": "MUST_FIRE",
+ "text": "I keep hoping I just won't wake up tomorrow.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Fixed literal family `won't/never wake up`. FP class: sleep and alarm talk (\"hoping I won't wake up at 3am again\"), ordinary journal content. Needs a paired MUST_NOT_FIRE before shipping."
+ },
+ {
+ "id": "mf-adv-everyone-life-honestly",
+ "class": "MUST_FIRE",
+ "text": "Everyone in my life would honestly have an easier time if I weren't in it.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "Burdensomeness carried by the whole construction; the only candidate literal, \"if I weren't in it\", resolves \"it\" by anaphora to \"my life\" in the prior clause, which a substring matcher cannot do."
+ },
+ {
+ "id": "mf-adv-thinking-lot-being",
+ "class": "MUST_FIRE",
+ "text": "Been thinking a lot about not being here anymore.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "`not being here anymore` is a near-fixed death euphemism. FP class is live and identically shaped: the same phrase describes leaving a job, a city or a relationship."
+ },
+ {
+ "id": "mf-adv-wanna-around-whatever",
+ "class": "MUST_FIRE",
+ "text": "I don't wanna be around for whatever comes next.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Be around\" exists as a literal but does not carry the signal β \"I won't be around this weekend\" is a scheduling note. The disclosure lives in the whole frame."
+ },
+ {
+ "id": "mf-adv-nights-sit-here",
+ "class": "MUST_FIRE",
+ "text": "Some nights I sit here and seriously consider just ending it.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "morphological",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Gerund plus truncation of the approved `end\\s*it\\s*all`. Distinct from mf-miss-ending-it-all, which keeps \"all\": dropping \"all\" removes the disambiguating token, so `end\\w*\\s*it` newly matches ending a call, a relationship or a game. Materially harder β do not bundle the two."
+ },
+ {
+ "id": "mf-adv-idea-existing-started",
+ "class": "MUST_FIRE",
+ "text": "The idea of not existing has started to feel like a relief instead of a fear.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "`not existing` is bounded, but the FP class is elevated by this product specifically: Stoic mortality reflection invites exactly this phrasing in a practice journal. Needs a philosopher pass alongside the crisis ruling."
+ },
+ {
+ "id": "mf-adv-quietly-making-peace",
+ "class": "MUST_FIRE",
+ "text": "I've been quietly making peace with the idea of leaving for good.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "Signal requires conjoining \"leaving for good\" with \"making peace with\"; the detector is a disjunction of single patterns and cannot express conjunction. \"Leaving for good\" alone is relocation or resignation."
+ },
+ {
+ "id": "mf-adv-caught-myself-wishing",
+ "class": "MUST_FIRE",
+ "text": "I caught myself wishing a car would just take me out on the way home.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Take me out\" is dinner, a date, or a sports idiom. The disclosure is the wish plus an imagined external agent, which no literal carries."
+ },
+ {
+ "id": "mf-adv-honestly-terminal-diagnosis",
+ "class": "MUST_FIRE",
+ "text": "Honestly, if I got a terminal diagnosis tomorrow I think I'd feel lucky.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "Entirely counterfactual β a hypothetical diagnosis plus an affect report. No literal in the sentence is death-intent vocabulary."
+ },
+ {
+ "id": "mf-adv-sure-longer-want",
+ "class": "MUST_FIRE",
+ "text": "Not sure how much longer I want to keep doing this.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Keep doing this\" has an unbound referent; nothing in the sentence names life, death or harm. Weakest item in this stratum β see the contentious-label note in the audit doc."
+ },
+ {
+ "id": "mf-adv-tired-being-alive",
+ "class": "MUST_FIRE",
+ "text": "I'm so tired of being alive.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Cleanest candidate in the batch: `tired of being alive` is close to unambiguous. Do NOT generalise to bare `tired of living`, which matches \"tired of living in this apartment\". Compatible with mnf-meeting-ran-long, which refutes `tired` alone, not this."
+ },
+ {
+ "id": "mf-adv-kept-picturing-own",
+ "class": "MUST_FIRE",
+ "text": "Kept picturing my own funeral again tonight, and it was weirdly comforting.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "`my own funeral` is marked in journal prose, but the disclosure is really the affect clause (\"weirdly comforting\"). FP class: bereavement, estate planning, and again this app's own mortality reflection. Philosopher pass needed with the crisis ruling."
+ },
+ {
+ "id": "mf-adv-coming-back-thought",
+ "class": "MUST_FIRE",
+ "text": "I keep coming back to the thought that I could just stop.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Just stop\" has no object. The euphemism is recoverable by a human from the intrusiveness framing and by no literal."
+ },
+ {
+ "id": "mf-adv-urge-hurt-myself",
+ "class": "MUST_FIRE",
+ "text": "The urge to hurt myself came back tonight and I'm not fighting it as hard as I used to.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Structurally the `kill\\s*my\\s*self` template with a different verb, so the STT `\\s*` rationale carries over unchanged. FP class is common and physical: \"I hurt myself at the gym\". Requires a paired MUST_NOT_FIRE for accidental injury. mnf-resisted-the-urge does not conflict β the pattern would be on `hurt my self`, not `urge`."
+ },
+ {
+ "id": "mf-adv-wanting-disappear-entirely",
+ "class": "MUST_FIRE",
+ "text": "Wanting to disappear entirely isn't a new feeling for me, but it's louder now.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "`want to disappear` is ordinary shame and overwhelm language; the disclosure rests on \"entirely\" and the escalation clause. The label was contentious for the same reason the literal is weak."
+ },
+ {
+ "id": "mf-adv-started-thinking-life",
+ "class": "MUST_FIRE",
+ "text": "I've started thinking of my life as something with an expiration date I get to choose.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "A compositional metaphor (\"expiration date I get to choose\"). No death or harm token appears."
+ },
+ {
+ "id": "mf-adv-nothing-changes-spring",
+ "class": "MUST_FIRE",
+ "text": "If nothing changes by spring I don't think I'll still be here.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Still be here\" is polysemous (this job, this city); the conditional frame and the named timeframe carry the disclosure."
+ },
+ {
+ "id": "mf-adv-only-thats-kept",
+ "class": "MUST_FIRE",
+ "text": "The only thing that's kept me here is not wanting to wreck my family.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Kept me here\" is polysemous. The disclosure is the protective-factor structure β one named deterrent as the sole thing preventing death. Structure, not vocabulary."
+ },
+ {
+ "id": "mf-adv-giving-away-care",
+ "class": "MUST_FIRE",
+ "text": "I've been giving away things I care about and I know why I'm doing it.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "No death, harm or intent token appears anywhere. A recognised behavioural warning sign plus an asserted undisclosed reason β the clean demonstration that this stratum is not a vocabulary gap."
+ },
+ {
+ "id": "mf-adv-waiting-permission-done",
+ "class": "MUST_FIRE",
+ "text": "I feel like I'm just waiting for permission to be done.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"To be done\" is unbound and the sentence is otherwise ordinary. Nothing to match."
+ },
+ {
+ "id": "mf-adv-morning-first-thought",
+ "class": "MUST_FIRE",
+ "text": "Every morning the first thought is that I have to get through another one of these, and I'm tired enough to stop trying.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "Hopelessness expressed across a whole construction; \"stop trying\" is effort language and would match ordinary discouragement."
+ },
+ {
+ "id": "mf-adv-going-make-end",
+ "class": "MUST_FIRE",
+ "text": "I don't think I'm going to make it to the end of the year, and that's my own doing.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "Requires conjoining a survival prediction with an agency attribution (\"my own doing\") across clauses. Neither literal is death vocabulary alone."
+ },
+ {
+ "id": "mf-adv-being-dead-sounds",
+ "class": "MUST_FIRE",
+ "text": "Being dead sounds restful in a way nothing else does right now.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "`being dead` avoids most of what a bare `dead` would catch (\"dead tired\", \"dead to me\", \"my phone died\"). Same vocabulary family as the deferred mf-miss-wish-i-was-dead β rule the `dead` family ONCE covering both, not twice."
+ },
+ {
+ "id": "mf-adv-rehearsing-goodbye-conversations",
+ "class": "MUST_FIRE",
+ "text": "I keep rehearsing goodbye conversations in my head.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Goodbye\" is ordinary farewell vocabulary; the disclosure is the rehearsal framing. The label was contentious on the same ground."
+ },
+ {
+ "id": "mf-adv-hurting-myself-only",
+ "class": "MUST_FIRE",
+ "text": "Hurting myself is the only thing that makes the noise stop, and I want the noise to stop.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "new-vocabulary",
+ "disposition": "pattern-candidate-deferred-to-crisis-pass",
+ "dispositionNote": "Gerund of the mf-adv-urge-hurt-myself literal, and the exact failure mf-miss-killing-myself already recorded: author `hurt\\w*\\s*my\\s*self`, not `hurt\\s*my\\s*self`, or this item misses again. One pattern serves both; the gerund widens the gym-injury FP class further."
+ },
+ {
+ "id": "mf-adv-theres-plan-forming",
+ "class": "MUST_FIRE",
+ "text": "There's a plan forming and I'm not scared of it the way I should be.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"A plan\" is among the most common nouns in a journal. The highest-acuity item in the corpus β plan-level, with reduced fear β and it carries no matchable literal at all."
+ },
+ {
+ "id": "mf-adv-biggest-fear-used",
+ "class": "MUST_FIRE",
+ "text": "My biggest fear used to be dying. Now it's waking up.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "Inversion split across two sentences with an anaphoric \"it\". The only death token, \"dying\", sits in the NON-disclosing clause, so a bare `dying` pattern would match the wrong half."
+ },
+ {
+ "id": "mf-adv-started-drafting-letter",
+ "class": "MUST_FIRE",
+ "text": "Started drafting a letter to my sister that I hope she never has to read.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "Note-writing inferred from \"hope she never has to read\". No literal names death, harm, or a note."
+ },
+ {
+ "id": "mf-adv-going-problem-longer",
+ "class": "MUST_FIRE",
+ "text": "I'm not going to be a problem for much longer.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Be a problem\" is polysemous and the time horizon is generic; the disclosure is the two together."
+ },
+ {
+ "id": "mf-adv-thought-never-feeling",
+ "class": "MUST_FIRE",
+ "text": "The thought of never feeling anything again is the most attractive thing in my head right now.",
+ "provenance": {
+ "source": "model-drafted",
+ "model": "claude-opus-5",
+ "promptVersion": "infra-512-v1",
+ "origin": "buildPrompt(MUST_FIRE,40) rendered verbatim; clean-room chat session with no repo access. Prompt byte-identical to scripts/generate-crisis-corpus.js; transport differs (INFRA-554).",
+ "runDate": "2026-08-28"
+ },
+ "review": {
+ "reviewer": "MP2EZ",
+ "reviewedOn": "2026-08-28",
+ "verdict": "accepted"
+ },
+ "reachability": "out-of-reach-of-substring-matching",
+ "disposition": "out-of-reach-of-current-architecture",
+ "dispositionNote": "\"Never feeling anything again\" is also ordinary numbness and anhedonia journaling, which this surface invites. The superlative attraction clause converts it, and that is compositional."
+ }
]
}
diff --git a/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
index bda51a3e..eac5dccf 100644
--- a/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
+++ b/app/src/features/crisis/services/__tests__/textCrisisDetection.corpus.test.ts
@@ -49,6 +49,7 @@ interface CorpusItem {
acceptedBecause?: string;
disposition?: string;
dispositionNote?: string;
+ reachability?: string;
provenance: { source: string; origin?: string; model?: string; runDate: string };
review: { reviewer: string; reviewedOn: string; verdict: string };
}
@@ -133,6 +134,40 @@ describe('corpus integrity', () => {
expect(undispositioned.map((i) => i.id)).toEqual([]);
});
+ it('every MUST_FIRE item carries a reachability rating (INFRA-554)', () => {
+ const VALID = new Set([
+ 'in-vocabulary',
+ 'morphological',
+ 'new-vocabulary',
+ 'out-of-reach-of-substring-matching',
+ ]);
+ const bad = itemsOfClass('MUST_FIRE').filter(
+ (i) => !i.reachability || !VALID.has(i.reachability),
+ );
+ expect(bad.map((i) => i.id)).toEqual([]);
+ });
+
+ it('every MUST_FIRE item the detector misses carries a disposition (INFRA-554)', () => {
+ // ARRIVAL guard. The assertion above keys on PROVENANCE, which protects a recorded
+ // disposition from removal but never required one to ARRIVE: an item landing with any
+ // provenance.source other than 'verified-miss' could miss silently and undispositioned,
+ // which is exactly what the 30 model-drafted items would have done. This one keys on
+ // DETECTOR BEHAVIOUR, so no provenance string can dodge it. The pair is monotone β
+ // required on arrival by behaviour, never droppable once recorded. Neither thresholds
+ // a rate; packet Β§7's declined drift pin stays declined.
+ const undispositionedMisses = itemsOfClass('MUST_FIRE').filter(
+ (i) => !fires(i.text) && !i.disposition,
+ );
+ expect(undispositionedMisses.map((i) => i.id)).toEqual([]);
+ });
+
+ it('every disposition carries a per-item note', () => {
+ // With thirty dispositions arriving at once, a bulk copy-pasted string is the realistic
+ // failure mode. A required per-item note is what makes that visible in review.
+ const unnoted = corpus.items.filter((i) => i.disposition && !i.dispositionNote);
+ expect(unnoted.map((i) => i.id)).toEqual([]);
+ });
+
it('every MUST_NOT_FIRE item names the widening it refutes', () => {
// Without this, the class fills with generic clean prose that refutes nothing and
// the false-positive rate stops being adversarial.
@@ -160,30 +195,57 @@ describe('anti-narrowing anchor set', () => {
});
describe('measurement β reported, never thresholded (AC5)', () => {
- it('reports MUST-FIRE recall, MUST-NOT-FIRE false-positive rate, and STT-MANGLED recall separately', () => {
+ it('reports MUST-FIRE recall per reachability stratum, never blended', () => {
const mustFire = itemsOfClass('MUST_FIRE');
const mustNotFire = itemsOfClass('MUST_NOT_FIRE');
const sttMangled = itemsOfClass('STT_MANGLED');
const knownOverFire = itemsOfClass('KNOWN_OVER_FIRE');
- const mustFireHits = mustFire.filter((i) => fires(i.text));
const mustNotFireHits = mustNotFire.filter((i) => fires(i.text));
const sttHits = sttMangled.filter((i) => fires(i.text));
const pct = (n: number, d: number) => (d === 0 ? 'n/a' : `${((n / d) * 100).toFixed(1)}%`);
+ const rate = (items: CorpusItem[]) => {
+ const hits = items.filter((i) => fires(i.text)).length;
+ return `${hits}/${items.length} ${pct(hits, items.length)}`;
+ };
+ const stratum = (r: string) => mustFire.filter((i) => i.reachability === r);
+ const addressable = mustFire.filter(
+ (i) => i.reachability === 'morphological' || i.reachability === 'new-vocabulary',
+ );
- // KNOWN_OVER_FIRE is excluded from the false-positive denominator on purpose: it is
- // accepted behaviour, and folding it in would let a narrowing "improve" the rate by
- // reversing a crisis decision.
+ // INFRA-554. There is deliberately NO blended MUST-FIRE figure, and re-adding one
+ // would undo this item. The strata are not commensurable: the seed cannot fail and the
+ // out-of-reach stratum cannot pass, so an average describes neither and moves only with
+ // which items happen to be present. A single number also presents a single deficit, and
+ // a single deficit admits exactly one remedy β widening CRISIS_TEXT_PATTERN_SOURCES,
+ // which feeds journalCrisisScanner.scan -> showCrisisAlert() on a journaling surface.
+ // The strata are what answer "widen what?" honestly.
+ //
+ // KNOWN_OVER_FIRE stays out of the false-positive denominator on purpose: it is accepted
+ // behaviour, and folding it in would let a narrowing "improve" the rate by reversing a
+ // crisis decision.
// eslint-disable-next-line no-console
console.log(
[
`\n INFRA-512 corpus ${corpus.corpusVersion}`,
- ` MUST-FIRE recall: ${mustFireHits.length}/${mustFire.length} ${pct(mustFireHits.length, mustFire.length)}`,
- ` MUST-NOT-FIRE false-positive: ${mustNotFireHits.length}/${mustNotFire.length} ${pct(mustNotFireHits.length, mustNotFire.length)}`,
- ` STT-MANGLED recall: ${sttHits.length}/${sttMangled.length} ${pct(sttHits.length, sttMangled.length)}`,
- ` KNOWN_OVER_FIRE (accepted): ${knownOverFire.filter((i) => fires(i.text)).length}/${knownOverFire.length}`,
- ` MUST-FIRE misses: ${mustFire.filter((i) => !fires(i.text)).map((i) => i.id).join(', ') || 'none'}`,
+ '',
+ ' MUST-FIRE recall β per reachability stratum. NOT blended; the strata are not commensurable.',
+ ` in-vocabulary ${rate(stratum('in-vocabulary'))}`,
+ ` morphological ${rate(stratum('morphological'))}`,
+ ` new-vocabulary ${rate(stratum('new-vocabulary'))}`,
+ ` out-of-reach-of-substring-matching ${rate(stratum('out-of-reach-of-substring-matching'))}`,
+ ' ^ not addressable by CRISIS_TEXT_PATTERN_SOURCES.',
+ ' A change to that constant cannot move this figure.',
+ '',
+ ` addressable (morphological + new-vocabulary) ${rate(addressable)}`,
+ ` ^ the only figure a widening can move. ${addressable.length} items, each with a named FP class.`,
+ '',
+ ` MUST-NOT-FIRE false-positive: ${mustNotFireHits.length}/${mustNotFire.length} ${pct(mustNotFireHits.length, mustNotFire.length)}`,
+ ` STT-MANGLED recall: ${sttHits.length}/${sttMangled.length} ${pct(sttHits.length, sttMangled.length)}`,
+ ` KNOWN_OVER_FIRE (accepted): ${knownOverFire.filter((i) => fires(i.text)).length}/${knownOverFire.length}`,
+ '',
+ ` MUST-FIRE misses: ${mustFire.filter((i) => !fires(i.text)).map((i) => i.id).join(', ') || 'none'}`,
].join('\n'),
);
diff --git a/app/src/features/crisis/services/textCrisisDetection.ts b/app/src/features/crisis/services/textCrisisDetection.ts
index 8a29398f..beece934 100644
--- a/app/src/features/crisis/services/textCrisisDetection.ts
+++ b/app/src/features/crisis/services/textCrisisDetection.ts
@@ -40,7 +40,13 @@
* THE GUARANTEE IS PRECISION, NOT RECALL. What this module guarantees is that a
* small, fixed, hand-approved vocabulary matches deterministically, in linear
* time, without leaking content. It does NOT guarantee that every disclosure
- * matches. Recall is UNMEASURED, and three miss classes are verified
+ * matches. Recall against a REPRESENTATIVE distribution of journal entries
+ * remains UNMEASURED. Against an adversarial corpus it is now MEASURED, per
+ * reachability stratum (INFRA-554, 2026-08-28): 12/12 on the repo-reviewed seed,
+ * and 0/30 on thirty phrasings drafted blind to this pattern list. Twenty-one of
+ * those thirty are carried by no fixed literal at all, so no widening of this
+ * pattern set can reach them β that is a stated ceiling, not a backlog. Read the
+ * strata, never a blended rate. Three miss classes are verified
* (INFRA-512 Β§3): morphological variants of approved phrases ("killing myself"
* against `kill\s*my\s*self`), contractions the normalizer does not expand
* ("cant" against `can\s*not`), and phrasings no pattern covers at all ("i wish
@@ -57,7 +63,7 @@
* it renders only once this scan has already fired.
*
* Recall is RECORDED, not thresholded, by `__tests__/textCrisisDetection.corpus.test.ts`
- * against `docs/development/audits/INFRA-512-corpus-review-packet-2026-08-22.md`.
+ * against `docs/development/audits/INFRA-554-corpus-baseline-2026-08-28.md`.
* Widening the pattern set is NOT the remedy β see `CRISIS_TEXT_PATTERN_SOURCES`.
*/
diff --git a/docs/development/audits/INFRA-554-corpus-baseline-2026-08-28.md b/docs/development/audits/INFRA-554-corpus-baseline-2026-08-28.md
new file mode 100644
index 00000000..d50ce1ae
--- /dev/null
+++ b/docs/development/audits/INFRA-554-corpus-baseline-2026-08-28.md
@@ -0,0 +1,185 @@
+# INFRA-554 β Adversarial crisis-corpus baseline (2026-08-28)
+
+Successor to `INFRA-523-corpus-baseline-2026-08-25.md`. Supersedes its figures; does not
+supersede its reasoning, which still stands.
+
+**The console figures are not the record β this document is.** The harness runs `--silent`
+under `test:crisis-quick`.
+
+## 1. What this measures, and what it does not
+
+`detectCrisisInText` recall against **30 adversarial phrasings the detector had never seen**,
+model-drafted blind and human-reviewed item by item.
+
+It is **not** a measure of recall against real journal entries. The generator brief asked for
+"hedged and indirect phrasings", so the batch is deliberately weighted toward the euphemistic
+tail. Recall against a representative distribution remains **UNMEASURED**.
+
+## 2. The figures
+
+```
+INFRA-512 corpus v3-seed-verified-misses-adversarial-batch-1
+
+MUST-FIRE recall β per reachability stratum. NOT blended; strata are not commensurable.
+ in-vocabulary 12/12 100.0%
+ morphological 0/5 0.0%
+ new-vocabulary 0/10 0.0%
+ out-of-reach-of-substring-matching 0/21 0.0%
+ ^ not addressable by CRISIS_TEXT_PATTERN_SOURCES.
+ A change to that constant cannot move this figure.
+
+ addressable (morphological + new-vocabulary) 0/15 0.0%
+ ^ the only figure a widening can move. 15 items, each with a named FP class.
+
+MUST-NOT-FIRE false-positive: 0/4 0.0%
+STT-MANGLED recall: 2/2 100.0%
+KNOWN_OVER_FIRE (accepted): 1/1
+```
+
+**Which stratum each figure covers:**
+
+| Stratum | n | Covers | Can a widening move it? |
+|---|---|---|---|
+| `in-vocabulary` | 12 | Repo-reviewed seed, drawn verbatim from strings already committed in `textCrisisDetection.unit.test.ts`. **Cannot fail by construction.** | n/a β already 100% |
+| `morphological` | 5 | Bounded morphological/normalizer variants of approved phrases. | Yes |
+| `new-vocabulary` | 10 | Distinct literal phrases a bounded new pattern would catch. | Yes |
+| `out-of-reach-of-substring-matching` | 21 | Genuine disclosures whose signal is compositional or inferential, carried by no fixed literal. | **No.** This is a stated ceiling. |
+
+**There is deliberately no blended MUST-FIRE figure.** The previous corpus reported
+`12/18 66.7%`; adding 30 items would have made that `12/48 25.0%`. Both are artefacts of
+corpus composition, not measurements β the numerator has been frozen at 12 since the seed
+was committed. Read naively, "25%" suggests the detector catches a quarter of disclosures.
+The truth is bimodal and the average describes neither half: **100% on phrasings already in
+the repo, 0% on thirty it had never seen.**
+
+## 3. The finding
+
+**The vocabulary gap is real but bounded at 15 items. The architectural ceiling is 21 items
+and is the larger finding.**
+
+No addition to `CRISIS_TEXT_PATTERN_SOURCES` reaches
+*"I've been giving away things I care about and I know why I'm doing it"* β it contains no
+death, harm, or intent token anywhere. Nor
+*"There's a plan forming and I'm not scared of it the way I should be"*, the highest-acuity
+item in the corpus. That is what the fourth stratum records, and it is why a single blended
+figure was refused: one number presents one deficit, and one deficit admits exactly one
+remedy β widen the patterns. That remedy cannot reach 21 of the 30.
+
+`CRISIS_TEXT_PATTERN_SOURCES` feeds `journalCrisisScanner.scan` β `showCrisisAlert()` on a
+journaling surface, where a false alarm degrades the response to true positives. **No
+widening is mandated by this document.**
+
+## 4. Provenance β and a deviation from AC1 as written
+
+AC1 specified generation via `node scripts/generate-crisis-corpus.js` with an
+`ANTHROPIC_API_KEY`. **No key exists in this environment** (re-verified 2026-08-28; the
+generator's fail-closed path was exercised and confirmed to exit rather than degrade to an
+empty corpus).
+
+Generation instead used `buildPrompt('MUST_FIRE', 40)` **rendered verbatim** and run in a
+clean-room chat session with no repository access. The prompt is byte-identical to what the
+script sends β the script builds a single user message with no system prompt, and that
+prompt never references the pattern list. Only the transport differs. Every item records
+this in `provenance.origin`.
+
+What is preserved: prompt version `infra-512-v1`, generator blindness to the matcher, no
+`@anthropic-ai/*` package anywhere near `app/package.json`, candidates written outside the
+repo. What is lost: the API's `output_config: { effort: 'high' }`, which has no chat
+equivalent.
+
+## 5. Review
+
+40 candidates β **30 accepted**, 2 rejected on label, 8 rejected on hygiene.
+Reviewer `MP2EZ`, 2026-08-28. Two passes, labels locked between them.
+
+- **Pass 1 (labels, pattern list closed).** `crisis` ruled per item; the reviewer held final
+ say. 8 of 9 CONTENTIOUS rulings accepted. **One overruled**: `[34]` *"you don't have to
+ keep doing this to yourself"* β DROP, on the ground that it reads equally as a
+ self-compassion realisation β a reading this app's own practice content actively
+ cultivates. `crisis` had flagged its own inverse bias on that row and nominated it as the
+ most defensible overrule.
+- **Pass 1 hygiene.** 8 near-duplicates collapsed to representatives. Note `[32]` survived
+ labelling and exited on hygiene β it is genuine ground truth, but duplicates `[14]`'s
+ construct, and keeping both would double-count one judgment call.
+- **Pass 2 (reachability + disposition, pattern list open).** All 30 dispositioned, each
+ with a per-item note naming either the FP class a widening would introduce or why no
+ literal carries the signal.
+
+### Contamination disclosures
+
+Both are recorded because a review's value rests on the discipline of the process, not on
+the outcome looking clean.
+
+1. **Reviewer-side, by the operator.** Before Pass 1 was defined, the operator told the
+ reviewer which phrases looked reachable (`ending it`, `hurt myself`, `hurting myself`)
+ and anchored an expected rate. This violates the blind-label rule. Assessed as low
+ impact β it touches 4 of 40 items, all independently ruled uncontentious β but it is a
+ real deviation and is not excused by the outcome.
+2. **Specialist-side, self-flagged.** `crisis` had read the pattern list before Pass 1 and
+ flagged three rows where that knowledge pulled on a label: `[12]` and `[5]` toward
+ SETTLED, `[34]` inversely toward DROP. It resisted all three and rated on text alone.
+
+### Recorded concern
+
+All seven accepted-contentious labels landed in the out-of-reach stratum, so that stratum is
+14 settled + 7 contentious rather than 21 equally-firm items. The finding is unchanged at 14
+β the ceiling still exceeds the addressable gap β but a future reviewer overturning any of
+those labels will move the stratum, and that must not be read as detector drift. If a firmer
+statement of the ceiling is wanted, `mf-adv-sure-longer-want` `[11]` is the first to
+re-examine: its referent is wholly unbound.
+
+## 6. Enforcement β a gap found and closed
+
+The existing integrity assertion required a `disposition` only for
+`provenance.source === 'verified-miss'`. The 30 new items carry `model-drafted`, so **AC5
+would have been honour-system**. The script's own stamp (`anthropic-api`) would have hit the
+identical gap, so this is not an artefact of the clean-room path.
+
+Fixed with a **second** assertion rather than by renaming the provenance value, which would
+have made the guard depend on a string any future generator run can change:
+
+- **Arrival guard (new, behaviour-keyed).** Every `MUST_FIRE` item the detector misses must
+ carry a non-empty `disposition`. Provenance-blind, so no source string dodges it.
+- **Retention guard (existing, provenance-keyed, unchanged).** Protects a recorded
+ disposition from removal once a widening turns a miss into a hit.
+
+The pair is monotone: required on arrival by behaviour, never droppable once recorded. Plus
+a `reachability`-required guard and a per-item `dispositionNote` guard.
+
+**All three new guards were mutation-verified before landing:** each mutation reds exactly
+one test (`1 failed, 19 passed`), and restore returns `20 passed`. Exactly-one is the point β
+it proves the guards do not conflate mechanisms.
+
+**Still no CI threshold.** AC5 of the grandparent stands, and packet Β§7's drift pin remains
+DECLINED. None of the four assertions thresholds a rate.
+
+## 7. Follow-ups this produces β none actioned here
+
+1. **15 addressable items β pattern-candidate rulings.** Each carries a named FP class.
+ `pattern-candidate-deferred-to-crisis-pass` means *a widening is plausible enough to
+ require a ruling*, never *widen*. Group by vocabulary family, not per item:
+ - `hurt my self` family β must be authored `hurt\w*\s*my\s*self`, not `hurt\s*my\s*self`,
+ or `mf-adv-hurting-myself-only` misses again. This is the exact failure
+ `mf-miss-killing-myself` already recorded. FP class: "I hurt myself at the gym."
+ - `dead` family β covers `mf-adv-being-dead-sounds` and the deferred
+ `mf-miss-wish-i-was-dead`. Rule once, not twice.
+ - `tired of being alive` β cleanest candidate in the batch. Do **not** generalise to
+ `tired of living`, which matches "tired of living in this apartment."
+ - `end it` truncation β materially harder than `mf-miss-ending-it-all` and must not be
+ bundled with it: dropping "all" removes the disambiguating token.
+2. **Two items need a `philosopher` pass alongside the `crisis` ruling.**
+ `mf-adv-idea-existing-started` ("not existing") and `mf-adv-kept-picturing-own` ("my own
+ funeral") name phrasings that **Stoic mortality reflection actively cultivates in this
+ app's own practice journal**. A widening ruled sound on crisis grounds alone would fire
+ on the app's prescribed exercise.
+3. **A `--class MUST_NOT_FIRE` run is owed.** This batch was MUST_FIRE-only and contributes
+ nothing to the false-positive denominator, which remains 4 items. Every pattern candidate
+ above needs a paired MUST_NOT_FIRE before shipping.
+4. **Carried from INFRA-523, still open.** `premeditationSafetyService.ts`'s private
+ `CRISIS_KEYWORDS` is matched with plain `includes()` against literal `'kill myself'` β it
+ misses everything here plus `kill my self` and `killmyself`. Wholly unmeasured; the parity
+ guard pins only subset-ness and structurally cannot see this.
+5. **`accepted-miss-mitigated-elsewhere` remains unavailable for every item.** INFRA-523 Β§3
+ set a two-part bar. Part (b) β no plausible bounded widening β now passes for the first
+ time on the out-of-reach stratum. Part (a) still fails: DEBUG-506 leaves the root crisis
+ button unreachable keyboard-up, which is the `scanOnSave` state. Half a bar is not the bar.
From 9eafc038a2202d6bb1514af5656ff5b1ab85ddc3 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:15:16 -0700
Subject: [PATCH 35/90] fix: DEBUG-557 pin the consent-grant navigator remount
(AC-1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Granting analytics consent swaps PostHogProvider's returned element type from
a bare fragment to , so React deletes and recreates the entire
subtree below it β SafeAreaProvider, RootCrisisBoundary, CleanRootNavigator and
RootCrisisButton included. A referentially identical `children` element does
not rescue it.
This is AC-1 only: a test-only characterization pin. The fix, the on-device
consequence (AC-2) and the RootCrisisButton unmount-window measurement (AC-3)
are tracked in DEBUG-559.
The pin is built so it cannot falsely exonerate. __tests__/setup/env.mock.js
sets EXPO_PUBLIC_POSTHOG_API_KEY to '' for every jest run and the provider
reads it at module scope, so without an override BOTH consent states render the
fragment branch, a mount counter reads 1, and the file reports "it does not
remount" β which under AC-1's own wording would close a live defect. Verified
by mutation: restoring the empty key red-lines the finding at the
branch-entered control ("Unable to find testID debug557-ph-provider-branch")
with the probe still reading 'dirty', i.e. exactly the false negative.
Five controls bracket the result: the counter fires at all; the PHProvider
branch was actually entered; the selector paths production reads are the ones
set; the subscription propagated; and a negative-control case under a stable
element type reports mounts === 1 with state preserved, proving the harness can
report both outcomes.
Renders the real at-risk subtree (RootCrisisBoundary + RootCrisisButton) rather
than a synthetic counter, so the pin measures the components actually at risk.
Also records, for DEBUG-559: a stable `key` does NOT prevent the remount β
element-type mismatch dominates key in reconciliation, measured on both the
children wrapper and the returned root. Only the always-render-same-type shape
works.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Spotj3yTn89a7rXLDEFyP3
---
app/.eslint-baseline.json | 3 +-
...ogProvider.consentRemount.privacy.test.tsx | 286 ++++++++++++++++++
2 files changed, 288 insertions(+), 1 deletion(-)
create mode 100644 app/src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index 71fb105f..a247ca3d 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -224,5 +224,6 @@
"src/core/theme/__tests__/black-call-sites.accessibility.test.ts": 1,
"src/features/journal/services/__tests__/journalPreview.unit.test.ts": 1,
"src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts": 1,
- "src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts": 1
+ "src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts": 1,
+ "src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx": 1
}
diff --git a/app/src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx b/app/src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx
new file mode 100644
index 00000000..dc3d1791
--- /dev/null
+++ b/app/src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx
@@ -0,0 +1,286 @@
+/**
+ * DEBUG-557 β granting analytics consent remounts the whole subtree below
+ * , including the root crisis affordance.
+ *
+ * WHAT THIS FILE SETTLES, AND WHAT IT DELIBERATELY DOES NOT
+ * --------------------------------------------------------
+ * `PostHogProvider` returns a bare fragment when analytics is off and
+ * `` when it is on. That is an element-TYPE change at a fixed
+ * position, so React deletes and recreates the entire subtree rather than
+ * reconciling it β `children` being a referentially identical element does not
+ * rescue it. This file pins that reconciliation fact and the state loss that
+ * follows from it.
+ *
+ * It does NOT establish the on-device consequence, nor measure how long
+ * RootCrisisButton is absent. Both need a device and a Release build and are
+ * owned by DEBUG-559.
+ *
+ * WHY THE ENV MOCK BELOW IS LOAD-BEARING, NOT SETUP NOISE
+ * ------------------------------------------------------
+ * `__tests__/setup/env.mock.js` sets EXPO_PUBLIC_POSTHOG_API_KEY to '' for every
+ * jest run, and PostHogProvider reads it at MODULE SCOPE. Without the override,
+ * the early-return guard is permanently true and BOTH consent states render the
+ * fragment branch β a mount counter then reads 1 and the file reports "it does
+ * not remount". Under this item's AC-1 that reading closes a live defect. The
+ * override is therefore the difference between a pin and a false exoneration,
+ * and the branch-entered control below is what proves the override took.
+ *
+ * `process.env` assignment cannot substitute: babel hoists the imports above any
+ * statement, so the module-scope read happens first. Editing env.mock.js cannot
+ * substitute either β it is a global setupFile and would switch the PHProvider
+ * branch on for every suite in the repo.
+ */
+
+// ---------------------------------------------------------------------------
+// Env: give the provider a non-placeholder key so the consent flip can actually
+// cross the conditional. Spread requireActual so every other validated var and
+// export stays real.
+// ---------------------------------------------------------------------------
+jest.mock('@/core/config/env', () => {
+ const actual = jest.requireActual('@/core/config/env');
+ return {
+ ...actual,
+ env: {
+ ...actual.env,
+ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_debug557_mount_pin',
+ EXPO_PUBLIC_POSTHOG_HOST: 'https://eu.i.posthog.com',
+ },
+ };
+});
+
+// ---------------------------------------------------------------------------
+// posthog-react-native: a passthrough with a DISTINCT element type. Faithful for
+// this question β the remount is caused by the type at the return position
+// changing from React.Fragment to PHProvider, independent of what PHProvider
+// renders internally. The testID is how the branch-entered control observes it.
+// ---------------------------------------------------------------------------
+const PH_BRANCH_TEST_ID = 'debug557-ph-provider-branch';
+jest.mock('posthog-react-native', () => {
+ const ReactActual = require('react');
+ const { View } = require('react-native');
+ return {
+ __esModule: true,
+ PostHogProvider: ({ children }: { children: React.ReactNode }) =>
+ ReactActual.createElement(View, { testID: 'debug557-ph-provider-branch' }, children),
+ usePostHog: () => null,
+ };
+});
+
+// ---------------------------------------------------------------------------
+// The real crisis subtree's own dependencies. Mirrors RootCrisisButton.test.tsx
+// so the at-risk components can render for real without dragging in the
+// animation-driven button or the navigation container.
+// ---------------------------------------------------------------------------
+jest.mock('@/features/crisis/components/CollapsibleCrisisButton', () => {
+ const ReactActual = require('react');
+ const { Text } = require('react-native');
+ const Stub = (props: { testID?: string; mode?: string }) =>
+ ReactActual.createElement(Text, { testID: props.testID }, `mode:${props.mode}`);
+ return { __esModule: true, CollapsibleCrisisButton: Stub, default: Stub };
+});
+
+jest.mock('@/features/crisis/utils/openCrisisUrl', () => ({
+ openCrisisUrl: jest.fn(),
+}));
+
+jest.mock('@/core/navigation/navigationRef', () => ({
+ navigationRef: { isReady: () => true, navigate: jest.fn() },
+ getActiveRootRouteName: jest.fn(),
+}));
+
+import React from 'react';
+import { render, act } from '@testing-library/react-native';
+import { Text } from 'react-native';
+
+import { PostHogProvider } from '../PostHogProvider';
+import { useConsentStore } from '@/core/stores/consentStore';
+import RootCrisisBoundary from '@/features/crisis/components/RootCrisisBoundary';
+import {
+ RootCrisisButton,
+ ROOT_CRISIS_BUTTON_TEST_ID,
+} from '@/features/crisis/components/RootCrisisButton';
+
+// ---------------------------------------------------------------------------
+// Mount accounting. Module-scoped so it survives the subtree being destroyed β
+// which is the entire point.
+// ---------------------------------------------------------------------------
+let mounts = 0;
+let unmounts = 0;
+let renders = 0;
+let setProbeValue: ((v: string) => void) | null = null;
+
+const PROBE_TEST_ID = 'debug557-probe';
+
+/**
+ * Wraps the real at-risk subtree. The `useState` is the state-loss instrument:
+ * component state cannot survive an unmount, so driving it to 'dirty' before the
+ * consent flip and reading 'initial' afterwards demonstrates destruction rather
+ * than a re-render. That is the same mechanism by which OnboardingScreen's
+ * in-progress screen state is lost.
+ */
+function MountProbe({ children }: { children: React.ReactNode }): React.ReactElement {
+ const [value, setValue] = React.useState('initial');
+ renders += 1;
+ setProbeValue = setValue;
+
+ React.useEffect(() => {
+ mounts += 1;
+ return () => {
+ unmounts += 1;
+ };
+ }, []);
+
+ return (
+ <>
+ {value}
+ {children}
+ >
+ );
+}
+
+function resetCounters(): void {
+ mounts = 0;
+ unmounts = 0;
+ renders = 0;
+ setProbeValue = null;
+}
+
+/**
+ * The provider reads exactly two paths off the store:
+ * currentConsent?.preferences?.analyticsEnabled
+ * currentConsent?.universalOptOut
+ * Both are optional-chained, so a literal carrying just those is a faithful
+ * stand-in and keeps this pin decoupled from unrelated ConsentRecord schema
+ * churn. The selector control below asserts these are the paths production reads.
+ */
+function setAnalyticsConsent(enabled: boolean): void {
+ useConsentStore.setState({
+ currentConsent: {
+ preferences: { analyticsEnabled: enabled },
+ universalOptOut: false,
+ },
+ } as unknown as Parameters[0]);
+}
+
+function renderSubtree() {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('DEBUG-557 β consent grant and subtree reconciliation', () => {
+ beforeEach(() => {
+ resetCounters();
+ useConsentStore.setState({ currentConsent: null } as unknown as Parameters<
+ typeof useConsentStore.setState
+ >[0]);
+ });
+
+ describe('the finding', () => {
+ it('destroys and recreates the subtree β including the root crisis button β when consent is granted', () => {
+ const { getByTestId, queryByTestId } = renderSubtree();
+
+ // ββ Control 1: the counter fires at all. If a mock swallowed `children`,
+ // this fails loudly instead of letting `mounts === 1` read as a pass.
+ expect(mounts).toBe(1);
+ expect(unmounts).toBe(0);
+ expect(renders).toBeGreaterThanOrEqual(1);
+ expect(getByTestId(ROOT_CRISIS_BUTTON_TEST_ID)).toBeTruthy();
+
+ // ββ Control 2: we start in the fragment branch.
+ expect(queryByTestId(PH_BRANCH_TEST_ID)).toBeNull();
+
+ // Dirty the probe's state so its loss is observable.
+ act(() => setProbeValue?.('dirty'));
+ expect(getByTestId(PROBE_TEST_ID).props.children).toBe('dirty');
+
+ const rendersBeforeToggle = renders;
+
+ act(() => setAnalyticsConsent(true));
+
+ // ββ Control 3: the selector paths production reads are the ones we set.
+ // Pins the literal property path, so a rename breaks this test rather
+ // than silently making it vacuous.
+ const consent = useConsentStore.getState().currentConsent;
+ expect(consent?.preferences?.analyticsEnabled).toBe(true);
+ expect(consent?.universalOptOut).toBe(false);
+
+ // ββ Control 4: the toggle actually CROSSED the conditional. This is the
+ // control that catches the empty-API-key false negative; without it a
+ // "no remount" result would be indistinguishable from a mis-set store.
+ expect(getByTestId(PH_BRANCH_TEST_ID)).toBeTruthy();
+
+ // ββ Control 5: the subscription fired and propagated. Separates a genuine
+ // negative from a store that never notified.
+ expect(renders).toBeGreaterThan(rendersBeforeToggle);
+
+ // ββ The finding: a real destroy/create, not a re-render.
+ expect(unmounts).toBe(1);
+ expect(mounts).toBe(2);
+
+ // ββ The user-visible consequence, as a property rather than a count:
+ // in-progress component state is gone.
+ expect(getByTestId(PROBE_TEST_ID).props.children).toBe('initial');
+
+ // The crisis button is present again AFTER remount β the exposure is the
+ // window, not the end state. Measuring that window is DEBUG-559's job.
+ expect(getByTestId(ROOT_CRISIS_BUTTON_TEST_ID)).toBeTruthy();
+ });
+ });
+
+ /**
+ * DEBUG-390 discipline: a pin that can only ever report "remounted" is
+ * indistinguishable from a rigged counter. This runs the IDENTICAL harness
+ * against a provider whose returned element type is STABLE across the same
+ * consent flip, and asserts the counters report the opposite. It brackets the
+ * finding from both sides and doubles as a zero-risk preview of the only fix
+ * shape that works.
+ */
+ describe('negative control β the harness can report "no remount"', () => {
+ function StableShapeProvider({
+ children,
+ }: {
+ children: React.ReactNode;
+ }): React.ReactElement {
+ // Subscribes to the same store slice so the flip drives a re-render here
+ // too, but the returned element type never changes.
+ const analyticsEnabled = useConsentStore(
+ (s) => s.currentConsent?.preferences?.analyticsEnabled ?? false,
+ );
+ const PH = require('posthog-react-native').PostHogProvider;
+ return {children} ;
+ }
+
+ it('reports mounts === 1 and preserves state when the element type is stable', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(mounts).toBe(1);
+ const rendersBeforeToggle = renders;
+
+ act(() => setProbeValue?.('dirty'));
+ act(() => setAnalyticsConsent(true));
+
+ // Same propagation control as above β proves the flip was observed here
+ // too, so `mounts === 1` means "did not remount", not "nothing happened".
+ expect(renders).toBeGreaterThan(rendersBeforeToggle);
+
+ expect(unmounts).toBe(0);
+ expect(mounts).toBe(1);
+ expect(getByTestId(PROBE_TEST_ID).props.children).toBe('dirty');
+ });
+ });
+});
From ea082e89c5876e644c1f529791431f4b4a263f1c Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:29:11 -0700
Subject: [PATCH 36/90] chore: INFRA-558 record how the PHIFilter whitelist is
amended, and enforce it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three of this item's five ACs were written against premises the code contradicts.
They are corrected here rather than implemented as written.
AC4 is false. FEAT-457 (bb70cb87, 2026-08-21) is an ANCESTOR of the frozen
d14d6178 (2026-08-24), so `guidance_opened` is inside the snapshot and was never
a post-freeze addition. There is no precedent; this invents the procedure.
AC3's "yes" branch is impossible. A benign CORPUS case for a newly-whitelisted
event turns the differential RED by construction β validateV1 rejects the
unknown name, live accepts, and the one-sided relation fires. The ledger is an
exempted channel for exactly that reason.
AC5 overstated the unblocking: FEAT-538 is blocked by this item alone, but
DEBUG-540 is also blocked by DEBUG-557 and FEAT-537 by DEBUG-514.
The title's premise is also a category error: the frozen baseline is NEVER
amended. Editing it to track live restores the compare-to-itself failure its own
rule 1 exists to prevent. What gets amended is the registered DELTA.
Scope was widened past the body's "not a behaviour change" note, on the founder's
call at batch approval. A doc-only version would have described a control that
does not exist: nothing anywhere read the live whitelist's MEMBERSHIP, so an
event type could be added with no test noticing. Verified by mutation β adding a
name to live SAFE_EVENT_TYPES passed every test in the repo before this change
and red-lines the new assertion after it.
What lands:
- A WIDENED ledger (shipped empty, correctly: live and baseline are both the
same 25 names) plus an assertion that live == baseline βͺ ledger, in both
directions. Gives getWhitelistedEvents() its first caller.
- A DEBUG-390 control proving the membership matcher still fires, written over
synthetic sets so it stays independent of the test it controls.
- The procedure itself, in the differential's header β stating plainly that the
harness verifies a widening was DECLARED, never that it was WARRANTED.
- Rule 4 on the frozen baseline: never amended, size pin never bumped.
- The stale four-step "Adding New Events" list in analytics-architecture.md,
which predates INFRA-535 and is where people actually land, replaced with the
real checklist; the hand-derived count note now records that the drift is
bounded rather than merely warned about.
Also recorded, unratified: the corpus names four non-baseline event types
(voice_journal_started, journal_entry_saved, reflection_transcribed,
totally_new_event), so whitelisting any of them red-lines the relation. A
de-facto negative list, not a designed control β a future journal-analytics item
must ratify or retire it rather than meeting it as a mysterious red.
Does not widen SAFE_EVENT_TYPES. No production file changed.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Spotj3yTn89a7rXLDEFyP3
---
app/__tests__/helpers/phiFilterBaselineV1.ts | 10 +-
.../phiFilterDifferential.privacy.test.ts | 135 ++++++++++++++++++
docs/architecture/analytics-architecture.md | 39 ++++-
3 files changed, 176 insertions(+), 8 deletions(-)
diff --git a/app/__tests__/helpers/phiFilterBaselineV1.ts b/app/__tests__/helpers/phiFilterBaselineV1.ts
index 9177f4bc..f51d7728 100644
--- a/app/__tests__/helpers/phiFilterBaselineV1.ts
+++ b/app/__tests__/helpers/phiFilterBaselineV1.ts
@@ -6,7 +6,7 @@
* scan-surface tightening. It exists so the differential test can compare the
* live filter against a fixed reference rather than against itself.
*
- * THREE RULES, each load-bearing:
+ * FOUR RULES, each load-bearing:
*
* 1. This file MUST NOT import from `@/core/analytics/PHIFilter`. A baseline
* that imports the implementation compares the implementation to itself and
@@ -25,6 +25,14 @@
* directory would be collected as a suite and fail "Your test suite must
* contain at least one test."
*
+ * 4. This file is NEVER AMENDED to track a newly-added event type, and the
+ * `BASELINE_SAFE_EVENT_TYPES.size` pin in the differential is NEVER BUMPED.
+ * Both are anti-tamper guards on a fixed reference, not a headcount of the
+ * live whitelist. Adding a live event type here would restore the
+ * compare-to-itself failure rule 1 exists to prevent. A legitimate widening
+ * is recorded in the differential's `WIDENED` ledger instead β see the
+ * amendment procedure in that file's header (INFRA-558).
+ *
* DELIBERATE DEVIATION FROM VERBATIM: every `logSecurity(...)` call in the
* original `validate()` has been REMOVED. The baseline is called thousands of
* times by the differential corpus and its logging is not under test; keeping the
diff --git a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
index f601206d..72dfe9a9 100644
--- a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
+++ b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
@@ -20,6 +20,56 @@
* is vacuously green and stays green forever. Three guards below β corpus size,
* a pinned minimum rejection count, and a live matcher check β exist so this suite
* can still go red.
+ *
+ * ===========================================================================
+ * AMENDING THE WHITELIST β the procedure (INFRA-558)
+ * ===========================================================================
+ *
+ * WHAT THIS SUITE CAN AND CANNOT DETECT. Read this before relying on it.
+ * The relation above is one-sided over a HAND-AUTHORED corpus, so it catches a
+ * LOOSENING of the filter's scanning behaviour against payloads the corpus
+ * happens to contain. It does NOT, on its own, notice a new event type being
+ * added to the live whitelist: nothing in the corpus mentions that name, so
+ * every assertion stays true and the suite stays green. The `WIDENED` ledger
+ * below is what closes that gap, and it is the ONLY thing that does.
+ *
+ * The harness verifies that a widening was DECLARED. It cannot verify that a
+ * widening was WARRANTED β no test can. Do not read a green run as review.
+ *
+ * THE FROZEN BASELINE IS NEVER AMENDED. `phiFilterBaselineV1.ts` is a fixed
+ * reference to `d14d6178`; editing it to track live makes the suite compare the
+ * implementation to itself. `BASELINE_SAFE_EVENT_TYPES.size` is an anti-tamper
+ * pin on that file, not a count of the live whitelist β never bump it. What
+ * gets amended is the DELTA, recorded here.
+ *
+ * TO ADD AN EVENT TYPE, in ONE pull request:
+ * 1. Add the string to `SAFE_EVENT_TYPES` and the constant to
+ * `AnalyticsEvents` (both in `PHIFilter.ts`) β a name in one but not the
+ * other cannot transmit and fails silently.
+ * 2. Add a `WIDENED` entry below naming the event, the work item, and why.
+ * Omit it and the enforcement test red-lines; that is the control.
+ * 3. Add a per-event boundary suite in the FEAT-457 shape β see
+ * `guidanceAnalyticsBoundary.contract.test.ts`: whitelist/constant parity,
+ * the exact emitted payload, and an explicit non-vacuity case.
+ * 4. Refresh the enumerated event list in
+ * `docs/architecture/analytics-architecture.md`.
+ * 5. Get a `compliance` pass. On a solo-founder repo "approval" cannot mean a
+ * human gate that does not exist, so the durable artifact is the ledger
+ * entry plus the boundary suite β a review with no checkable output is
+ * indistinguishable afterwards from a review that never happened.
+ *
+ * DO NOT add a benign CORPUS case for a newly-whitelisted event type. It turns
+ * this suite RED by construction: `validateV1` rejects the unknown name, live
+ * accepts it, and the one-sided relation fires. The ledger is the exempted
+ * channel for exactly that reason.
+ *
+ * KNOWN, UNRATIFIED: the corpus names four event types that are NOT in the
+ * baseline whitelist β `voice_journal_started`, `journal_entry_saved`,
+ * `reflection_transcribed`, `totally_new_event`. Whitelisting any of them WOULD
+ * red-line the relation, so the corpus is a de-facto permanent negative list for
+ * those four names. That is an unrecorded side effect, not a designed control.
+ * A future item wanting to ship journal analytics must ratify or retire it
+ * deliberately rather than discovering it as a mysterious red.
*/
import { PHIFilter } from '@/core/analytics/PHIFilter';
@@ -123,6 +173,29 @@ const TIGHTENED: ReadonlyArray = [
*/
const MIN_BASELINE_REJECTIONS = 19;
+/**
+ * The registered delta between the frozen baseline and the live whitelist
+ * (INFRA-558). Every event type live-whitelisted after `d14d6178` must appear
+ * here, in the same PR that adds it.
+ *
+ * SHIPPED EMPTY, and correctly so: live and baseline are both exactly the 25
+ * names of `d14d6178`. `guidance_opened` is NOT a widening β FEAT-457 landed
+ * `bb70cb87` on 2026-08-21, three days BEFORE the freeze, so it is inside the
+ * snapshot. There is no post-freeze precedent; this ledger records the first.
+ *
+ * `sample` is a payload the event would really send, used to prove the widening
+ * is name-scoped β i.e. the baseline rejects it for the NAME and not because it
+ * smuggles wellness data past the keyword scan.
+ */
+interface Widening {
+ readonly eventType: string;
+ readonly workItem: string;
+ readonly rationale: string;
+ readonly sample: Record;
+}
+
+const WIDENED: ReadonlyArray = [];
+
describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () => {
const baselineRejections = CORPUS.filter((c) => !validateV1(c.eventType, c.data).valid);
@@ -155,6 +228,68 @@ describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () =>
});
});
+ /**
+ * The live-side control (INFRA-558). Everything above compares BEHAVIOUR over a
+ * fixed corpus; nothing above reads the live whitelist's MEMBERSHIP, so before
+ * this group a new event type could be added with no test anywhere noticing.
+ */
+ describe('whitelist amendments are declared (INFRA-558)', () => {
+ const live = new Set(PHIFilter.getWhitelistedEvents());
+ const declared = new Set([
+ ...BASELINE_SAFE_EVENT_TYPES,
+ ...WIDENED.map((w) => w.eventType),
+ ]);
+
+ it('every live event type is either in the frozen baseline or in the WIDENED ledger', () => {
+ const undeclared = [...live].filter((e) => !declared.has(e)).sort();
+ // Failing here means someone widened SAFE_EVENT_TYPES without recording it.
+ // The fix is a WIDENED entry in that same PR β never an edit to the baseline.
+ expect(undeclared).toEqual([]);
+ });
+
+ it('nothing declared has since been removed from the live whitelist', () => {
+ // The other direction: a stale ledger entry, or a baseline name deleted live.
+ // A removal is a legitimate NARROWING, but it must be reflected here rather
+ // than left as a claim the code no longer supports.
+ const missing = [...declared].filter((e) => !live.has(e)).sort();
+ expect(missing).toEqual([]);
+ });
+
+ it('each ledger entry is a NAME-scoped widening, not smuggled wellness data', () => {
+ // Vacuous while WIDENED is empty β the guard below is what keeps that honest.
+ for (const w of WIDENED) {
+ const before = validateV1(w.eventType, w.sample);
+ expect(before.valid).toBe(false);
+ expect(before.reason).toMatch(/not in whitelist/i);
+ expect(PHIFilter.validate(w.eventType, w.sample).valid).toBe(true);
+ expect(containsPHI(w.sample)).toBe(false);
+ expect(w.workItem).toMatch(/^(FEAT|DEBUG|INFRA|MAINT|AGENT)-\d+$/);
+ expect(w.rationale.length).toBeGreaterThan(20);
+ }
+ });
+
+ it('the membership matcher still fires (DEBUG-390)', () => {
+ // An empty ledger plus an unchanged whitelist makes the two tests above pass
+ // over nothing. Prove the comparison can still detect an undeclared name, so
+ // "green" means "checked" rather than "found nothing to check".
+ //
+ // Deliberately over SYNTHETIC sets, not over `live`: a control derived from
+ // live state fails whenever the test it is controlling fails, which makes it
+ // a second symptom rather than an independent check.
+ const fakeDeclared = new Set(['a', 'b']);
+ const fakeLive = new Set(['a', 'b', 'phantom_undeclared_event']);
+ expect([...fakeLive].filter((e) => !fakeDeclared.has(e))).toEqual([
+ 'phantom_undeclared_event',
+ ]);
+ expect([...fakeDeclared].filter((e) => !fakeLive.has(e))).toEqual([]);
+
+ // And that the real sets being compared are non-trivial, so the assertions
+ // above are running against something.
+ expect(live.size).toBeGreaterThanOrEqual(25);
+ expect(declared.size).toBe(BASELINE_SAFE_EVENT_TYPES.size + WIDENED.length);
+ });
+ });
+
describe('ONE-SIDED relation: anything the baseline rejected is still rejected', () => {
it.each(CORPUS.map((c) => [c.label, c] as const))(
'%s',
diff --git a/docs/architecture/analytics-architecture.md b/docs/architecture/analytics-architecture.md
index 12629968..c6f0d4ec 100644
--- a/docs/architecture/analytics-architecture.md
+++ b/docs/architecture/analytics-architecture.md
@@ -180,9 +180,12 @@ Whitelist-based validation ensuring only safe events are transmitted.
- Learn: `learn_content_viewed`, `learn_module_started/completed`
- Guidance: `guidance_opened` (FEAT-457) β **no properties, ever**
-> The count above was stated as 27 before FEAT-457 and the whitelist held 24; it is
-> derived by hand and had drifted. Read it from `PHIFilter.SAFE_EVENT_TYPES`, not
-> from here, if the exact number matters.
+> This list is derived by hand and has drifted before (stated as 27 while the whitelist
+> held 24, pre-FEAT-457). Read `PHIFilter.SAFE_EVENT_TYPES` if the exact set matters.
+> Since INFRA-558 the drift is bounded rather than merely warned about: the differential
+> suite asserts the live whitelist equals the frozen `d14d6178` baseline plus its
+> `WIDENED` ledger, so a name can no longer be added here or there without the other
+> noticing. Refreshing this list is step 4 of **Adding New Events** below.
**`guidance_opened` carries no `domain` β this is a ruling, not an omission.**
Domain-specific guidance is summoned for a named hardship (`conflict`, `career`,
@@ -320,10 +323,32 @@ if (PHIFilter.isWhitelisted(AnalyticsEvents.CHECK_IN_COMPLETED)) {
### Adding New Events
-1. Add event to `SAFE_EVENT_TYPES` in `PHIFilter.ts`
-2. Add constant to `AnalyticsEvents` object (same file)
-3. Ensure no PHI is included in event properties
-4. Update this documentation
+Adding an event type is a **widening of the app's only third-party egress filter**, so it
+carries obligations beyond registering the name. All of these land in ONE pull request:
+
+1. Add the string to `SAFE_EVENT_TYPES` **and** the constant to `AnalyticsEvents` β both in
+ `PHIFilter.ts`. A name in one but not the other cannot transmit, and fails silently.
+2. Record a `WIDENED` ledger entry in `app/__tests__/privacy/phiFilterDifferential.privacy.test.ts`
+ naming the event, the work item and the rationale. Omit it and that suite red-lines.
+3. Add a per-event boundary suite in the FEAT-457 shape β see
+ `guidanceAnalyticsBoundary.contract.test.ts`: whitelist/constant parity, the exact
+ emitted payload, and an explicit non-vacuity case.
+4. Refresh the enumerated list above (it is hand-derived and has drifted before).
+5. Get a `compliance` pass. The durable artifact is the ledger entry plus the boundary
+ suite β a review with no checkable output is indistinguishable afterwards from one that
+ never happened.
+
+**The frozen baseline is never amended.** `app/__tests__/helpers/phiFilterBaselineV1.ts` is
+a fixed reference to `d14d6178`; editing it to track live makes the differential compare the
+implementation to itself. Its `size` pin is an anti-tamper guard, not a headcount β never
+bump it. The registered delta is the ledger.
+
+**What the harness does and does not prove.** It verifies a widening was DECLARED. It
+cannot verify one was WARRANTED, and its behavioural relation runs over a hand-authored
+corpus, so it does not by itself notice a new name. Do not read green as review.
+
+Authoritative procedure, with the traps: the header of
+`app/__tests__/privacy/phiFilterDifferential.privacy.test.ts`.
### Analytics identity reset
From 19a81b5a2ce706bc0570533df5902046d61d6cc3 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:40:20 -0700
Subject: [PATCH 37/90] fix: DEBUG-549 delete the redundant autosave
subscription
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The filed defects are real: the module-level subscription read `autoSaveEnabled`
at SCHEDULE time and never re-read it in the fired callback, and it discarded the
`setTimeout` handle so nothing could cancel one. A PHQ-9 run scheduled nine
uncancelled 1000ms timers, each re-persisting whatever state existed a second
later.
The repair is a DELETION rather than the filed fix, because the subscription was
REDUNDANT, not merely un-debounced. Every mutation that could trigger it has
already persisted synchronously on the same call chain: startAssessment and
answerQuestion each await saveProgress() under the same flag, completeAssessment
and setSessionNote always save, and resetAssessment nulls currentSession, which
the guard excluded. The one mutation it uniquely covered is recoverSession, which
has ZERO production callers and only writes back the blob it just read. So every
timer it ever scheduled was a duplicate encrypted write.
`autoSaveEnabled` and both setters REMAIN. The flag is persisted via partialize
and is one of exactly two fields in CloudBackupService's restore allowlist
(EXPECTED_SAFE_FIELDS = 2, pinned in both directions by
CloudBackupService.privacy.test.ts), so deleting it would break a cross-feature
contract and its privacy suite. Seven suites also call enableAutoSave() as
load-bearing setup. It still gates the inline saves; only the deferred duplicate
is gone.
The item's own rationale is wrong and is corrected in the Notion comment: it
greps `disableAutoSave` and concludes no production writer of the flag exists,
missing that cloud-backup Restore writes it from a user-tappable control
(CloudBackupSettings.tsx:121 -> CloudBackupService.ts:381). The conclusion
survives; the reasoning does not.
The new pin asserts the property that matters β no deferred duplicate write β
rather than a timer count. The existing "auto-saves after each answer" test is
tautological here: the inline saves have already called the mock before any timer
advances, so it passes with the subscription present AND deleted. Deliberately
not jest.getTimerCount(): the process carries unrelated pending timers (1 after
startAssessment, 5 after nine answers, none per-answer), so an absolute count
would assert something this item does not own. Measured pre-fix delta was 13
across nine answers versus 4 now β exactly the nine removed. Mutation-verified:
restoring the subscription makes draining the queue add 20 writes (36 -> 56).
Not represented as a retention control: zustand persist writes `answers` on every
set() with no autoSaveEnabled gate, so disabling autosave never stopped answers
reaching encrypted storage.
Out of scope, filed separately: AccountDeletionService wipes storage and the
master key but nothing resets the in-memory store, so completedAssessments
survives and a later write recreates the encrypted blob after the sweep.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Spotj3yTn89a7rXLDEFyP3
---
.../stores/__tests__/assessmentStore.test.ts | 52 +++++++++++++++
.../assessment/stores/assessmentStore.ts | 63 +++++++++----------
2 files changed, 82 insertions(+), 33 deletions(-)
diff --git a/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts b/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts
index 5e647530..309e900f 100644
--- a/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts
+++ b/app/src/features/assessment/stores/__tests__/assessmentStore.test.ts
@@ -504,6 +504,58 @@ describe('Assessment Store - Clinical Validation', () => {
useAssessmentStore.setState({ autoSaveEnabled: false });
});
+ /**
+ * DEBUG-549 β the module-level autosave subscription no longer schedules
+ * uncancelled timers, because it no longer exists.
+ *
+ * WHY THIS PIN IS NEW RATHER THAN A STRENGTHENED SIBLING. The two tests below
+ * cannot observe this change at all: `startAssessment` and `answerQuestion`
+ * each `await get().saveProgress()` INLINE under the same flag, so
+ * `mockStoreWellnessBlob` has already been called before any timer is
+ * advanced. Both pass with the subscription present and with it deleted β
+ * tautological with respect to the code under change. Timer COUNT is the only
+ * property that discriminates.
+ *
+ * Against the pre-fix code this asserts 0 and finds 9 (one uncancelled
+ * 1000ms timer per answer, unref'd so `--detectOpenHandles` cannot see them).
+ */
+ it('queues no deferred duplicate write across a full PHQ-9 (DEBUG-549)', async () => {
+ const { result } = renderHook(() => useAssessmentStore());
+
+ act(() => {
+ result.current.enableAutoSave();
+ });
+
+ await act(async () => {
+ await result.current.startAssessment('phq9');
+ for (let i = 1; i <= 9; i += 1) {
+ await result.current.answerQuestion(`phq9_${i}`, 1);
+ }
+ });
+
+ // Control, asserted BEFORE the discriminating step: the inline persistence
+ // path is genuinely live. Without this, "no deferred write" would also be
+ // satisfied by a store that never persists at all.
+ const inlineWrites = mockStoreWellnessBlob.mock.calls.length;
+ expect(inlineWrites).toBeGreaterThan(0);
+
+ // THE DISCRIMINATING ASSERTION. Everything above has already been written
+ // synchronously. Draining the timer queue must therefore produce no further
+ // write β a deferred one would be a duplicate of state already on disk.
+ //
+ // Deliberately NOT `jest.getTimerCount()`: the process has other, unrelated
+ // pending timers (measured: 1 after startAssessment, 5 after nine answers,
+ // none of them per-answer), so an absolute count would assert something this
+ // item does not own and would rot on any unrelated change. The pre-fix delta
+ // was 13 across nine answers versus 4 now β exactly the nine this removed.
+ await act(async () => {
+ jest.advanceTimersByTime(1100);
+ await Promise.resolve();
+ });
+
+ expect(mockStoreWellnessBlob.mock.calls.length).toBe(inlineWrites);
+ });
+
it('auto-saves progress after each answer when enabled', async () => {
const { result } = renderHook(() => useAssessmentStore());
diff --git a/app/src/features/assessment/stores/assessmentStore.ts b/app/src/features/assessment/stores/assessmentStore.ts
index ecc05dbc..a1c9b700 100644
--- a/app/src/features/assessment/stores/assessmentStore.ts
+++ b/app/src/features/assessment/stores/assessmentStore.ts
@@ -975,38 +975,35 @@ export const useAssessmentStore = create()(
)
);
-// Helper: call .unref() on a setTimeout handle when running in Node
-// (Jest). In browser/RN, setTimeout returns a number that has no unref.
-function unrefTimeout(handle: ReturnType): void {
- const h = handle as unknown as { unref?: () => void };
- if (typeof h.unref === 'function') h.unref();
-}
-
-// Auto-save subscription for real-time persistence
-useAssessmentStore.subscribe(
- (state) => ({
- answers: state.answers,
- currentSession: state.currentSession,
- autoSaveEnabled: state.autoSaveEnabled
- }),
- async (current, previous) => {
- if (
- current.autoSaveEnabled &&
- current.currentSession &&
- (current.answers.length !== previous.answers.length ||
- current.currentSession?.id !== previous.currentSession?.id)
- ) {
- // Debounced auto-save; unref the timer in Node so it doesn't keep
- // Jest alive past test completion. Safe in RN production.
- unrefTimeout(setTimeout(async () => {
- try {
- await useAssessmentStore.getState().saveProgress();
- } catch (error) {
- logError(LogCategory.SYSTEM, 'Auto-save failed:', error instanceof Error ? error : new Error(String(error)));
- }
- }, 1000));
- }
- }
-);
+// DEBUG-549 β the module-level autosave subscription was REMOVED, not repaired.
+//
+// It read `autoSaveEnabled` at SCHEDULE time and never re-read it in the fired
+// callback, and it discarded the `setTimeout` handle, so nothing could ever
+// cancel one. A PHQ-9 run scheduled nine uncancelled 1000ms timers, each of
+// which re-persisted whatever state existed a second later.
+//
+// The repair is a deletion because the subscription was REDUNDANT, not merely
+// un-debounced. Every mutation that could trigger it has already persisted
+// synchronously on the same call chain:
+// β’ `startAssessment` β awaits `saveProgress()` under the same flag
+// β’ `answerQuestion` β awaits `saveProgress()` under the same flag
+// β’ `completeAssessment`β always saves
+// β’ `setSessionNote` β always saves
+// β’ `resetAssessment` β nulls `currentSession`, which the guard excluded
+// The one mutation it uniquely covered is `recoverSession`, which has NO
+// production callers and in any case only writes back the blob it just read.
+// So every timer it ever scheduled was a duplicate encrypted write.
+//
+// `autoSaveEnabled` and both setters DELIBERATELY REMAIN. The flag is persisted
+// via `partialize` AND is one of exactly two fields in CloudBackupService's
+// restore allowlist (`EXPECTED_SAFE_FIELDS = 2`, pinned in both directions by
+// CloudBackupService.privacy.test.ts), so removing it would break a cross-feature
+// contract and its privacy suite. It still gates the inline saves above; only the
+// deferred duplicate is gone.
+//
+// Not a retention control, and must never be described as one: the zustand
+// `persist` middleware writes `answers` on every `set()` with no
+// `autoSaveEnabled` gate, so disabling autosave has never stopped answers
+// reaching encrypted storage.
export default useAssessmentStore;
\ No newline at end of file
From 1b6290f4790adad9c33b53dad76f53578a3dc1a7 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:48:34 -0700
Subject: [PATCH 38/90] fix: DEBUG-550 refuse to score a malformed answer set,
and stop stranding the user
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The item's premise is FALSE and is corrected here. A short PHQ-9 does NOT
under-total: ClinicalScoringService already throws on a wrong COUNT
(assessmentStore.ts:220 for PHQ-9, :250 for GAD-7). Two different defects are
real, and one is worse than the one filed.
DEFECT 1 β a Q9 FALSE NEGATIVE. A count is not a completeness check. Nine PHQ-9
entries where one id repeats and phq9_9 is absent passes the count check, sums,
and bands. Measured against pre-fix code that set scores 9, bands "mild", and
reports suicidalIdeation:false and isCrisis:false β because the self-harm
question is simply missing. This directly contradicts the item's claim that the
self-harm path is not implicated.
Fixed with SET EQUALITY: exactly the expected ids, each exactly once. Deliberately
NOT by swapping in validateCurrentAnswers, which the ACs prescribe: it has zero
callers, and it is INCOMPARABLE to the shipped count check rather than stronger β
ten entries with an extra pass presence but fail count, nine with a duplicate pass
count but fail presence. Substituting it would have lost a case caught today. One
helper, two views, so the "no second parallel validator" AC is satisfied
structurally.
Refuse rather than score-and-mark-partial: a partial-flagged result still needs a
band to render and still enters completedAssessments, history, trends and cloud
backup, so the false negative would move downstream and multiply. The refusal
keeps the session and answers intact and explicitly nulls currentResult β
recoverSession does not clear it, so a second assessment completed-then-refused in
one app session could otherwise render the earlier banded result as this one, and
SyncCoordinator's null -> non-null transition would re-evaluate it for crisis.
DEFECT 2 β a LIVE SILENT DEAD END, independent of the guard. completeAssessment
swallows a scoring failure into `error` and RESOLVES, so handleCompleteAssessment
never entered its catch; `if (storeState.currentResult)` had no else, and nothing
rendered. The reader sat on the final question of a check-in with no feedback, no
navigation and no alert. Adds the else: route back to the first unanswered
question, stay in 'questions', and say so.
Copy is philosopher-reviewed and instrument-agnostic. It does NOT name the
question, because on the PHQ-9 path the missing item is most often Q9 and naming
it would spotlight self-harm to someone who never answered it. "check-in" matches
the established house string (AssessmentIntroduction.tsx:112); no clinical
framing, no blame, no consolation the reader did not ask for.
Q9 > 0 continues to fire regardless of total β the inline branch in
answerQuestion is untouched, and no awaited work was added ahead of it, so the
<200ms crisis-detection CI gate is unaffected (14 perf tests pass).
ClinicalScoringService is deliberately NOT tightened: inlineQ9SeverityLevel relies
on it throwing-and-being-caught on an incomplete set.
TDD, forced for clinical logic: the duplicate-id case was written first and
observed red with the banded "mild" result quoted above. Four of eight specs were
red pre-fix; the four green ones are the regression invariants, which is what
proves the harness runs rather than failing uniformly.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01Spotj3yTn89a7rXLDEFyP3
---
.../incompleteAnswerSetGuard.clinical.test.ts | 199 ++++++++++++++++++
.../components/EnhancedAssessmentFlow.tsx | 42 +++-
.../assessment/stores/assessmentStore.ts | 85 +++++++-
3 files changed, 317 insertions(+), 9 deletions(-)
create mode 100644 app/__tests__/clinical/assessment-accuracy/incompleteAnswerSetGuard.clinical.test.ts
diff --git a/app/__tests__/clinical/assessment-accuracy/incompleteAnswerSetGuard.clinical.test.ts b/app/__tests__/clinical/assessment-accuracy/incompleteAnswerSetGuard.clinical.test.ts
new file mode 100644
index 00000000..4a5db377
--- /dev/null
+++ b/app/__tests__/clinical/assessment-accuracy/incompleteAnswerSetGuard.clinical.test.ts
@@ -0,0 +1,199 @@
+/**
+ * DEBUG-550 β `completeAssessment` refuses to score an answer set that is not
+ * exactly the expected question ids.
+ *
+ * WHAT THE ITEM SAID, AND WHAT IS ACTUALLY WRONG
+ * ----------------------------------------------
+ * The item was filed as "a short PHQ-9 scores as if complete, producing a
+ * sub-threshold total". That is NOT reachable: `ClinicalScoringService`
+ * already throws on a wrong COUNT (assessmentStore.ts:220 for PHQ-9, :250 for
+ * GAD-7), so a short set has never produced a banded result.
+ *
+ * A count is not a completeness check, and that gap IS reachable. Nine answers
+ * where one question id repeats and `phq9_9` is absent passes the count check,
+ * sums, bands, and computes `suicidalIdeation` from `find('phq9_9') === undefined`
+ * β i.e. reports NO self-harm risk because the self-harm question is missing.
+ * That is a Q9 false negative from a set that looks complete, and it is the
+ * opposite of what the item's body assumed about the self-harm path.
+ *
+ * `validateCurrentAnswers` (assessmentStore.ts:924) is NOT a drop-in fix and is
+ * not used as one. It is a PRESENCE check with zero callers, and it is
+ * incomparable to the shipped count check rather than stronger: ten answers
+ * carrying one extra pass presence but fail count, while nine answers with a
+ * duplicate pass count but fail presence. Substituting it would LOSE a case that
+ * is caught today. The guard here is set EQUALITY β exactly the expected ids,
+ * each exactly once β which is strictly stronger than both.
+ *
+ * WHY REFUSE RATHER THAN SCORE-AND-FLAG. A partial-flagged result still needs a
+ * severity band to render, still enters `completedAssessments`, and flows from
+ * there into history, trends and cloud backup. Every downstream reader would have
+ * to learn the flag or silently treat the under-total as real, so the false
+ * negative would move downstream and multiply.
+ */
+
+import { useAssessmentStore } from '@/features/assessment/stores/assessmentStore';
+import type { AssessmentAnswer } from '@/features/assessment/types';
+
+const state = () => useAssessmentStore.getState();
+
+/** Seed the store directly so we can author answer shapes the UI cannot produce. */
+function seedSession(type: 'phq9' | 'gad7', answers: AssessmentAnswer[]): void {
+ useAssessmentStore.setState({
+ currentSession: {
+ id: `debug550-${type}-session`,
+ type,
+ startedAt: new Date().toISOString(),
+ progress: { currentQuestion: 0, totalQuestions: type === 'phq9' ? 9 : 7, isComplete: false, answers },
+ },
+ answers,
+ currentResult: null,
+ completionBlocked: null,
+ error: null,
+ } as unknown as Parameters[0]);
+}
+
+function answer(questionId: string, response: number): AssessmentAnswer {
+ return { questionId, response, timestamp: new Date().toISOString() } as unknown as AssessmentAnswer;
+}
+
+describe('DEBUG-550 β completeAssessment refuses a malformed answer set', () => {
+ beforeEach(() => {
+ useAssessmentStore.setState({
+ currentSession: null,
+ answers: [],
+ currentResult: null,
+ completedAssessments: [],
+ completionBlocked: null,
+ error: null,
+ } as unknown as Parameters[0]);
+ });
+
+ describe('the Q9 false negative β count satisfied, completeness not', () => {
+ it('refuses nine PHQ-9 answers where one id repeats and phq9_9 is absent', async () => {
+ // Exactly 9 entries, so the shipped count check passes. Only 8 distinct
+ // ids, and the missing one is the self-harm question.
+ const answers = [
+ answer('phq9_1', 1), answer('phq9_2', 1), answer('phq9_3', 1),
+ answer('phq9_4', 1), answer('phq9_5', 1), answer('phq9_6', 1),
+ answer('phq9_7', 1), answer('phq9_8', 1),
+ answer('phq9_8', 1), // duplicate; phq9_9 never answered
+ ];
+ seedSession('phq9', answers);
+
+ await state().completeAssessment();
+
+ // Against pre-fix code this set scores 9 and bands 'mild', with
+ // suicidalIdeation false because phq9_9 is simply absent.
+ expect(state().currentResult).toBeNull();
+ expect(state().completedAssessments).toHaveLength(0);
+ expect(state().completionBlocked?.reason).toBe('incomplete_answers');
+ expect(state().completionBlocked?.missingQuestionIds).toEqual(['phq9_9']);
+ });
+
+ it('refuses the GAD-7 mirror', async () => {
+ const answers = [
+ answer('gad7_1', 2), answer('gad7_2', 2), answer('gad7_3', 2),
+ answer('gad7_4', 2), answer('gad7_5', 2), answer('gad7_6', 2),
+ answer('gad7_6', 2), // duplicate; gad7_7 never answered
+ ];
+ seedSession('gad7', answers);
+
+ await state().completeAssessment();
+
+ expect(state().currentResult).toBeNull();
+ expect(state().completedAssessments).toHaveLength(0);
+ expect(state().completionBlocked?.missingQuestionIds).toEqual(['gad7_7']);
+ });
+ });
+
+ describe('the short set β already refused, now refused with a reason', () => {
+ it('names every unanswered PHQ-9 question rather than failing opaquely', async () => {
+ seedSession('phq9', [
+ answer('phq9_1', 1), answer('phq9_2', 1), answer('phq9_3', 1),
+ answer('phq9_4', 1), answer('phq9_5', 1),
+ ]);
+
+ await state().completeAssessment();
+
+ expect(state().currentResult).toBeNull();
+ // Pre-fix this was already null (the count check throws), but the throw was
+ // swallowed into `error` and nothing named WHICH questions were missing β
+ // so the flow had nothing to route the user back to. This half is the red.
+ expect(state().completionBlocked?.missingQuestionIds).toEqual([
+ 'phq9_6', 'phq9_7', 'phq9_8', 'phq9_9',
+ ]);
+ });
+
+ it('leaves the session intact so nothing the user entered is lost', async () => {
+ seedSession('phq9', [answer('phq9_1', 3), answer('phq9_2', 3)]);
+
+ await state().completeAssessment();
+
+ expect(state().currentSession).not.toBeNull();
+ expect(state().answers).toHaveLength(2);
+ });
+ });
+
+ describe('a refusal does not strand a stale result', () => {
+ it('nulls currentResult so a previous assessment cannot render as this one', async () => {
+ // recoverSession does not clear currentResult, so a second assessment
+ // completed-then-refused in one app session could otherwise render the
+ // earlier banded result β and SyncCoordinator's null->non-null transition
+ // would re-evaluate it for crisis.
+ seedSession('phq9', [answer('phq9_1', 1)]);
+ useAssessmentStore.setState({
+ currentResult: { totalScore: 24, severity: 'severe' },
+ } as unknown as Parameters[0]);
+
+ await state().completeAssessment();
+
+ expect(state().currentResult).toBeNull();
+ });
+ });
+
+ describe('regression β a well-formed set is completely unaffected', () => {
+ it('still scores and bands a severe PHQ-9 (the phq9-severe-completion invariant)', async () => {
+ seedSession('phq9', [
+ answer('phq9_1', 3), answer('phq9_2', 3), answer('phq9_3', 3),
+ answer('phq9_4', 3), answer('phq9_5', 3), answer('phq9_6', 3),
+ answer('phq9_7', 3), answer('phq9_8', 3), answer('phq9_9', 0),
+ ]);
+
+ await state().completeAssessment();
+
+ expect(state().completionBlocked).toBeNull();
+ expect(state().currentResult).not.toBeNull();
+ expect(state().currentResult?.totalScore).toBe(24);
+ expect(state().completedAssessments).toHaveLength(1);
+ });
+
+ it('still scores and bands a severe GAD-7 (the gad7-severe invariant)', async () => {
+ seedSession('gad7', [
+ answer('gad7_1', 3), answer('gad7_2', 3), answer('gad7_3', 3),
+ answer('gad7_4', 3), answer('gad7_5', 3), answer('gad7_6', 3),
+ answer('gad7_7', 3),
+ ]);
+
+ await state().completeAssessment();
+
+ expect(state().completionBlocked).toBeNull();
+ expect(state().currentResult?.totalScore).toBe(21);
+ });
+
+ it('a complete set with Q9 > 0 still reports self-harm risk', async () => {
+ // The invariant q9-single-alert pins. Q9 is answered, so the guard is
+ // invisible here β it must stay invisible.
+ seedSession('phq9', [
+ answer('phq9_1', 0), answer('phq9_2', 0), answer('phq9_3', 0),
+ answer('phq9_4', 0), answer('phq9_5', 0), answer('phq9_6', 0),
+ answer('phq9_7', 0), answer('phq9_8', 0), answer('phq9_9', 2),
+ ]);
+
+ await state().completeAssessment();
+
+ expect(state().completionBlocked).toBeNull();
+ expect(state().currentResult).not.toBeNull();
+ expect((state().currentResult as { suicidalIdeation?: boolean })?.suicidalIdeation).toBe(true);
+ });
+ });
+});
diff --git a/app/src/features/assessment/components/EnhancedAssessmentFlow.tsx b/app/src/features/assessment/components/EnhancedAssessmentFlow.tsx
index 6717e0d7..1c48b551 100644
--- a/app/src/features/assessment/components/EnhancedAssessmentFlow.tsx
+++ b/app/src/features/assessment/components/EnhancedAssessmentFlow.tsx
@@ -327,6 +327,46 @@ const EnhancedAssessmentFlow: React.FC = ({
setResult(storeState.currentResult);
setFlowState('results');
}
+ } else {
+ // DEBUG-550 β the branch that was missing.
+ //
+ // `completeAssessment` swallows a scoring failure into store `error` and
+ // RESOLVES, so this function never entered its own catch. With no `else`
+ // here, nothing rendered: no navigation, no alert, `flowState` stuck at
+ // 'questions'. The reader was left on the last question of a wellness
+ // check-in with no feedback at all. That strand is live today,
+ // independent of the completeness guard.
+ const blocked = storeState.completionBlocked;
+ if (blocked && blocked.missingQuestionIds.length > 0) {
+ // Route back to the first unanswered question rather than dead-ending.
+ const firstMissing = questions.findIndex(
+ (q) => q.id === blocked.missingQuestionIds[0]
+ );
+ if (firstMissing >= 0) {
+ setCurrentQuestionIndex(firstMissing);
+ }
+ setFlowState('questions');
+ // Copy is deliberately instrument-agnostic and does NOT name the
+ // question: on the PHQ-9 path the missing item is most often Q9, and
+ // naming it would spotlight self-harm to someone who never answered it.
+ Alert.alert(
+ 'Not quite finished',
+ "One answer didn't come through, so this check-in isn't complete. You're back at that question; your other answers are saved.",
+ [{ text: 'OK' }]
+ );
+ } else {
+ // Any other reason scoring produced no result. Previously also silent.
+ logError(
+ LogCategory.SYSTEM,
+ 'Assessment completion produced no result:',
+ new Error(storeState.error || 'unknown')
+ );
+ Alert.alert(
+ 'Completion Error',
+ 'There was an issue completing your check-in. Your responses are safely stored.',
+ [{ text: 'OK' }]
+ );
+ }
}
} catch (error) {
@@ -339,7 +379,7 @@ const EnhancedAssessmentFlow: React.FC = ({
} finally {
setIsProcessing(false);
}
- }, [completeAssessment, crisisDetected, questions.length, answers.size, context, onComplete]);
+ }, [completeAssessment, crisisDetected, questions, answers.size, context, onComplete]);
// Begin assessment flow
const handleBeginAssessment = useCallback(() => {
diff --git a/app/src/features/assessment/stores/assessmentStore.ts b/app/src/features/assessment/stores/assessmentStore.ts
index ecc05dbc..5d1a6f2f 100644
--- a/app/src/features/assessment/stores/assessmentStore.ts
+++ b/app/src/features/assessment/stores/assessmentStore.ts
@@ -66,6 +66,38 @@ const GAD7_QUESTIONS = [
'gad7_1', 'gad7_2', 'gad7_3', 'gad7_4', 'gad7_5', 'gad7_6', 'gad7_7'
];
+/**
+ * DEBUG-550 β the single completeness predicate. SET EQUALITY: exactly the
+ * expected ids, each exactly once.
+ *
+ * Deliberately stronger than either check that existed before, because those two
+ * are INCOMPARABLE rather than ordered:
+ * β’ the shipped count check (`filter(startsWith).length !== 9`) catches a short
+ * set and an extra answer, but passes nine entries with a duplicate and a gap;
+ * β’ `validateCurrentAnswers`'s presence check catches that gap, but passes ten
+ * entries carrying an extra.
+ * Substituting one for the other would have LOST a case that is caught today.
+ *
+ * Returns the expected ids with no answer, in question order, so a caller can
+ * route the reader to the first one rather than just refusing.
+ */
+function missingAnswerIds(type: AssessmentType, answers: AssessmentAnswer[]): string[] {
+ const expected = type === 'phq9' ? PHQ9_QUESTIONS : GAD7_QUESTIONS;
+ return expected.filter((id) => !answers.some((a) => a.questionId === id));
+}
+
+/** True when `answers` carries an id that is not expected, or any id twice. */
+function hasUnexpectedOrDuplicateAnswers(type: AssessmentType, answers: AssessmentAnswer[]): boolean {
+ const expected = new Set(type === 'phq9' ? PHQ9_QUESTIONS : GAD7_QUESTIONS);
+ const seen = new Set();
+ for (const a of answers) {
+ if (!expected.has(a.questionId)) return true;
+ if (seen.has(a.questionId)) return true;
+ seen.add(a.questionId);
+ }
+ return false;
+}
+
// Severity mappings (validated scoring algorithm)
const PHQ9_SEVERITY_THRESHOLDS = {
minimal: [0, 4],
@@ -479,6 +511,13 @@ export interface AssessmentStoreState {
// Performance tracking
autoSaveEnabled: boolean;
lastSyncAt: number | null;
+
+ /**
+ * DEBUG-550 β set when `completeAssessment` REFUSED to score. Distinct from
+ * `error`, which is a free-text string nothing renders; this is structured so
+ * the flow can route the reader back to a specific question.
+ */
+ completionBlocked: { reason: 'incomplete_answers'; missingQuestionIds: string[] } | null;
}
/**
@@ -539,10 +578,11 @@ export const useAssessmentStore = create()(
lastSavedAt: null,
autoSaveEnabled: true,
lastSyncAt: null,
+ completionBlocked: null,
// Session management actions
startAssessment: async (type: AssessmentType, context: AssessmentContext = 'standalone') => {
- set({ isLoading: true, error: null });
+ set({ isLoading: true, error: null, completionBlocked: null });
try {
const sessionId = generateTimestampedId(type);
@@ -659,6 +699,34 @@ export const useAssessmentStore = create()(
set({ isLoading: true });
try {
+ // DEBUG-550 β REFUSE an answer set that is not exactly the expected
+ // ids. A count is not a completeness check: nine PHQ-9 entries with a
+ // duplicate and `phq9_9` absent used to score, band, and report
+ // `suicidalIdeation: false` from the missing question β a Q9 false
+ // negative from a set that looks complete.
+ //
+ // Refuse rather than score-and-flag. A partial-flagged result still
+ // needs a band to render and still enters `completedAssessments`,
+ // history, trends and cloud backup, so the false negative would just
+ // move downstream. Nothing here is destroyed: the session and answers
+ // stay put so the reader can finish.
+ const missing = missingAnswerIds(state.currentSession.type, state.answers);
+ const malformed = hasUnexpectedOrDuplicateAnswers(state.currentSession.type, state.answers);
+ if (missing.length > 0 || malformed) {
+ set({
+ isLoading: false,
+ // Explicitly null: `recoverSession` does not clear this, so a
+ // second assessment completed-then-refused in one app session
+ // could otherwise render the EARLIER banded result as this one β
+ // and SyncCoordinator's null -> non-null transition would
+ // re-evaluate it for crisis.
+ currentResult: null,
+ completionBlocked: { reason: 'incomplete_answers', missingQuestionIds: missing },
+ error: `ASSESSMENT_INCOMPLETE: ${missing.length} unanswered`
+ });
+ return;
+ }
+
// Calculate final result
let result: PHQ9Result | GAD7Result;
@@ -694,7 +762,8 @@ export const useAssessmentStore = create()(
currentResult: result,
completedAssessments: updatedHistory,
isLoading: false,
- hasRecoverableSession: false
+ hasRecoverableSession: false,
+ completionBlocked: null
});
// Handle crisis if detected. handleCrisisDetection is now the
@@ -728,7 +797,8 @@ export const useAssessmentStore = create()(
crisisIntervention: null,
hasRecoverableSession: false,
error: null,
- isLoading: false
+ isLoading: false,
+ completionBlocked: null
});
},
@@ -922,13 +992,12 @@ export const useAssessmentStore = create()(
},
validateCurrentAnswers: () => {
+ // DEBUG-550: delegates to the same predicate `completeAssessment` uses,
+ // so there is one implementation and two views rather than two
+ // validators that can drift apart.
const state = get();
if (!state.currentSession) return false;
-
- const expectedQuestions = state.currentSession.type === 'phq9' ? PHQ9_QUESTIONS : GAD7_QUESTIONS;
- return expectedQuestions.every(questionId =>
- state.answers.some(answer => answer.questionId === questionId)
- );
+ return missingAnswerIds(state.currentSession.type, state.answers).length === 0;
}
}),
{
From 1ab51796225729a916e810bbdccb0252f98a6bfc Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:16:17 -0700
Subject: [PATCH 39/90] fix: DEBUG-560 crisis contact lines are one text flow,
not a fixed 80pt column
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`contactLabel` carried `width: 80` beside a `flex: 1` `contactValue`. A fixed pt box
holding Dynamic-Type-scaled text overflows by construction: "Languages:" measures
~77-80pt at bodySmall 14 semibold, so it word-broke at the first step above Large,
and the value was confined to 214pt of a 294pt card and wrapped into a narrow
indented column with ~80pt of dead gutter beside every continuation line.
Deletes the two-column row container. Each contact line is now one Text paragraph
with a nested styled label span, so no fixed dimension exists to scale and
continuation lines use the full content box.
Reported via Sentry TestFlight feedback (fyi.being.app@1.2.1+2, iPhone 16e 390x844,
iOS 26.6): "Languages spills to another line and looks bad."
AC 1 restated. As authored it asked for no wrapping, which is arithmetically
unreachable: the Domestic Violence line is ~395-405pt against a 294pt box, so one
line would need a ~28% smaller font or truncation, and truncation is forbidden on a
crisis affordance. The crisis pass restated it as one continuous text flow with
continuation at the content-box left edge and no orphaned or word-broken label.
AC 2 ruled layout-only. The prose entries stay in `languages`. Only one of the two
is even user-visible: `emergency_911` is `priority: 'emergency'` and is excluded by
both render paths, so `CrisisResources.ts:133` renders nowhere. The existing
`interpreterAvailable` boolean cannot carry "200+" and has zero render consumers, so
moving the prose there would delete user-visible safety information. Normalization
(`languageAccess?: string`) is refiled, not folded in.
Specialist passes: crisis (constraint 5 amended to permit the node merge, since it
follows from deleting the two-column structure rather than riding along on a width
fix), accessibility (layout, VoiceOver, WCAG 1.4.4).
Also extends daily-loop-ax5-entry.yaml with a `crisis-call-988-button` assertion β
it already lands on the crisis destination at AX5, so guarding DEBUG-432/488's fold
invariant there is free. No new flow: Maestro asserts presence, not wrapping, so a
flow written for this defect would pass on the broken build too.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01TYgDdLs3wxbxwr9M5Jts4p
---
app/.eslint-baseline.json | 1 +
app/.maestro/daily-loop-ax5-entry.yaml | 13 +
.../crisis/screens/CrisisResourcesScreen.tsx | 76 +++--
...isisResourcesScreen.accessibility.test.tsx | 279 ++++++++++++++++++
4 files changed, 342 insertions(+), 27 deletions(-)
create mode 100644 app/src/features/crisis/screens/__tests__/CrisisResourcesScreen.accessibility.test.tsx
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index a247ca3d..bc2fb672 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -138,6 +138,7 @@
"src/features/crisis/components/__tests__/RootCrisisButton.test.tsx": 1,
"src/features/crisis/constants/__tests__/crisisButtonGeometry.test.ts": 1,
"src/features/crisis/screens/CrisisResourcesScreen.tsx": 2,
+ "src/features/crisis/screens/__tests__/CrisisResourcesScreen.accessibility.test.tsx": 1,
"src/features/crisis/screens/__tests__/CrisisResourcesScreen.reachability.test.tsx": 1,
"src/features/crisis/screens/__tests__/CrisisResourcesScreen.test.tsx": 1,
"src/features/crisis/services/__tests__/CrisisSecurityProtocol.test.ts": 1,
diff --git a/app/.maestro/daily-loop-ax5-entry.yaml b/app/.maestro/daily-loop-ax5-entry.yaml
index 65604748..de4e0a4d 100644
--- a/app/.maestro/daily-loop-ax5-entry.yaml
+++ b/app/.maestro/daily-loop-ax5-entry.yaml
@@ -109,3 +109,16 @@ name: "DailyLoop AX5 entry (DEBUG-469): the loop is enterable at accessibility t
id: "daily-loop-support-line"
- assertVisible:
id: "crisis-resources-screen"
+# DEBUG-560 β the destination's own 988 control, still in the fold at AX5.
+#
+# Added here rather than in a new flow because this flow already lands on
+# `crisis-resources-screen` at AX5, so the assertion is free, and because a flow written
+# for DEBUG-560's actual defect could not fail: Maestro asserts presence, not wrapping,
+# and the Languages text is findable on the broken build and the fixed one alike.
+#
+# What it DOES guard is DEBUG-432/488's invariant surviving any change to card height.
+# The 988 control is a sibling of the ScrollView inside `crisisFooter`, so content height
+# is not an input to its frame β but that is a structural argument, and this is the one
+# cheap place the structure is observed at AX5 on a 375x667 device rather than argued.
+- assertVisible:
+ id: "crisis-call-988-button"
diff --git a/app/src/features/crisis/screens/CrisisResourcesScreen.tsx b/app/src/features/crisis/screens/CrisisResourcesScreen.tsx
index 9675efe1..045a0642 100644
--- a/app/src/features/crisis/screens/CrisisResourcesScreen.tsx
+++ b/app/src/features/crisis/screens/CrisisResourcesScreen.tsx
@@ -82,6 +82,35 @@ interface ResourceCardProps {
hidePrimaryAction?: boolean;
}
+/**
+ * DEBUG-560 β one contact line, one text flow.
+ *
+ * Was a `flexDirection: 'row'` View pairing a `width: 80` label with a `flex: 1` value.
+ * A fixed pt box holding Dynamic-Type-scaled text overflows by construction: "Languages:"
+ * measures ~77-80pt at bodySmall 14 semibold against that 80pt box, so it word-broke at
+ * the first step above Large, and the value was confined to the remaining 214pt of a 294pt
+ * card and wrapped into a narrow indented column. Nesting the label inside the paragraph
+ * removes the fixed dimension entirely, so correctness holds at every type step rather
+ * than at a measured breakpoint, and continuation lines use the full content box.
+ *
+ * The nested span also merges the two VoiceOver stops into one natively β
+ * `RCTParagraphComponentAccessibilityProvider` exposes a single element carrying the
+ * rendered attributed string, adding more only for "button"/"link" spans. Pre-fix the
+ * label was an orphan stop with no referent. Do NOT add `accessibilityLabel` (it REPLACES
+ * the rendered string and cannot track `resource.languages`), `accessible` in either
+ * direction (`false` erases the line, phone number included), or `accessibilityRole` on
+ * the span (re-splits the element and stamps a false trait).
+ *
+ * The separator space lives INSIDE the template literal. Written as JSX whitespace it is
+ * trimmed at a line boundary by a formatter reflow, silently yielding "Languages:English".
+ */
+const ContactLine: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
+
+ {`${label}: `}
+ {children}
+
+);
+
/**
* Resource Card Component
* Displays individual crisis resource with contact actions
@@ -164,28 +193,19 @@ const ResourceCard: React.FC = ({ resource, onPress, hidePrim
{/* Contact Information */}
{resource.phone && (
-
- Phone:
-
- {resource.phone}{resource.extension ? ` (Press ${resource.extension})` : ''}
-
-
+
+ {`${resource.phone}${resource.extension ? ` (Press ${resource.extension})` : ''}`}
+
)}
{resource.textNumber && (
-
- Text:
-
- {resource.textMessage} to {resource.textNumber}
-
-
+
+ {`${resource.textMessage} to ${resource.textNumber}`}
+
)}
{resource.languages && resource.languages.length > 0 && (
-
- Languages:
- {resource.languages.join(', ')}
-
+ {resource.languages.join(', ')}
)}
{/* Warning Note */}
@@ -696,26 +716,28 @@ const styles = StyleSheet.create({
// 911 card to #FFEBEE, where gray[650] is 4.8744 (gray[700] was 8.7911) β passing,
// and pinned in APP_LOCAL_TINTED_SURFACES rather than left ungoverned.
// Deliberately NOT `primary`: that would put orienting prose at parity with
- // `resourceName` and `contactValue` (both gray[800]), and the phone number must
+ // `resourceName` and `contactLine` (both gray[800]), and the phone number must
// out-rank the description on a crisis card.
color: semantic.text.secondary,
lineHeight: spacing[20],
marginBottom: spacing[16]
},
- contactInfo: {
- flexDirection: 'row',
+ contactLine: {
+ fontSize: typography.bodySmall.size,
+ color: colorSystem.gray[800],
marginBottom: spacing[4]
+ // DEBUG-560: deliberately NO width/minWidth/flex β a fixed dimension beside scaled
+ // text is the defect this replaced, and a larger constant is the same bug. Also NO
+ // lineHeight: RN scales fontSize under Dynamic Type but not a numeric lineHeight, so
+ // a fixed value clips at AX5. (`resourceDescription` above carries that pre-existing
+ // hazard; do not propagate it here.)
},
contactLabel: {
- fontSize: typography.bodySmall.size,
+ // No fontSize β inherited from the paragraph, so one font-size owner per line and
+ // nothing to drift. Keeps the MAINT-487 hierarchy: the value stays gray[800] so the
+ // phone number out-ranks its label.
fontWeight: typography.fontWeight.semibold,
- color: semantic.text.secondary,
- width: 80
- },
- contactValue: {
- fontSize: typography.bodySmall.size,
- color: colorSystem.gray[800],
- flex: 1
+ color: semantic.text.secondary
},
warningContainer: {
backgroundColor: '#FFF3CD',
diff --git a/app/src/features/crisis/screens/__tests__/CrisisResourcesScreen.accessibility.test.tsx b/app/src/features/crisis/screens/__tests__/CrisisResourcesScreen.accessibility.test.tsx
new file mode 100644
index 00000000..24f27a75
--- /dev/null
+++ b/app/src/features/crisis/screens/__tests__/CrisisResourcesScreen.accessibility.test.tsx
@@ -0,0 +1,279 @@
+/**
+ * DEBUG-560 β contact lines on the crisis destination are one text flow, not two columns.
+ *
+ * THE DEFECT, from Sentry TestFlight feedback (2026-08-25, fyi.being.app@1.2.1+2,
+ * iPhone 16e 390x844, iOS 26.6): "Languages spills to another line and looks bad."
+ *
+ * `contactLabel` carried `width: 80` beside a `flex: 1` `contactValue`. That pairing is a
+ * fixed pt dimension holding text that Dynamic Type scales, so the box is guaranteed to
+ * overflow at the step where legibility matters most. Two distinct failures shared the line:
+ *
+ * Label wrap. Card content box on 390x844 is 390 - 2*spacing[24] (section)
+ * - 2*spacing[24] (card) = 294pt; the label box was a fixed 80pt.
+ * "Languages:" at bodySmall 14 semibold measures ~77-80pt β 0 to 3pt of
+ * slack. At the first step above Large (xLarge, x1.12) it needs ~86-90pt
+ * and word-breaks inside its own box, orphaning the colon.
+ * Value wrap. The value column was 214pt. It still wraps after the fix; see below.
+ *
+ * WHAT IS AND IS NOT PINNED HERE. AC 1 was authored as "without the value column
+ * wrapping" and that is arithmetically unreachable: the Domestic Violence line is ~58
+ * characters, ~395-405pt at bodySmall 14, against a 294pt box. One line would need a ~28%
+ * smaller font or truncation, and truncation is forbidden on a crisis affordance (the
+ * DEBUG-390 ruling recorded at `styles.crisisFooter`: capping text growth on the crisis
+ * affordance inverts the priority). The crisis pass restated it as: ONE continuous text
+ * flow, continuation lines at the content-box left edge, no orphaned or word-broken label,
+ * no narrow indented second column. That is what these tests pin.
+ *
+ * WHAT JEST CANNOT DO. `react-test-renderer` has no text measurement and no layout, so no
+ * assertion here proves "does not wrap" or "uses the full card width" β the same split
+ * `CrisisResourcesScreen.reachability.test.tsx` draws for the 988 footer, and the same
+ * limitation `CleanHomeScreen.accessibility.test.tsx` records. Jest owns STRUCTURE; real
+ * bounds are `maestro hierarchy`'s job. Do not rename these to claim otherwise.
+ *
+ * VOICEOVER. Verified against this worktree's native source, not inferred.
+ * `RCTParagraphComponentView.mm` returns `isAccessibilityElement = NO` and delegates to
+ * `RCTParagraphComponentAccessibilityProvider.mm:60-105`, which exposes exactly ONE element
+ * whose label is the rendered attributed string, adding further elements only for nested
+ * spans whose role is "button" or "link". So a nested styled Text merges natively: the
+ * platform composes "Languages: English, Spanish, ..." with no hand-authored label to go
+ * stale against `resource.languages`. Pre-fix this line was TWO stops, the first an
+ * orphaned "Languages:" with no referent.
+ *
+ * OUT OF SCOPE, do not "fix" here: `ResourceCard`'s outer View carries
+ * `accessibilityRole="button"` + `accessibilityLabel` with no `accessible` prop, which on
+ * Fabric (`AccessibilityProps.h` defaults `accessible{false}`) makes both inert. Adding
+ * `accessible` would coopt the card into one node and swallow the phone number and the
+ * action buttons β the DEBUG-341-shaped regression this file's parent warns about.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import { Alert, Linking, Text } from 'react-native';
+
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({ navigate: jest.fn(), goBack: jest.fn() }),
+ useRoute: () => ({ params: {} }),
+ useFocusEffect: (cb: () => void) => cb(),
+}));
+
+jest.mock('@/core/analytics', () => ({
+ useAnalytics: () => ({
+ trackScreenView: jest.fn(),
+ trackCrisisResourcesViewed: jest.fn(),
+ trackCrisisHotlineTapped: jest.fn(),
+ }),
+}));
+
+jest.spyOn(Linking, 'openURL').mockResolvedValue(true);
+jest.spyOn(Linking, 'canOpenURL').mockResolvedValue(true);
+jest.spyOn(Alert, 'alert').mockImplementation(() => {});
+
+import CrisisResourcesScreen from '../CrisisResourcesScreen';
+
+const renderScreen = () => render( );
+
+/** Flatten a possibly-nested RN style prop into one object. */
+const flatten = (style: unknown): Record => {
+ if (Array.isArray(style)) return Object.assign({}, ...style.map(flatten));
+ return (style ?? {}) as Record;
+};
+
+/**
+ * The full rendered string of a node, walking nested Text spans. `children` on a
+ * paragraph with a nested label is an array, so a bare `props.children` read would
+ * return a fragment and quietly pass a two-column layout.
+ */
+const textOf = (node: { props: { children?: unknown } }): string => {
+ const walk = (child: unknown): string => {
+ if (child == null || child === false) return '';
+ if (typeof child === 'string' || typeof child === 'number') return String(child);
+ if (Array.isArray(child)) return child.map(walk).join('');
+ const el = child as { props?: { children?: unknown } };
+ return el.props ? walk(el.props.children) : '';
+ };
+ return walk(node.props.children);
+};
+
+/**
+ * The Domestic Violence Hotline is the ONLY card that renders a long Languages value.
+ * `emergency_911`'s 'Interpreter services available' (CrisisResources.ts:133) renders
+ * nowhere β it is `priority: 'emergency'`, excluded by BOTH render paths
+ * (CrisisResourcesScreen.tsx:375 filters to 'high'; :461 excludes `cat.id === 'emergency'`).
+ */
+const DV_LANGUAGES_LINE = 'Languages: English, Spanish, 200+ languages via interpreter';
+
+const LABEL = /(Phone|Text|Languages): /g;
+
+/**
+ * The contact PARAGRAPHS β label-prefixed Text nodes with no Text ancestor.
+ *
+ * The nesting is the whole point of the fix, so the pins must discriminate on it: the
+ * label span renders as its own node in the react-test-renderer tree but is NOT its own
+ * accessibility element natively (the paragraph provider adds elements only for
+ * "button"/"link" spans). A pin that merely scanned every Text would therefore see the
+ * correct structure and the pre-fix two-column structure as identical.
+ */
+const contactParagraphs = (screen: ReturnType) => {
+ const texts = screen.UNSAFE_getAllByType(Text);
+ const known = new Set(texts);
+ const hasTextAncestor = (node: (typeof texts)[number]) => {
+ let cur = node.parent;
+ while (cur) {
+ if (known.has(cur as (typeof texts)[number])) return true;
+ cur = cur.parent;
+ }
+ return false;
+ };
+ return texts.filter(n => !hasTextAncestor(n) && /^(Phone|Text|Languages): /.test(textOf(n)));
+};
+
+describe('DEBUG-560 β a contact line is one text flow', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (Linking.openURL as jest.Mock).mockResolvedValue(true);
+ (Linking.canOpenURL as jest.Mock).mockResolvedValue(true);
+ });
+
+ /**
+ * PIN A. Stated over the render output, so it survives style refactors, and it goes red
+ * the moment anyone re-splits label and value into siblings β which IS the defect. It
+ * simultaneously pins the single-VoiceOver-stop reading established above: the label
+ * and its referent are one accessibility element because they are one paragraph.
+ */
+ it('renders the Languages label and value as a single Text node', () => {
+ const screen = renderScreen();
+
+ const matches = screen.UNSAFE_getAllByType(Text).filter(n => textOf(n) === DV_LANGUAGES_LINE);
+
+ // Exactly one: two would mean a nested span also matched the whole string, which
+ // would indicate the label is not actually nested inside the value's paragraph.
+ expect(matches).toHaveLength(1);
+ });
+
+ /**
+ * The orphan-fragment guard, and the reason Pin A is not satisfiable by a stray
+ * concatenation elsewhere. A bare "Languages:" node is precisely the pre-fix shape:
+ * a label with no referent, which is both the layout defect and the VoiceOver defect.
+ */
+ it('renders no bare label fragment outside a contact paragraph', () => {
+ const screen = renderScreen();
+ const texts = screen.UNSAFE_getAllByType(Text);
+ const known = new Set(texts);
+
+ const orphans = texts
+ .filter(n => /^(Phone|Text|Languages):\s*$/.test(textOf(n)))
+ .filter(n => {
+ let cur = n.parent;
+ while (cur) {
+ if (known.has(cur as (typeof texts)[number])) return false;
+ cur = cur.parent;
+ }
+ return true;
+ })
+ .map(textOf);
+
+ // A label span nested in its paragraph is the fix. A label standing on its own is the
+ // defect: a column head with no referent, both visually and to VoiceOver.
+ expect(orphans).toEqual([]);
+ });
+
+ /**
+ * PIN B β the class guard. The honest generalization: it would have caught the original
+ * and catches the next instance, where an assertion naming `contactLabel.width` would
+ * pin only the token that was deleted and fall to a rename to `minWidth`.
+ */
+ it('gives no text-bearing node in a resource card a fixed width', () => {
+ const screen = renderScreen();
+ const offenders: string[] = [];
+ let visited = 0;
+
+ for (const node of screen.UNSAFE_getAllByType(Text)) {
+ visited += 1;
+ const style = flatten(node.props.style);
+ if (typeof style.width === 'number' || typeof style.minWidth === 'number') {
+ offenders.push(`${textOf(node).slice(0, 40)} -> ${JSON.stringify(style.width ?? style.minWidth)}`);
+ }
+ }
+
+ // Positive control, per the DEBUG-390 rule: a walker that silently stops matching is
+ // indistinguishable from a clean tree, and this one would then be permanently green.
+ expect(visited).toBeGreaterThan(0);
+
+ expect(offenders).toEqual([]);
+ });
+
+ /**
+ * The second half of Pin B's control: proof the predicate still fires. Without this,
+ * a refactor that broke the style read would leave `offenders` empty for the wrong
+ * reason and the pin above would pass on a regressed tree.
+ */
+ it('Pin B rejects a known-bad style (control)', () => {
+ const knownBad = flatten([{ fontSize: 14 }, { width: 80 }]);
+
+ expect(typeof knownBad.width === 'number' || typeof knownBad.minWidth === 'number').toBe(true);
+ });
+
+ /**
+ * Constraint 5, as amended by the crisis pass. The merge must come from the platform
+ * composing the attributed string, never from a hand-authored label β which replaces
+ * the rendered text outright (`if (accessibilityLabel.length == 0)` in the provider)
+ * and cannot be kept in sync with `resource.languages`. `accessible={false}` would
+ * erase the line, phone number included, from the accessibility tree.
+ */
+ it('carries no accessibility overrides on the contact lines', () => {
+ const screen = renderScreen();
+
+ const contactLines = screen
+ .UNSAFE_getAllByType(Text)
+ .filter(n => /^(Phone|Text|Languages): \S/.test(textOf(n)));
+
+ expect(contactLines.length).toBeGreaterThan(0);
+
+ for (const node of contactLines) {
+ expect(node.props.accessibilityLabel).toBeUndefined();
+ expect(node.props.accessible).toBeUndefined();
+ expect(node.props.accessibilityRole).toBeUndefined();
+ // Constraint 4 β truncating language information on a crisis card is content loss.
+ expect(node.props.numberOfLines).toBeUndefined();
+ expect(node.props.maxFontSizeMultiplier).toBeUndefined();
+ expect(node.props.allowFontScaling).toBeUndefined();
+ }
+ });
+});
+
+describe('DEBUG-560 β the fix stays in its lane', () => {
+ /**
+ * Constraint 3. The two `priority: 'high'` cards carry `languages: ['English','Spanish']`,
+ * which does not spill, so a correct fix costs them zero height. This cannot measure
+ * height, but it pins the input to it: the joined value these cards render is unchanged.
+ */
+ it('keeps each contact line a separate paragraph', () => {
+ const screen = renderScreen();
+ const paragraphs = contactParagraphs(screen);
+
+ expect(paragraphs.length).toBeGreaterThan(0);
+
+ // Constraint 5: the three lines stay three paragraphs. Collapsing Phone/Text/Languages
+ // into one Text would also merge them into a single VoiceOver stop, which is a
+ // different change from merging a label with its own value.
+ for (const node of paragraphs) {
+ expect(textOf(node).match(LABEL)).toHaveLength(1);
+ }
+
+ // The long value the Sentry report was about is one of them, unsplit.
+ expect(paragraphs.map(textOf)).toContain(DV_LANGUAGES_LINE);
+ });
+
+ /**
+ * Constraint 1. The fix touches ResourceCard and the contact styles only; the 988
+ * control's position is a property of `crisisFooter` being a sibling of the ScrollView.
+ * `CrisisResourcesScreen.reachability.test.tsx` is authoritative for that invariant β
+ * this is the cheap tripwire that the count did not move under this diff.
+ */
+ it('still renders exactly one 988 control', () => {
+ const screen = renderScreen();
+
+ expect(screen.getAllByTestId('crisis-call-988-button')).toHaveLength(1);
+ });
+});
From 94e46ee7b8fcb199bc6b383ebb045191834449c9 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:54:19 -0700
Subject: [PATCH 40/90] chore: INFRA-542 wire app_opened / app_backgrounded
(slice 1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Scoped to lifecycle emitters at batch approval. session_id rotation is carved
off to INFRA-568; session_started/session_ended stay with INFRA-552.
The filed AC was unimplementable. It pinned the wiring site to App.tsx's
AppState listener β but App() is the component that RENDERS ,
so that listener sits ABOVE it, usePostHog() is undefined there, and trackEvent
early-returns. Wiring the emits in place would have compiled, type-checked,
passed review, and passed any test mocking usePostHog while transmitting
nothing. That is the FEAT-137 shape.
The listener MOVES to a new null-rendering AppLifecycleTracker; it does not gain
a sibling, so "do not add a second listener" holds by construction.
AppLifecycleTracker renders in BOTH PostHogProvider branches, which is
load-bearing rather than defensive. The listener does two unrelated jobs, and
the always-on setLastActiveTimestamp write feeds CleanHomeScreen's intro
animation. Mounting only inside the consent-gated branch reads as
tidier and silently breaks that animation for every non-consenting user. Both
halves are pinned and both pins were mutation-verified: gating the write on a
live client reds 2 of 9 cases and leaves 7 green; dropping the tracker from the
ungated branch reds 2 of 3 in the branch suite and leaves the gated case green.
Payloads verified against the live PHIFilter, not against the item's text.
seconds_since_last_active is absent from SAFE_NUMERIC_KEYS, so an unlisted
numeric key would discard the whole event; since_last_active is a closed
bucket enum instead, failing closed to `unknown`. duration_seconds is
whitelisted in both key lists and means FOREGROUND DWELL on app_backgrounded
only β one key meaning dwell here and time-away on app_opened would make any
aggregate over it meaningless.
The first-open marker is consumed only when a client exists. consumeColdStart
CONSUMES it, so running it while the event would be dropped loses the first
open permanently.
compliance (INFRA-542): the marker is a device-level install anchor holding no
wellness content β the AsyncStorage analogue of auth_device_id β and must
SURVIVE clearAllWellnessData on both branches. It carries none of
SWEPT_ASYNC_PREFIXES and is not in SWEPT_EXACT_KEYS; a test asserts that at its
definition site so a later pass cannot "fix" it into the sweep. Same pass ruled
since_last_active falls outside the five disclosed analytics categories, so one
bullet is added to the privacy policy and its mirror in the architecture doc.
duration_seconds needed no doc change β it is already "Session duration".
captureAppLifecycleEvents stays false; the comment claiming "we handle this
ourselves" is now true rather than aspirational.
Tests: 48 passed across the 5 analytics suites (incl. DEBUG-557's consent-remount
suite, unmodified); test:privacy 686 passed / 38 suites.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
app/.eslint-baseline.json | 5 +-
app/App.tsx | 32 +---
.../analyticsTrackerContract.privacy.test.ts | 6 +-
.../core/analytics/AppLifecycleTracker.tsx | 104 +++++++++++
app/src/core/analytics/PostHogProvider.tsx | 25 ++-
.../__tests__/AppLifecycleTracker.test.tsx | 166 +++++++++++++++++
.../PostHogProvider.lifecycleTracker.test.tsx | 125 +++++++++++++
.../__tests__/appLifecycleTelemetry.test.ts | 171 ++++++++++++++++++
.../core/analytics/appLifecycleTelemetry.ts | 97 ++++++++++
app/src/core/analytics/useAnalytics.ts | 31 +++-
docs/architecture/analytics-architecture.md | 5 +-
docs/legal/privacy-policy.md | 1 +
12 files changed, 729 insertions(+), 39 deletions(-)
create mode 100644 app/src/core/analytics/AppLifecycleTracker.tsx
create mode 100644 app/src/core/analytics/__tests__/AppLifecycleTracker.test.tsx
create mode 100644 app/src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx
create mode 100644 app/src/core/analytics/__tests__/appLifecycleTelemetry.test.ts
create mode 100644 app/src/core/analytics/appLifecycleTelemetry.ts
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index a247ca3d..b8f133c0 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -225,5 +225,8 @@
"src/features/journal/services/__tests__/journalPreview.unit.test.ts": 1,
"src/core/analytics/__tests__/analyticsIdentityReset.privacy.test.ts": 1,
"src/core/services/security/__tests__/accountDeletionAttestationDurability.privacy.test.ts": 1,
- "src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx": 1
+ "src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx": 1,
+ "src/core/analytics/__tests__/AppLifecycleTracker.test.tsx": 1,
+ "src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx": 1,
+ "src/core/analytics/__tests__/appLifecycleTelemetry.test.ts": 1
}
diff --git a/app/App.tsx b/app/App.tsx
index ce1c0b13..a6c38e11 100644
--- a/app/App.tsx
+++ b/app/App.tsx
@@ -3,8 +3,8 @@
* Evidence-based mindfulness and cognitive therapy for mental wellness
*/
-import React, { useEffect, useState, useRef } from 'react';
-import { AppState, AppStateStatus, LogBox } from 'react-native';
+import React, { useEffect, useState } from 'react';
+import { LogBox } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -17,7 +17,6 @@ import { logCrisis } from './src/core/services/logging';
import { IAPService } from './src/core/services/subscription/IAPService';
import { useSubscriptionStore } from './src/core/stores/subscriptionStore';
import EncryptionService from './src/core/services/security/EncryptionService';
-import { useSettingsStore } from './src/core/stores/settingsStore';
import { initializeExternalReporting, logSystem, logError, LogCategory } from './src/core/services/logging';
import { sweepStaleAudioArtifacts } from './src/core/services/speech/audioArtifactSweeper';
import { sweepLegacyPlaintextRecords } from './src/core/services/security/legacyPlaintextRecordSweeper';
@@ -44,7 +43,6 @@ if (__DEV__ && process.env['EXPO_PUBLIC_E2E_SUPPRESS_DEV_MENU'] === '1') {
function App() {
const [isInitialized, setIsInitialized] = useState(false);
- const appState = useRef(AppState.currentState);
// FEAT-284: shake-to-report (internal builds only; no-ops when the
// bug_reporting flag is off or Sentry has no DSN).
@@ -175,26 +173,12 @@ function App() {
return () => clearTimeout(t);
}, []);
- // Track app state changes to update lastActiveTimestamp for intro animation
- useEffect(() => {
- const handleAppStateChange = (nextAppState: AppStateStatus) => {
- // When app goes to background or becomes inactive, record timestamp
- if (
- appState.current === 'active' &&
- (nextAppState === 'background' || nextAppState === 'inactive')
- ) {
- logSystem('App backgrounded, recording lastActive timestamp');
- useSettingsStore.getState().setLastActiveTimestamp(Date.now());
- }
- appState.current = nextAppState;
- };
-
- const subscription = AppState.addEventListener('change', handleAppStateChange);
-
- return () => {
- subscription?.remove();
- };
- }, []);
+ // INFRA-542: the AppState listener that recorded lastActiveTimestamp used to
+ // live here. It MOVED β it did not gain a sibling β to AppLifecycleTracker,
+ // rendered under below. This component renders that
+ // provider, so a listener here sits ABOVE it, where usePostHog() is
+ // undefined and any analytics emit would silently early-return forever.
+ // The timestamp write itself is unchanged and still unconditional.
// Render app immediately - migration runs in background
return (
diff --git a/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
index 83374023..daab5dfb 100644
--- a/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
+++ b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
@@ -45,8 +45,10 @@ import { PHIFilter, AnalyticsEvents } from '@/core/analytics/PHIFilter';
*/
const FIXTURES: Readonly> = {
trackScreenView: ['HomeScreen'],
- trackAppOpened: [],
- trackAppBackgrounded: [],
+ // INFRA-542: real call shapes β these two gained emitters and
+ // properties. A bucketed string, never a raw elapsed number.
+ trackAppOpened: [true, 'cold_start'],
+ trackAppBackgrounded: [42],
trackCheckInStarted: [],
trackCheckInCompleted: [5000],
trackAssessmentStarted: [],
diff --git a/app/src/core/analytics/AppLifecycleTracker.tsx b/app/src/core/analytics/AppLifecycleTracker.tsx
new file mode 100644
index 00000000..92be7968
--- /dev/null
+++ b/app/src/core/analytics/AppLifecycleTracker.tsx
@@ -0,0 +1,104 @@
+/**
+ * AppLifecycleTracker (INFRA-542)
+ *
+ * Owns the app's single `AppState` listener for two unrelated jobs:
+ * 1. the ALWAYS-ON `setLastActiveTimestamp` write that feeds the Home
+ * intro animation, and
+ * 2. the CONSENT-GATED `app_opened` / `app_backgrounded` emits.
+ *
+ * WHY THIS COMPONENT EXISTS. The listener used to live in `App.tsx`, which is
+ * the component that RENDERS `` β so it sat above the
+ * provider, `usePostHog()` returned undefined there, and any emit added at
+ * that site would have early-returned in `trackEvent` forever. It would have
+ * compiled, type-checked, passed review, and passed any test that mocks
+ * `usePostHog`. That is the FEAT-137 shape: instrumentation that looks correct
+ * and transmits nothing.
+ *
+ * WHY IT RENDERS IN BOTH PROVIDER BRANCHES. Job (1) is not analytics and must
+ * keep running for a user who has not consented. Mounting this only inside the
+ * gated `` branch would silently stop the intro animation's
+ * timestamp for every non-consenting user. Outside a provider `usePostHog()`
+ * returns undefined, so job (2) becomes a no-op on its own β no extra gating
+ * needed, and none should be added.
+ *
+ * The client is read through the React context hook ONLY. Never reach for the
+ * module-scope reference in `analyticsIdentityReset` β that one deliberately
+ * outlives unmount so erasure can reset it, and emitting through it would fire
+ * events for a user who has revoked consent.
+ */
+
+import { useEffect, useRef } from 'react';
+import { AppState, AppStateStatus } from 'react-native';
+import { usePostHog } from 'posthog-react-native';
+import { useAnalytics } from './useAnalytics';
+import { bucketSinceLastActive, consumeColdStart } from './appLifecycleTelemetry';
+import { useSettingsStore } from '@/core/stores/settingsStore';
+
+function isBackgroundish(state: AppStateStatus): boolean {
+ return state === 'background' || state === 'inactive';
+}
+
+export function AppLifecycleTracker(): null {
+ const posthog = usePostHog();
+ const { trackAppOpened, trackAppBackgrounded } = useAnalytics();
+
+ // `?? 'active'` because AppState.currentState is null on Android before the
+ // first change event; without it the first background transition is missed.
+ const appState = useRef(AppState.currentState ?? 'active');
+ const activatedAt = useRef(Date.now());
+ /** When this process last went to background. Null until it does. */
+ const backgroundedAt = useRef(null);
+ const emittedOpenForThisMount = useRef(false);
+
+ // Mount emit. Gated on a live client for a specific reason: consumeColdStart
+ // CONSUMES the marker, so running it while the event would be dropped loses
+ // the first open permanently. Granting consent remounts this whole subtree
+ // (pinned by PostHogProvider.consentRemount.privacy.test.tsx), so a user who
+ // opts in still gets a mount with a client present.
+ useEffect(() => {
+ if (!posthog || emittedOpenForThisMount.current) return;
+ emittedOpenForThisMount.current = true;
+
+ let cancelled = false;
+ void (async (): Promise => {
+ const isColdStart = await consumeColdStart();
+ if (cancelled) return;
+ const lastActiveAt = useSettingsStore.getState().getLastActiveTimestamp();
+ trackAppOpened(isColdStart, bucketSinceLastActive(lastActiveAt, Date.now()));
+ })();
+
+ return (): void => {
+ cancelled = true;
+ };
+ }, [posthog, trackAppOpened]);
+
+ useEffect(() => {
+ const handleAppStateChange = (nextAppState: AppStateStatus): void => {
+ const previous = appState.current;
+ const now = Date.now();
+
+ if (previous === 'active' && isBackgroundish(nextAppState)) {
+ backgroundedAt.current = now;
+ // Unconditional: this is the intro-animation timestamp, not analytics.
+ void useSettingsStore.getState().setLastActiveTimestamp(now);
+ // Foreground dwell β how long the app was open, never time away.
+ trackAppBackgrounded(Math.max(0, Math.round((now - activatedAt.current) / 1000)));
+ } else if (isBackgroundish(previous) && nextAppState === 'active') {
+ activatedAt.current = now;
+ // A re-foreground is never a first open, whatever the marker says.
+ trackAppOpened(false, bucketSinceLastActive(backgroundedAt.current, now));
+ }
+
+ appState.current = nextAppState;
+ };
+
+ const subscription = AppState.addEventListener('change', handleAppStateChange);
+ return (): void => {
+ subscription?.remove();
+ };
+ }, [trackAppOpened, trackAppBackgrounded]);
+
+ return null;
+}
+
+export default AppLifecycleTracker;
diff --git a/app/src/core/analytics/PostHogProvider.tsx b/app/src/core/analytics/PostHogProvider.tsx
index 955b3e6c..97ad7650 100644
--- a/app/src/core/analytics/PostHogProvider.tsx
+++ b/app/src/core/analytics/PostHogProvider.tsx
@@ -13,6 +13,7 @@
import React from 'react';
import { PostHogProvider as PHProvider, usePostHog } from 'posthog-react-native';
import { registerAnalyticsClient } from './analyticsIdentityReset';
+import { AppLifecycleTracker } from './AppLifecycleTracker';
import { useConsentStore } from '@/core/stores/consentStore';
import { env } from '@/core/config/env';
@@ -76,8 +77,18 @@ export function PostHogProvider({ children }: PostHogProviderProps): React.React
!analyticsEnabled ||
universalOptOut
) {
- // Development mode, no consent, or honoring universal opt-out β render children without PostHog
- return <>{children}>;
+ // Development mode, no consent, or honoring universal opt-out β render children without PostHog.
+ // AppLifecycleTracker still mounts here: it owns the always-on
+ // setLastActiveTimestamp write that feeds the Home intro animation, which
+ // is not analytics and must keep working without consent. Its emits
+ // self-disable β usePostHog() is undefined outside , so
+ // trackEvent early-returns (INFRA-542).
+ return (
+ <>
+
+ {children}
+ >
+ );
}
return (
@@ -95,11 +106,17 @@ export function PostHogProvider({ children }: PostHogProviderProps): React.React
flushAt: 10, // Batch 10 events before sending
flushInterval: 30000, // Or flush every 30 seconds
- // Don't capture device identifiers automatically
- captureAppLifecycleEvents: false, // We handle this ourselves
+ // Don't capture device identifiers automatically.
+ // Stays false, and since INFRA-542 the claim below is true:
+ // AppLifecycleTracker (mounted just under this provider) emits our own
+ // app_opened / app_backgrounded with a first-open marker and a coarse
+ // time-away bucket that pass PHIFilter. Enabling PostHog's own
+ // Application Installed/Opened/Backgrounded would double-count them.
+ captureAppLifecycleEvents: false, // We handle this ourselves β see AppLifecycleTracker
}}
>
+
{children}
);
diff --git a/app/src/core/analytics/__tests__/AppLifecycleTracker.test.tsx b/app/src/core/analytics/__tests__/AppLifecycleTracker.test.tsx
new file mode 100644
index 00000000..f39d72f8
--- /dev/null
+++ b/app/src/core/analytics/__tests__/AppLifecycleTracker.test.tsx
@@ -0,0 +1,166 @@
+/**
+ * AppLifecycleTracker β INFRA-542.
+ *
+ * This component exists because the listener it replaces sat ABOVE
+ * in App.tsx, where `usePostHog()` is undefined and
+ * `trackEvent` early-returns. Wiring the emits there would have compiled,
+ * type-checked, passed review and transmitted nothing β the FEAT-137 shape.
+ *
+ * The two contracts worth pinning are therefore:
+ * 1. the emits happen where a client can exist, and
+ * 2. the `setLastActiveTimestamp` write β which feeds the Home intro
+ * animation and is NOT analytics β still runs for a user who has not
+ * consented, i.e. when there is no PostHog client at all.
+ * (2) is the regression this component's placement is designed to avoid, and
+ * it is invisible to any test that only checks the events.
+ */
+
+import React from 'react';
+import { AppState } from 'react-native';
+import { render, waitFor } from '@testing-library/react-native';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { AppLifecycleTracker } from '../AppLifecycleTracker';
+import { useSettingsStore } from '@/core/stores/settingsStore';
+
+const mockCapture = jest.fn();
+let mockPosthogAvailable = true;
+
+jest.mock('posthog-react-native', () => ({
+ usePostHog: () =>
+ mockPosthogAvailable ? { capture: (...args: unknown[]) => mockCapture(...args) } : undefined,
+}));
+
+const setLastActiveTimestamp = jest.fn().mockResolvedValue(undefined);
+
+/** Drive the AppState listener the component registered. */
+function emitAppState(next: 'active' | 'inactive' | 'background'): void {
+ const calls = (AppState.addEventListener as unknown as jest.Mock).mock.calls;
+ const handler = calls[calls.length - 1]?.[1] as (s: string) => void;
+ handler(next);
+}
+
+describe('AppLifecycleTracker (INFRA-542)', () => {
+ beforeEach(() => {
+ mockCapture.mockClear();
+ setLastActiveTimestamp.mockClear();
+ mockPosthogAvailable = true;
+
+ (AsyncStorage.getItem as jest.Mock).mockReset().mockResolvedValue('1');
+ (AsyncStorage.setItem as jest.Mock).mockReset().mockResolvedValue(undefined);
+
+ jest.spyOn(AppState, 'addEventListener').mockReturnValue({
+ remove: jest.fn(),
+ } as unknown as ReturnType);
+
+ jest.spyOn(useSettingsStore, 'getState').mockReturnValue({
+ setLastActiveTimestamp,
+ getLastActiveTimestamp: () => null,
+ } as unknown as ReturnType);
+ });
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it('renders nothing', () => {
+ const { toJSON } = render( );
+ expect(toJSON()).toBeNull();
+ });
+
+ it('emits app_opened on mount with is_cold_start true on a first-ever launch', async () => {
+ (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
+
+ render( );
+
+ await waitFor(() =>
+ expect(mockCapture).toHaveBeenCalledWith(
+ 'app_opened',
+ expect.objectContaining({ is_cold_start: true })
+ )
+ );
+ });
+
+ it('emits app_opened on mount with is_cold_start false once the marker exists', async () => {
+ (AsyncStorage.getItem as jest.Mock).mockResolvedValue('1');
+
+ render( );
+
+ await waitFor(() =>
+ expect(mockCapture).toHaveBeenCalledWith(
+ 'app_opened',
+ expect.objectContaining({ is_cold_start: false })
+ )
+ );
+ });
+
+ it('emits app_opened again on a background -> active transition, never as a cold start', async () => {
+ render( );
+ await waitFor(() => expect(mockCapture).toHaveBeenCalledTimes(1));
+ mockCapture.mockClear();
+
+ emitAppState('background');
+ emitAppState('active');
+
+ await waitFor(() =>
+ expect(mockCapture).toHaveBeenCalledWith(
+ 'app_opened',
+ expect.objectContaining({ is_cold_start: false })
+ )
+ );
+ });
+
+ it('emits app_backgrounded with a numeric duration_seconds on active -> background', async () => {
+ render( );
+ await waitFor(() => expect(mockCapture).toHaveBeenCalledTimes(1));
+ mockCapture.mockClear();
+
+ emitAppState('background');
+
+ await waitFor(() => {
+ const call = mockCapture.mock.calls.find(([name]) => name === 'app_backgrounded');
+ expect(call).toBeDefined();
+ expect(typeof (call?.[1] as { duration_seconds: unknown }).duration_seconds).toBe('number');
+ });
+ });
+
+ it('writes lastActiveTimestamp exactly once per backgrounding', async () => {
+ render( );
+ await waitFor(() => expect(mockCapture).toHaveBeenCalledTimes(1));
+
+ emitAppState('background');
+
+ await waitFor(() => expect(setLastActiveTimestamp).toHaveBeenCalledTimes(1));
+ });
+
+ it('still writes lastActiveTimestamp when there is NO PostHog client', async () => {
+ // The regression this component's placement exists to prevent: mounting it
+ // only inside the consent-gated provider branch would silently stop the
+ // Home intro animation's timestamp for every non-consenting user.
+ mockPosthogAvailable = false;
+
+ render( );
+ emitAppState('background');
+
+ await waitFor(() => expect(setLastActiveTimestamp).toHaveBeenCalledTimes(1));
+ expect(mockCapture).not.toHaveBeenCalled();
+ });
+
+ it('emits nothing at all without a PostHog client', async () => {
+ mockPosthogAvailable = false;
+
+ render( );
+ emitAppState('background');
+ emitAppState('active');
+
+ await waitFor(() => expect(setLastActiveTimestamp).toHaveBeenCalled());
+ expect(mockCapture).not.toHaveBeenCalled();
+ });
+
+ it('removes its AppState subscription on unmount', () => {
+ const remove = jest.fn();
+ (AppState.addEventListener as unknown as jest.Mock).mockReturnValue({ remove });
+
+ const { unmount } = render( );
+ unmount();
+
+ expect(remove).toHaveBeenCalled();
+ });
+});
diff --git a/app/src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx b/app/src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx
new file mode 100644
index 00000000..63164360
--- /dev/null
+++ b/app/src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx
@@ -0,0 +1,125 @@
+/**
+ * INFRA-542 β AppLifecycleTracker mounts in BOTH PostHogProvider branches.
+ *
+ * WHY THIS PIN EXISTS. The tracker owns two unrelated jobs: the always-on
+ * `setLastActiveTimestamp` write that feeds the Home intro animation, and the
+ * consent-gated `app_opened` / `app_backgrounded` emits. Mounting it only
+ * inside the gated `` branch reads as the tidier arrangement and
+ * is silently wrong β it stops the intro-animation timestamp for every user
+ * who has not consented to analytics, with no failing test and nothing
+ * user-visible until someone notices Home animating differently.
+ *
+ * The tracker's own suite proves the write survives a missing PostHog client.
+ * It cannot prove the component is RENDERED on the path where the client is
+ * missing. That is this file's only job.
+ *
+ * The env override below is load-bearing, for the reason DEBUG-557's
+ * consent-remount suite documents at length: `__tests__/setup/env.mock.js`
+ * blanks EXPO_PUBLIC_POSTHOG_API_KEY for every jest run and PostHogProvider
+ * reads it at MODULE SCOPE, so without the override BOTH consent states render
+ * the fragment branch β and this file would pass while testing one branch
+ * twice. The branch-entered control asserts the override actually took.
+ */
+
+jest.mock('@/core/config/env', () => {
+ const actual = jest.requireActual('@/core/config/env');
+ return {
+ ...actual,
+ env: {
+ ...actual.env,
+ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_infra542_branch_pin',
+ EXPO_PUBLIC_POSTHOG_HOST: 'https://eu.i.posthog.com',
+ },
+ };
+});
+
+const PH_BRANCH_TEST_ID = 'infra542-ph-provider-branch';
+jest.mock('posthog-react-native', () => {
+ const ReactActual = require('react');
+ const { View } = require('react-native');
+ return {
+ __esModule: true,
+ PostHogProvider: ({ children }: { children: React.ReactNode }) =>
+ ReactActual.createElement(View, { testID: 'infra542-ph-provider-branch' }, children),
+ usePostHog: () => null,
+ };
+});
+
+const TRACKER_TEST_ID = 'infra542-lifecycle-tracker';
+jest.mock('../AppLifecycleTracker', () => {
+ const ReactActual = require('react');
+ const { View } = require('react-native');
+ return {
+ __esModule: true,
+ AppLifecycleTracker: () =>
+ ReactActual.createElement(View, { testID: 'infra542-lifecycle-tracker' }),
+ default: () => ReactActual.createElement(View, { testID: 'infra542-lifecycle-tracker' }),
+ };
+});
+
+import React from 'react';
+import { Text } from 'react-native';
+import { render } from '@testing-library/react-native';
+import { PostHogProvider } from '../PostHogProvider';
+import { useConsentStore } from '@/core/stores/consentStore';
+
+function setAnalyticsConsent(enabled: boolean): void {
+ useConsentStore.setState({
+ currentConsent: {
+ preferences: { analyticsEnabled: enabled },
+ universalOptOut: false,
+ },
+ } as unknown as Parameters[0]);
+}
+
+function renderProvider() {
+ return render(
+
+ child
+
+ );
+}
+
+describe('PostHogProvider mounts AppLifecycleTracker in both branches (INFRA-542)', () => {
+ beforeEach(() => {
+ useConsentStore.setState({ currentConsent: null } as unknown as Parameters<
+ typeof useConsentStore.setState
+ >[0]);
+ });
+
+ it('mounts the tracker on the UNGATED branch (analytics consent off)', () => {
+ setAnalyticsConsent(false);
+ const { getByTestId, queryByTestId } = renderProvider();
+
+ // Control: we really are on the fragment branch, not the provider one.
+ expect(queryByTestId(PH_BRANCH_TEST_ID)).toBeNull();
+ expect(getByTestId('infra542-child')).toBeTruthy();
+
+ // The assertion this file exists for.
+ expect(getByTestId(TRACKER_TEST_ID)).toBeTruthy();
+ });
+
+ it('mounts the tracker on the GATED branch (analytics consent on)', () => {
+ setAnalyticsConsent(true);
+ const { getByTestId } = renderProvider();
+
+ // Control: the env override took and we crossed into . Without
+ // this, a blanked API key would put both cases on the fragment branch and
+ // this suite would pass having never tested the gated path.
+ expect(getByTestId(PH_BRANCH_TEST_ID)).toBeTruthy();
+
+ expect(getByTestId(TRACKER_TEST_ID)).toBeTruthy();
+ });
+
+ it('mounts exactly one tracker per branch', () => {
+ // A second listener is what the item's AC forbids: the fix RELOCATES the
+ // App.tsx listener, it does not add a sibling. Two mounted trackers would
+ // double every emit and double-write lastActiveTimestamp.
+ for (const enabled of [false, true]) {
+ setAnalyticsConsent(enabled);
+ const { getAllByTestId, unmount } = renderProvider();
+ expect(getAllByTestId(TRACKER_TEST_ID)).toHaveLength(1);
+ unmount();
+ }
+ });
+});
diff --git a/app/src/core/analytics/__tests__/appLifecycleTelemetry.test.ts b/app/src/core/analytics/__tests__/appLifecycleTelemetry.test.ts
new file mode 100644
index 00000000..918babef
--- /dev/null
+++ b/app/src/core/analytics/__tests__/appLifecycleTelemetry.test.ts
@@ -0,0 +1,171 @@
+/**
+ * App-lifecycle telemetry helpers β INFRA-542.
+ *
+ * Two mechanisms, both of which fail SILENTLY in production if wrong:
+ * - `bucketSinceLastActive` must only ever produce one of the six enum
+ * values compliance approved. A raw elapsed value leaking through would
+ * be a disclosure the privacy policy does not make.
+ * - `consumeColdStart` must fail CLOSED. A read error that returned `true`
+ * would report a fresh install on every launch, which is worse than no
+ * first-open marker at all because it looks like data.
+ *
+ * The last block is the load-bearing one: every emitted payload is asserted
+ * against the real `PHIFilter.validate`. `trackEvent` logs and returns on a
+ * rejected payload β nothing throws β so an unvalidated property is invisible
+ * to every other test in this file and to review.
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import {
+ FIRST_OPEN_MARKER_KEY,
+ SINCE_LAST_ACTIVE_BUCKETS,
+ bucketSinceLastActive,
+ consumeColdStart,
+} from '../appLifecycleTelemetry';
+import { PHIFilter, AnalyticsEvents } from '../PHIFilter';
+
+const MINUTE = 60 * 1000;
+const HOUR = 60 * MINUTE;
+const NOW = 1_700_000_000_000;
+
+describe('bucketSinceLastActive (INFRA-542)', () => {
+ it('reports cold_start when there is no prior timestamp', () => {
+ expect(bucketSinceLastActive(null, NOW)).toBe('cold_start');
+ expect(bucketSinceLastActive(undefined, NOW)).toBe('cold_start');
+ });
+
+ // Boundaries, not midpoints: an off-by-one here silently reassigns a whole
+ // cohort and no consumer of the data could ever notice.
+ it.each([
+ ['at 0ms elapsed', NOW, 'lt_5m'],
+ ['just under 5m', NOW - (5 * MINUTE - 1), 'lt_5m'],
+ ['exactly 5m', NOW - 5 * MINUTE, '5m_30m'],
+ ['just under 30m', NOW - (30 * MINUTE - 1), '5m_30m'],
+ ['exactly 30m', NOW - 30 * MINUTE, '30m_24h'],
+ ['just under 24h', NOW - (24 * HOUR - 1), '30m_24h'],
+ ['exactly 24h', NOW - 24 * HOUR, 'gt_24h'],
+ ['well past 24h', NOW - 400 * HOUR, 'gt_24h'],
+ ])('%s', (_label, lastActiveAt, expected) => {
+ expect(bucketSinceLastActive(lastActiveAt as number, NOW)).toBe(expected);
+ });
+
+ it('fails closed to unknown on a future timestamp (clock skew)', () => {
+ expect(bucketSinceLastActive(NOW + 5 * MINUTE, NOW)).toBe('unknown');
+ });
+
+ it('fails closed to unknown on a non-finite timestamp', () => {
+ expect(bucketSinceLastActive(Number.NaN, NOW)).toBe('unknown');
+ expect(bucketSinceLastActive(Number.POSITIVE_INFINITY, NOW)).toBe('unknown');
+ });
+
+ it('never returns a value outside the approved enum', () => {
+ const probes = [
+ null,
+ undefined,
+ Number.NaN,
+ NOW,
+ NOW + 1,
+ NOW - 1,
+ NOW - 7 * MINUTE,
+ NOW - 3 * HOUR,
+ NOW - 90 * HOUR,
+ 0,
+ -1,
+ ];
+ for (const probe of probes) {
+ expect(SINCE_LAST_ACTIVE_BUCKETS).toContain(
+ bucketSinceLastActive(probe as number | null, NOW)
+ );
+ }
+ });
+});
+
+describe('consumeColdStart (INFRA-542)', () => {
+ beforeEach(() => {
+ (AsyncStorage.getItem as jest.Mock).mockReset();
+ (AsyncStorage.setItem as jest.Mock).mockReset();
+ (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined);
+ });
+
+ it('reports a first open and persists the marker when absent', async () => {
+ (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
+
+ await expect(consumeColdStart()).resolves.toBe(true);
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith(FIRST_OPEN_MARKER_KEY, expect.any(String));
+ });
+
+ it('reports a return and does not rewrite the marker when present', async () => {
+ (AsyncStorage.getItem as jest.Mock).mockResolvedValue('1');
+
+ await expect(consumeColdStart()).resolves.toBe(false);
+ expect(AsyncStorage.setItem).not.toHaveBeenCalled();
+ });
+
+ it('fails closed to false when the read throws', async () => {
+ (AsyncStorage.getItem as jest.Mock).mockRejectedValue(new Error('storage unavailable'));
+
+ // false, not true: a broken read must never be reported as a fresh install.
+ await expect(consumeColdStart()).resolves.toBe(false);
+ });
+
+ it('still reports the first open when the marker WRITE throws', async () => {
+ (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
+ (AsyncStorage.setItem as jest.Mock).mockRejectedValue(new Error('disk full'));
+
+ // The read is what decides; a failed write must not throw into app launch.
+ await expect(consumeColdStart()).resolves.toBe(true);
+ });
+
+ it('is not swept by account erasure β the key carries no swept prefix', () => {
+ // compliance ruling (INFRA-542): this marker is a device-level install
+ // anchor holding no wellness content, the AsyncStorage analogue of
+ // auth_device_id. It must SURVIVE clearAllWellnessData on both branches.
+ // Asserted here rather than in the erasure suite so that a future change
+ // giving it a swept prefix fails at its definition site.
+ const sweptPrefixes = [
+ 'crisis_async_',
+ 'assessment_async_',
+ 'wellness_async_',
+ 'wellness_migrated:',
+ 'audit_log_',
+ ];
+ for (const prefix of sweptPrefixes) {
+ expect(FIRST_OPEN_MARKER_KEY.startsWith(prefix)).toBe(false);
+ }
+ });
+});
+
+describe('emitted payloads survive PHIFilter (INFRA-542)', () => {
+ // The whole point of the item. PHIFilter drops a violating event WHOLE and
+ // only logs, so without this block a wrong key ships as a permanent zero.
+ it.each(SINCE_LAST_ACTIVE_BUCKETS.map((b) => [b] as const))(
+ 'app_opened validates with since_last_active=%s',
+ (bucket) => {
+ for (const isColdStart of [true, false]) {
+ const result = PHIFilter.validate(AnalyticsEvents.APP_OPENED, {
+ is_cold_start: isColdStart,
+ since_last_active: bucket,
+ });
+ expect(result).toEqual({ valid: true });
+ }
+ }
+ );
+
+ it('app_backgrounded validates with a raw duration_seconds', () => {
+ for (const seconds of [0, 1, 42, 86_400]) {
+ expect(
+ PHIFilter.validate(AnalyticsEvents.APP_BACKGROUNDED, { duration_seconds: seconds })
+ ).toEqual({ valid: true });
+ }
+ });
+
+ it('the validator still rejects the key this design deliberately avoids', () => {
+ // Non-vacuity guard (DEBUG-390): proves the assertions above are passing
+ // on real validation rather than on a matcher that fires for anything.
+ // `seconds_since_last_active` is absent from SAFE_NUMERIC_KEYS, which is
+ // precisely why this item emits a bucketed string instead.
+ expect(
+ PHIFilter.validate(AnalyticsEvents.APP_OPENED, { seconds_since_last_active: 300 })
+ ).not.toEqual({ valid: true });
+ });
+});
diff --git a/app/src/core/analytics/appLifecycleTelemetry.ts b/app/src/core/analytics/appLifecycleTelemetry.ts
new file mode 100644
index 00000000..9feee0e3
--- /dev/null
+++ b/app/src/core/analytics/appLifecycleTelemetry.ts
@@ -0,0 +1,97 @@
+/**
+ * App-lifecycle telemetry helpers (INFRA-542).
+ *
+ * Supports `app_opened` / `app_backgrounded`, which had no emitter at all
+ * before this item β PostHog's own `captureAppLifecycleEvents` is off, so the
+ * product had no first-open marker anywhere and installs were uncohortable.
+ */
+
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+/**
+ * Device-level "this install has launched before" anchor.
+ *
+ * DELIBERATELY EXCLUDED FROM ERASURE (compliance ruling, INFRA-542) β mirrors
+ * the `auth_device_id` exclusion in SecureStorageService. This key holds a
+ * boolean install-state fact: no wellness content, no PII, no user identifier.
+ * It must SURVIVE `clearAllWellnessData` on both the logout and the
+ * delete-master-key branch, so it carries none of `SWEPT_ASYNC_PREFIXES`
+ * (`crisis_async_`, `assessment_async_`, `wellness_async_`,
+ * `wellness_migrated:`, `audit_log_`) and is not in `SWEPT_EXACT_KEYS`.
+ * Do not "fix" it into the sweep β that would report a fresh install to
+ * analytics every time a user clears their data.
+ */
+export const FIRST_OPEN_MARKER_KEY = '@being/analytics_has_launched_before';
+
+/**
+ * The closed enum for `app_opened`'s `since_last_active` property.
+ *
+ * A bucket, never a raw elapsed value: coarse ranges spanning minutes to
+ * multiple days cannot reconstruct a near-exact timestamp. `unknown` is the
+ * fail-closed value β every error path lands here rather than emitting a
+ * number. Widening or narrowing this list changes what the privacy policy
+ * discloses; it is not a free implementation detail.
+ */
+export const SINCE_LAST_ACTIVE_BUCKETS = [
+ 'cold_start',
+ 'lt_5m',
+ '5m_30m',
+ '30m_24h',
+ 'gt_24h',
+ 'unknown',
+] as const;
+
+export type SinceLastActiveBucket = (typeof SINCE_LAST_ACTIVE_BUCKETS)[number];
+
+const MINUTE_MS = 60 * 1000;
+const HOUR_MS = 60 * MINUTE_MS;
+
+/**
+ * Coarsen "time since the app was last active" into an approved bucket.
+ *
+ * `null`/`undefined` means no prior timestamp exists β a genuine first launch.
+ * Anything non-finite, or a last-active in the future (clock skew), fails
+ * closed to `unknown`.
+ */
+export function bucketSinceLastActive(
+ lastActiveAt: number | null | undefined,
+ now: number
+): SinceLastActiveBucket {
+ if (lastActiveAt === null || lastActiveAt === undefined) return 'cold_start';
+ if (!Number.isFinite(lastActiveAt) || !Number.isFinite(now)) return 'unknown';
+
+ const elapsed = now - lastActiveAt;
+ if (elapsed < 0) return 'unknown';
+ if (elapsed < 5 * MINUTE_MS) return 'lt_5m';
+ if (elapsed < 30 * MINUTE_MS) return '5m_30m';
+ if (elapsed < 24 * HOUR_MS) return '30m_24h';
+ return 'gt_24h';
+}
+
+/**
+ * Read-then-set the first-open marker. Returns true exactly once per install.
+ *
+ * Fails CLOSED to `false` on a read error: a broken read reported as `true`
+ * would claim a fresh install on every launch, which is worse than having no
+ * marker because it looks like data. A failed WRITE still returns true β the
+ * read is what decides β and never throws into app launch.
+ *
+ * Call this only when an analytics client exists. Consuming the marker while
+ * the event would be dropped loses the first open permanently.
+ */
+export async function consumeColdStart(): Promise {
+ try {
+ const existing = await AsyncStorage.getItem(FIRST_OPEN_MARKER_KEY);
+ if (existing !== null) return false;
+ } catch {
+ return false;
+ }
+
+ try {
+ await AsyncStorage.setItem(FIRST_OPEN_MARKER_KEY, '1');
+ } catch {
+ // Over-counting a first open on the next launch is the acceptable
+ // failure; blocking or throwing during launch is not.
+ }
+ return true;
+}
diff --git a/app/src/core/analytics/useAnalytics.ts b/app/src/core/analytics/useAnalytics.ts
index 3446126b..f767d4a4 100644
--- a/app/src/core/analytics/useAnalytics.ts
+++ b/app/src/core/analytics/useAnalytics.ts
@@ -12,6 +12,7 @@ import { usePostHog } from 'posthog-react-native';
import { PHIFilter, AnalyticsEvents } from './PHIFilter';
import { logAnalytics } from '@/core/services/logging';
import { coarsenScreenNameForAnalytics } from '@/core/utils/sensitiveScreens';
+import type { SinceLastActiveBucket } from './appLifecycleTelemetry';
/**
* Hook for safe analytics tracking
@@ -74,15 +75,31 @@ export function useAnalytics() {
);
/**
- * Track app lifecycle events
+ * Track app lifecycle events (INFRA-542).
+ *
+ * `since_last_active` is a coarse bucket, never a raw elapsed value β
+ * `seconds_since_last_active` is absent from `SAFE_NUMERIC_KEYS`, so an
+ * unlisted numeric key would make PHIFilter discard the whole event.
+ * `duration_seconds` is whitelisted and means FOREGROUND DWELL on
+ * `app_backgrounded` only; emitting one key that meant dwell here and time
+ * away on `app_opened` would make any aggregate over it meaningless.
*/
- const trackAppOpened = useCallback(() => {
- trackEvent(AnalyticsEvents.APP_OPENED);
- }, [trackEvent]);
+ const trackAppOpened = useCallback(
+ (isColdStart: boolean, sinceLastActive: SinceLastActiveBucket) => {
+ trackEvent(AnalyticsEvents.APP_OPENED, {
+ is_cold_start: isColdStart,
+ since_last_active: sinceLastActive,
+ });
+ },
+ [trackEvent]
+ );
- const trackAppBackgrounded = useCallback(() => {
- trackEvent(AnalyticsEvents.APP_BACKGROUNDED);
- }, [trackEvent]);
+ const trackAppBackgrounded = useCallback(
+ (durationSeconds: number) => {
+ trackEvent(AnalyticsEvents.APP_BACKGROUNDED, { duration_seconds: durationSeconds });
+ },
+ [trackEvent]
+ );
/**
* Track feature usage
diff --git a/docs/architecture/analytics-architecture.md b/docs/architecture/analytics-architecture.md
index c6f0d4ec..69cdecf3 100644
--- a/docs/architecture/analytics-architecture.md
+++ b/docs/architecture/analytics-architecture.md
@@ -170,7 +170,9 @@ Wraps the app and provides PostHog context. Key behaviors:
Whitelist-based validation ensuring only safe events are transmitted.
**Whitelisted Events (25 total):**
-- App lifecycle: `app_opened`, `app_backgrounded`, `session_started`, `session_ended`
+- App lifecycle: `app_opened` (`is_cold_start`, `since_last_active` β a coarse bucket,
+ never a raw elapsed value), `app_backgrounded` (`duration_seconds` β FOREGROUND DWELL
+ only, never time away), `session_started`, `session_ended`
- Navigation: `screen_viewed`
- Features: `check_in_started/completed`, `assessment_started/completed`, `practice_started/completed`, `breathing_exercise_started/completed`
- Crisis: `crisis_resources_viewed`, `crisis_hotline_tapped`
@@ -417,6 +419,7 @@ Required disclosure for privacy policy:
> - Feature usage counts (e.g., "check-in completed")
> - App performance metrics
> - Session duration
+> - App open patterns (first open vs. return; time since last open, in coarse ranges)
> - Device type and OS version
>
> **What We NEVER Collect:**
diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md
index 9429c6b4..3f0ff9de 100644
--- a/docs/legal/privacy-policy.md
+++ b/docs/legal/privacy-policy.md
@@ -161,6 +161,7 @@ What we collect (when opted in):
- Feature usage counts (e.g., "check-in completed")
- App performance metrics
- Session duration
+- App open patterns (first open vs. return; time since last open, in coarse ranges)
- Device type and OS version
What we **NEVER** collect in-app:
From 339237144ef7496edeb7d9707b48565e30c6f39b Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 14:58:06 -0700
Subject: [PATCH 41/90] chore: INFRA-542 wire src/core/analytics into CI by
pattern
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`npm run check:ci-test-coverage` (b-close Phase 0) failed: the three suites the
previous commit added match no CI --testPathPattern, so they would run on
nobody's pull request. Two files in that directory are gated today only because
their NAMES contain `privacy` β coverage by filename, the exact defect
scripts/ci-uncovered-tests.json exists to remove.
Fixed by PATTERN, not by renaming files toward one, per that ledger's
do-not-fix-by-renaming note. Mirrors DEBUG-468, which wired src/features/practices
the same way.
Scoped to src/core/analytics/ rather than widened to src/**: the general case is
still blocked by the assessmentStore.test.ts load-dependent flake the ledger
records under wiring-them-is-deferred, and `CI pass` is the sole required check
on both protected branches with enforce_admins:true.
This directory is the app's only third-party egress path, and its failure mode is
silent in both directions β trackEvent early-returns without a client, PHIFilter
drops a violating payload with a log rather than a throw. A pin here that runs on
no PR is worth approximately nothing.
Ungated files: 49 -> 46, exactly the three added; no existing allowlist entry
became covered.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
.github/workflows/ci.yml | 14 ++++++++++++++
app/package.json | 1 +
2 files changed, 15 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 843e449e..bd1acac4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -353,6 +353,20 @@ jobs:
# dragging a flaky suite in here would intermittently block every PR in the repo.
run: cd app && npm run test:practices -- --ci --testTimeout=20000
+ - name: Analytics boundary tests
+ # INFRA-542. Co-located under src/core/analytics/, which no existing pattern
+ # reaches: the two files here carrying `privacy` in their NAME are gated by
+ # test:privacy, and the rest of the directory by nothing β coverage by
+ # filename, the defect scripts/ci-uncovered-tests.json exists to remove.
+ #
+ # This directory is the app's ONLY third-party egress path. Its failure mode
+ # is silent in both directions: trackEvent early-returns without a client, and
+ # PHIFilter drops a violating payload with a log rather than a throw. A pin
+ # here that runs on nobody's PR is worth approximately nothing.
+ #
+ # Wired by PATTERN, never by renaming files toward one (DEBUG-468 precedent).
+ run: cd app && npm run test:analytics -- --ci --testTimeout=20000
+
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v6
diff --git a/app/package.json b/app/package.json
index b63da673..9f78641c 100644
--- a/app/package.json
+++ b/app/package.json
@@ -30,6 +30,7 @@
"test:privacy": "jest --testPathPattern=privacy --verbose --forceExit",
"test:scripts": "jest --testPathPattern=__tests__/scripts --verbose --forceExit",
"test:practices": "jest --testPathPattern=src/features/practices --verbose --forceExit",
+ "test:analytics": "jest --testPathPattern=src/core/analytics --verbose --forceExit",
"test:encryption": "jest --testPathPattern=EncryptionService --verbose",
"test:crisis-quick": "jest --testPathPattern=\"[Cc]risis\" --ci --silent --testTimeout=5000 --maxWorkers=4",
"test:clinical-quick": "JEST_QUICK=true jest --testNamePattern=\"clinical|phq|gad\"",
From 8a80607fc096e68f2b3779a9f6386184c140e159 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:08:32 -0700
Subject: [PATCH 42/90] chore: INFRA-552 derive SAFE_EVENT_TYPES from
AnalyticsEvents (AC5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mechanism only. A deliberate no-op today β both sets held the same 25 names with
zero drift β landed separately so it is reviewable and revertable without touching
the deletion that follows.
A name in the catalog but not the whitelist (or vice versa) cannot transmit and
fails SILENTLY: trackEvent logs the block and returns. Deriving one from the other
makes that divergence unrepresentable rather than merely checked.
AC5 as written asks for a subset assertion. That is weaker than it sounds and the
item's own notes say so: at 25/25 it finds nothing, and the condition actually
wanted β "no production emitter" β is one a parity check structurally cannot see.
The catalog is hoisted above the class so the class can reference it; same module,
no import cycle. getWhitelistedEvents() and isWhitelisted() are unchanged.
Verified by mutation, not by a green run: adding a constant to AnalyticsEvents now
widens the live whitelist and red-lines the differential's "undeclared" assertion.
Before this change the same mutation moved `live` not at all.
test:privacy 686 passed / 38 suites.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
app/src/core/analytics/PHIFilter.ts | 165 ++++++++++++----------------
1 file changed, 68 insertions(+), 97 deletions(-)
diff --git a/app/src/core/analytics/PHIFilter.ts b/app/src/core/analytics/PHIFilter.ts
index 48310714..2d77c419 100644
--- a/app/src/core/analytics/PHIFilter.ts
+++ b/app/src/core/analytics/PHIFilter.ts
@@ -32,6 +32,57 @@ interface PHIViolation {
severity: 'medium' | 'high';
}
+/**
+ * Type-safe event names for analytics
+ * Use these constants instead of raw strings
+ */
+export const AnalyticsEvents = {
+ // App lifecycle
+ APP_OPENED: 'app_opened',
+ APP_BACKGROUNDED: 'app_backgrounded',
+ SESSION_STARTED: 'session_started',
+ SESSION_ENDED: 'session_ended',
+
+ // Navigation
+ SCREEN_VIEWED: 'screen_viewed',
+
+ // Feature usage
+ CHECK_IN_STARTED: 'check_in_started',
+ CHECK_IN_COMPLETED: 'check_in_completed',
+ ASSESSMENT_STARTED: 'assessment_started',
+ ASSESSMENT_COMPLETED: 'assessment_completed',
+ PRACTICE_STARTED: 'practice_started',
+ PRACTICE_COMPLETED: 'practice_completed',
+ BREATHING_EXERCISE_STARTED: 'breathing_exercise_started',
+ BREATHING_EXERCISE_COMPLETED: 'breathing_exercise_completed',
+
+ // Crisis
+ CRISIS_RESOURCES_VIEWED: 'crisis_resources_viewed',
+ CRISIS_HOTLINE_TAPPED: 'crisis_hotline_tapped',
+
+ // Settings
+ SETTINGS_OPENED: 'settings_opened',
+ CONSENT_CHANGED: 'consent_changed',
+
+ // Errors
+ ERROR_OCCURRED: 'error_occurred',
+
+ // Onboarding
+ ONBOARDING_STARTED: 'onboarding_started',
+ ONBOARDING_COMPLETED: 'onboarding_completed',
+ ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed',
+
+ // Learn
+ LEARN_CONTENT_VIEWED: 'learn_content_viewed',
+ LEARN_MODULE_STARTED: 'learn_module_started',
+ LEARN_MODULE_COMPLETED: 'learn_module_completed',
+
+ // Domain guidance (FEAT-457) β no properties, ever. See SAFE_EVENT_TYPES.
+ GUIDANCE_OPENED: 'guidance_opened',
+} as const;
+
+export type AnalyticsEventType = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
+
/**
* PHI Filter - Whitelist-based analytics event validation
*
@@ -44,53 +95,23 @@ export class PHIFilter {
* WHITELIST: Only these event types can be transmitted
* If it's not here, it doesn't get sent.
*/
- private static readonly SAFE_EVENT_TYPES: ReadonlySet = new Set([
- // App lifecycle
- 'app_opened',
- 'app_backgrounded',
- 'session_started',
- 'session_ended',
-
- // Navigation (screen names only, no content)
- 'screen_viewed',
-
- // Feature usage (counts only, no content/values)
- 'check_in_started',
- 'check_in_completed',
- 'assessment_started',
- 'assessment_completed',
- 'practice_started',
- 'practice_completed',
- 'breathing_exercise_started',
- 'breathing_exercise_completed',
-
- // Crisis (access tracking only, no contact details)
- 'crisis_resources_viewed',
- 'crisis_hotline_tapped',
-
- // Settings
- 'settings_opened',
- 'consent_changed',
-
- // Errors (sanitized - no PHI in error messages)
- 'error_occurred',
-
- // Onboarding
- 'onboarding_started',
- 'onboarding_completed',
- 'onboarding_step_completed',
-
- // Learn tab
- 'learn_content_viewed',
- 'learn_module_started',
- 'learn_module_completed',
-
- // Domain guidance (FEAT-457) β REACH ONLY, and deliberately carries no
- // `domain` property. See the PHI_KEYWORDS note below: the hardship domain is
- // itself the wellness inference, so this event measures that the surface was
- // opened and nothing about what for.
- 'guidance_opened',
- ]);
+ /**
+ * WHITELIST: only these event types can be transmitted.
+ *
+ * DERIVED from `AnalyticsEvents`, not hand-maintained (INFRA-552 AC5). A name in
+ * one but not the other cannot transmit and fails SILENTLY β `trackEvent` logs the
+ * block and returns β so parity is enforced by construction rather than by a check
+ * that has to be remembered. Both had 25 entries and zero drift when this was
+ * derived; the point is that the next divergence is now unrepresentable.
+ *
+ * Know what this does NOT do: it cannot tell whether a whitelisted event has a
+ * PRODUCTION EMITTER. That is the `no production emitter` condition, which a
+ * parity check structurally cannot see β `analyticsTrackerContract.privacy.test.ts`
+ * is what detects it.
+ */
+ private static readonly SAFE_EVENT_TYPES: ReadonlySet = new Set(
+ Object.values(AnalyticsEvents)
+ );
/**
* BLOCKLIST: Keywords that indicate PHI - block if detected in data
@@ -379,53 +400,3 @@ export class PHIFilter {
}
}
-/**
- * Type-safe event names for analytics
- * Use these constants instead of raw strings
- */
-export const AnalyticsEvents = {
- // App lifecycle
- APP_OPENED: 'app_opened',
- APP_BACKGROUNDED: 'app_backgrounded',
- SESSION_STARTED: 'session_started',
- SESSION_ENDED: 'session_ended',
-
- // Navigation
- SCREEN_VIEWED: 'screen_viewed',
-
- // Feature usage
- CHECK_IN_STARTED: 'check_in_started',
- CHECK_IN_COMPLETED: 'check_in_completed',
- ASSESSMENT_STARTED: 'assessment_started',
- ASSESSMENT_COMPLETED: 'assessment_completed',
- PRACTICE_STARTED: 'practice_started',
- PRACTICE_COMPLETED: 'practice_completed',
- BREATHING_EXERCISE_STARTED: 'breathing_exercise_started',
- BREATHING_EXERCISE_COMPLETED: 'breathing_exercise_completed',
-
- // Crisis
- CRISIS_RESOURCES_VIEWED: 'crisis_resources_viewed',
- CRISIS_HOTLINE_TAPPED: 'crisis_hotline_tapped',
-
- // Settings
- SETTINGS_OPENED: 'settings_opened',
- CONSENT_CHANGED: 'consent_changed',
-
- // Errors
- ERROR_OCCURRED: 'error_occurred',
-
- // Onboarding
- ONBOARDING_STARTED: 'onboarding_started',
- ONBOARDING_COMPLETED: 'onboarding_completed',
- ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed',
-
- // Learn
- LEARN_CONTENT_VIEWED: 'learn_content_viewed',
- LEARN_MODULE_STARTED: 'learn_module_started',
- LEARN_MODULE_COMPLETED: 'learn_module_completed',
-
- // Domain guidance (FEAT-457) β no properties, ever. See SAFE_EVENT_TYPES.
- GUIDANCE_OPENED: 'guidance_opened',
-} as const;
-
-export type AnalyticsEventType = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
From 673bf360eefbcbc94f0283ab26f7a7dfb26203a0 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:16:45 -0700
Subject: [PATCH 43/90] chore: INFRA-552 prune the 12 orphan analytics
constants
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Audit REDONE mechanically against development, not inherited. 25 constants /
25 whitelist entries / zero drift; 13 FIRE, 12 ORPHAN.
The panel's starting point was 11/14. It is 13/12 now because INFRA-542 landed
app_opened and app_backgrounded emitters first, which is exactly why these two
items were sequenced. Both are retained on the evidence of their call sites
rather than on a claim about a sibling's intent β AC3 asks implementers to "map
deletions against sibling intent", but that intent lives only in the Notion
graph and has zero repo footprint, so an implementer reading only code would
have deleted precisely the two constants the sibling had just wired.
crisis_hotline_tapped is retained and is the reason this item was separately
gated: it is never called with parentheses anywhere. It is passed BY REFERENCE
as `onTap: trackCrisisHotlineTapped` into openCrisisUrl at
CrisisResourcesScreen.tsx:319 and :346 (the pinned 988 footer), so the obvious
literal-call grep misclassifies it ORPHAN. That is what the draft audit did.
Orphan status was confirmed with a matcher PROVEN TO FIRE first: no deleted name
is emitted by raw string anywhere in src, supabase/, scripts/ or .maestro/. My
own first pass returned a false negative here and was discarded β an empty result
from a filtered search is not evidence. The single surviving match is
SyncCoordinator.ts:917 `scheduleSync('high','assessment_completed')`, a
sync-reason tag unrelated to the PostHog catalog.
BLOCKER RESOLVED. INFRA-558's differential ratchet landed after this item was
filed and made the prune impossible as specified: `declared` was
`baseline βͺ WIDENED`, the frozen baseline is never amended, and WIDENED only
grows β so a removal red-lined the assertion with no sanctioned resolution, and
its own comment said a removal "must be reflected here" while nothing implemented
that. Adds a NARROWED ledger and a removal procedure mirroring the widening one.
The frozen baseline is untouched.
Both directions mutation-verified rather than observed green: dropping a NARROWED
entry reds the removal assertion; a stale entry naming a still-live event reds
three, including the new anti-staleness control.
phiFilterScanSurface needed more than the mechanical swap its failure suggested.
Three events in its real-payload list were deleted, and two more were scaffolding
for the KEY-scan tests β after the prune those still assert valid===false but
VACUOUSLY, via the whitelist check, never reaching the key scan the block exists
to exercise. Swapped for live events and added an isWhitelisted() precondition so
the same rot cannot recur silently.
The contract test's "recorded for INFRA-552" marker block is discharged, not
deleted: it now asserts session_started/session_ended are absent, because
re-adding a name the hook cannot reach is the defect it was recording.
Deleting a constant now removes its whitelist entry automatically (the AC5
derivation in the previous commit), so there is one place to edit rather than two.
test:privacy 677 passed / 38 suites; test:safety 547 passed / 22 suites.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
.../analyticsTrackerContract.privacy.test.ts | 31 +++----
.../phiFilterDifferential.privacy.test.ts | 90 +++++++++++++++++--
.../phiFilterScanSurface.privacy.test.ts | 17 ++--
app/src/core/analytics/PHIFilter.ts | 22 +----
app/src/core/analytics/useAnalytics.ts | 86 ------------------
docs/architecture/analytics-architecture.md | 23 +++--
6 files changed, 127 insertions(+), 142 deletions(-)
diff --git a/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
index daab5dfb..8ae75bb0 100644
--- a/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
+++ b/app/__tests__/privacy/analyticsTrackerContract.privacy.test.ts
@@ -49,12 +49,6 @@ const FIXTURES: Readonly> = {
// properties. A bucketed string, never a raw elapsed number.
trackAppOpened: [true, 'cold_start'],
trackAppBackgrounded: [42],
- trackCheckInStarted: [],
- trackCheckInCompleted: [5000],
- trackAssessmentStarted: [],
- trackAssessmentCompleted: [42000],
- trackPracticeStarted: [],
- trackPracticeCompleted: [300000],
trackCrisisResourcesViewed: [],
trackCrisisHotlineTapped: [],
trackGuidanceOpened: [],
@@ -62,13 +56,9 @@ const FIXTURES: Readonly> = {
trackConsentChanged: [],
trackLearnContentViewed: ['module-1'],
trackLearnModuleStarted: ['module-1'],
- trackLearnModuleCompleted: ['module-1', 900],
- trackBreathingExerciseStarted: [],
- trackBreathingExerciseCompleted: [180000],
trackOnboardingStarted: [],
trackOnboardingStepCompleted: [3],
trackOnboardingCompleted: [],
- trackErrorOccurred: ['network_error'],
};
/**
@@ -79,8 +69,8 @@ const FIXTURES: Readonly> = {
*/
const EXCLUDED = new Set(['trackEvent']);
-/** Pinned floor: 23 named trackers today. Growth fine, shrinkage red. */
-const MIN_TRACKERS = 23;
+/** Pinned floor: 13 named trackers today (INFRA-552 pruned 10). Growth fine, shrinkage red. */
+const MIN_TRACKERS = 13;
describe('every useAnalytics tracker transmits (INFRA-535)', () => {
const { result } = renderHook(() => useAnalytics());
@@ -145,13 +135,16 @@ describe('every useAnalytics tracker transmits (INFRA-535)', () => {
});
});
- describe('catalog constants with no tracker at all (recorded for INFRA-552)', () => {
- it('session_started and session_ended are whitelisted but unreachable from the hook', () => {
- // Neither has a tracker function, so the derived enumeration above cannot
- // see them and this suite cannot protect them. The catalog prune must handle
- // them by hand rather than assuming the contract test covers the catalog.
- expect(PHIFilter.isWhitelisted('session_started')).toBe(true);
- expect(PHIFilter.isWhitelisted('session_ended')).toBe(true);
+ describe('catalog constants with no tracker at all (DISCHARGED by INFRA-552)', () => {
+ it('session_started and session_ended are gone from the catalog entirely', () => {
+ // Previously these were whitelisted with no tracker function, so the derived
+ // enumeration above could not see them and this suite could not protect them.
+ // INFRA-552 deleted both: no session-lifecycle concept exists anywhere in
+ // app/src, so they were catalog fiction rather than pending work. Kept as an
+ // assertion rather than deleted with them β re-adding a name the hook cannot
+ // reach is the exact defect this block was recording.
+ expect(PHIFilter.isWhitelisted('session_started')).toBe(false);
+ expect(PHIFilter.isWhitelisted('session_ended')).toBe(false);
const emitters = trackerKeys.filter((k) => /session/i.test(k));
expect(emitters).toEqual([]);
diff --git a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
index 72dfe9a9..5a2378e0 100644
--- a/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
+++ b/app/__tests__/privacy/phiFilterDifferential.privacy.test.ts
@@ -196,6 +196,61 @@ interface Widening {
const WIDENED: ReadonlyArray = [];
+/**
+ * The other direction (INFRA-552). A name REMOVED from the live whitelist after
+ * `d14d6178` must appear here, in the same PR that removes it.
+ *
+ * This ledger exists because the amendment procedure above had an add-path only:
+ * `declared` was `baseline βͺ WIDENED`, the frozen baseline is never amended, and
+ * `WIDENED` only grows β so a legitimate narrowing had no way to be recorded and
+ * red-lined the removal assertion with no sanctioned resolution. The assertion's
+ * own comment already said a removal "must be reflected here"; nothing implemented
+ * that. This does.
+ *
+ * A narrowing is SAFER than the baseline (the filter transmits strictly less), so
+ * unlike `WIDENED` there is no payload to vet β the check is that the removal was
+ * declared, attributed, and reasoned, not that it is harmless.
+ *
+ * TO REMOVE AN EVENT TYPE, in ONE pull request:
+ * 1. Delete the constant from `AnalyticsEvents` in `PHIFilter.ts`. Since
+ * INFRA-552 the whitelist is DERIVED from it, so there is no second list to
+ * edit β and no way to remove from one and not the other.
+ * 2. Delete its tracker function and hook-return entry in `useAnalytics.ts`, and
+ * its `FIXTURES` entry in `analyticsTrackerContract.privacy.test.ts`. Deleting
+ * the constant alone breaks typecheck; deleting the tracker alone leaves a
+ * dead fixture that suite rejects.
+ * 3. Add a `NARROWED` entry below naming the event, the work item, and why.
+ * 4. Refresh the enumerated event list in
+ * `docs/architecture/analytics-architecture.md`.
+ *
+ * Do NOT remove a name because nothing emits it YET. "No production emitter" and
+ * "no longer wanted" are different claims, and a sibling item mid-flight looks
+ * exactly like the former β INFRA-542 wired `app_opened`/`app_backgrounded` while
+ * this prune was being planned, which is why both are absent from this list.
+ */
+interface Narrowing {
+ readonly eventType: string;
+ readonly workItem: string;
+ readonly rationale: string;
+}
+
+const NARROWED: ReadonlyArray = [
+ // All twelve: no production emitter anywhere in app/src, supabase/, scripts/ or
+ // .maestro/, re-derived mechanically against `development` rather than inherited.
+ { eventType: 'check_in_started', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' },
+ { eventType: 'check_in_completed', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' },
+ { eventType: 'assessment_started', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' },
+ { eventType: 'assessment_completed', workItem: 'INFRA-552', rationale: 'No production emitter. The one string match in src is SyncCoordinator.ts:917 scheduleSync("high","assessment_completed"), a sync-reason tag unrelated to the PostHog catalog.' },
+ { eventType: 'practice_started', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' },
+ { eventType: 'practice_completed', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' },
+ { eventType: 'learn_module_completed', workItem: 'INFRA-552', rationale: 'No production emitter; its siblings learn_module_started and learn_content_viewed do fire and are retained.' },
+ { eventType: 'breathing_exercise_started', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' },
+ { eventType: 'breathing_exercise_completed', workItem: 'INFRA-552', rationale: 'No production emitter; tracker existed with zero call sites.' },
+ { eventType: 'error_occurred', workItem: 'INFRA-552', rationale: 'No production emitter. Error reporting goes to Sentry, not to PostHog product analytics.' },
+ { eventType: 'session_started', workItem: 'INFRA-552', rationale: 'Catalog fiction: no tracker function ever existed, so the contract test could not see it. No session-lifecycle concept exists in app/src.' },
+ { eventType: 'session_ended', workItem: 'INFRA-552', rationale: 'Catalog fiction: no tracker function ever existed, so the contract test could not see it. No session-lifecycle concept exists in app/src.' },
+];
+
describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () => {
const baselineRejections = CORPUS.filter((c) => !validateV1(c.eventType, c.data).valid);
@@ -235,10 +290,12 @@ describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () =>
*/
describe('whitelist amendments are declared (INFRA-558)', () => {
const live = new Set(PHIFilter.getWhitelistedEvents());
- const declared = new Set([
- ...BASELINE_SAFE_EVENT_TYPES,
- ...WIDENED.map((w) => w.eventType),
- ]);
+ const narrowed = new Set(NARROWED.map((n) => n.eventType));
+ const declared = new Set(
+ [...BASELINE_SAFE_EVENT_TYPES, ...WIDENED.map((w) => w.eventType)].filter(
+ (e) => !narrowed.has(e)
+ )
+ );
it('every live event type is either in the frozen baseline or in the WIDENED ledger', () => {
const undeclared = [...live].filter((e) => !declared.has(e)).sort();
@@ -268,6 +325,25 @@ describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () =>
}
});
+ it('each NARROWED entry names a baseline event that really is gone (INFRA-552)', () => {
+ // The symmetric control to the widening check above. Two ways this ledger
+ // could rot, both silent without this:
+ // - an entry naming something that was never in the baseline (a typo, or a
+ // name invented to satisfy the arithmetic), which would shrink `declared`
+ // without any real removal having happened;
+ // - a stale entry left behind after the event was RE-ADDED, which would
+ // hide it from the "every live event type is declared" check above.
+ for (const n of NARROWED) {
+ expect(BASELINE_SAFE_EVENT_TYPES.has(n.eventType)).toBe(true);
+ expect(live.has(n.eventType)).toBe(false);
+ expect(n.workItem).toMatch(/^(FEAT|DEBUG|INFRA|MAINT|AGENT)-\d+$/);
+ expect(n.rationale.length).toBeGreaterThan(20);
+ }
+ // Non-vacuity: this suite shipped with WIDENED empty, and an empty NARROWED
+ // would make the loop above pass over nothing in exactly the same way.
+ expect(NARROWED.length).toBeGreaterThan(0);
+ });
+
it('the membership matcher still fires (DEBUG-390)', () => {
// An empty ledger plus an unchanged whitelist makes the two tests above pass
// over nothing. Prove the comparison can still detect an undeclared name, so
@@ -285,8 +361,10 @@ describe('PHIFilter differential vs frozen d14d6178 baseline (INFRA-535)', () =>
// And that the real sets being compared are non-trivial, so the assertions
// above are running against something.
- expect(live.size).toBeGreaterThanOrEqual(25);
- expect(declared.size).toBe(BASELINE_SAFE_EVENT_TYPES.size + WIDENED.length);
+ expect(live.size).toBeGreaterThanOrEqual(13);
+ expect(declared.size).toBe(
+ BASELINE_SAFE_EVENT_TYPES.size + WIDENED.length - NARROWED.length
+ );
});
});
diff --git a/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts b/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts
index 7516518c..07c16541 100644
--- a/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts
+++ b/app/__tests__/privacy/phiFilterScanSurface.privacy.test.ts
@@ -37,12 +37,12 @@ beforeEach(() => {
describe('PHIFilter scan surface (INFRA-535)', () => {
describe('KEY scanning β new, and the allowlist that makes it survivable', () => {
it('blocks a PHI keyword appearing as a whole key', () => {
- expect(PHIFilter.validate('check_in_completed', { mood: 'ok' }).valid).toBe(false);
+ expect(PHIFilter.validate('app_backgrounded', { mood: 'ok' }).valid).toBe(false);
expect(PHIFilter.validate('app_opened', { journal: 'x' }).valid).toBe(false);
});
it('blocks a PHI keyword appearing as one segment of a key', () => {
- expect(PHIFilter.validate('assessment_completed', { phq_score: 'x' }).valid).toBe(false);
+ expect(PHIFilter.validate('screen_viewed', { phq_score: 'x' }).valid).toBe(false);
expect(PHIFilter.validate('app_opened', { journal_id: 'abc' }).valid).toBe(false);
expect(PHIFilter.validate('app_opened', { userEmail: 'x' }).valid).toBe(false);
});
@@ -78,16 +78,21 @@ describe('PHIFilter scan surface (INFRA-535)', () => {
});
it('every real tracker key survives the key scan', () => {
- // Derived from the literal keys in useAnalytics.ts.
+ // Derived from the literal keys in useAnalytics.ts. INFRA-552 replaced
+ // check_in_completed / learn_module_completed / error_occurred here: all three
+ // were pruned, and a deleted event would pass this assertion for the WRONG
+ // reason β rejected at the whitelist check, never reaching the key scan this
+ // block exists to exercise. Every entry below must be a LIVE whitelisted event.
const real: Array<[string, Record]> = [
['screen_viewed', { screen_name: 'App' }],
- ['check_in_completed', { duration_ms: 5000 }],
+ ['app_opened', { is_cold_start: true, since_last_active: 'lt_5m' }],
+ ['app_backgrounded', { duration_seconds: 42 }],
['learn_content_viewed', { module_id: 'm1' }],
- ['learn_module_completed', { module_id: 'm1', duration_ms: 900 }],
+ ['learn_module_started', { module_id: 'm1' }],
['onboarding_step_completed', { step: 3 }],
- ['error_occurred', { error_type: 'network' }],
];
for (const [evt, data] of real) {
+ expect(PHIFilter.isWhitelisted(evt)).toBe(true);
expect(PHIFilter.validate(evt, data)).toEqual({ valid: true });
}
});
diff --git a/app/src/core/analytics/PHIFilter.ts b/app/src/core/analytics/PHIFilter.ts
index 2d77c419..06bdd26a 100644
--- a/app/src/core/analytics/PHIFilter.ts
+++ b/app/src/core/analytics/PHIFilter.ts
@@ -37,25 +37,15 @@ interface PHIViolation {
* Use these constants instead of raw strings
*/
export const AnalyticsEvents = {
- // App lifecycle
+ // App lifecycle (INFRA-542 wired these two; session_started/session_ended were
+ // deleted by INFRA-552 β they never had a tracker function at all, so the
+ // contract test's key enumeration could not see them and no producer existed.)
APP_OPENED: 'app_opened',
APP_BACKGROUNDED: 'app_backgrounded',
- SESSION_STARTED: 'session_started',
- SESSION_ENDED: 'session_ended',
// Navigation
SCREEN_VIEWED: 'screen_viewed',
- // Feature usage
- CHECK_IN_STARTED: 'check_in_started',
- CHECK_IN_COMPLETED: 'check_in_completed',
- ASSESSMENT_STARTED: 'assessment_started',
- ASSESSMENT_COMPLETED: 'assessment_completed',
- PRACTICE_STARTED: 'practice_started',
- PRACTICE_COMPLETED: 'practice_completed',
- BREATHING_EXERCISE_STARTED: 'breathing_exercise_started',
- BREATHING_EXERCISE_COMPLETED: 'breathing_exercise_completed',
-
// Crisis
CRISIS_RESOURCES_VIEWED: 'crisis_resources_viewed',
CRISIS_HOTLINE_TAPPED: 'crisis_hotline_tapped',
@@ -64,9 +54,6 @@ export const AnalyticsEvents = {
SETTINGS_OPENED: 'settings_opened',
CONSENT_CHANGED: 'consent_changed',
- // Errors
- ERROR_OCCURRED: 'error_occurred',
-
// Onboarding
ONBOARDING_STARTED: 'onboarding_started',
ONBOARDING_COMPLETED: 'onboarding_completed',
@@ -75,9 +62,8 @@ export const AnalyticsEvents = {
// Learn
LEARN_CONTENT_VIEWED: 'learn_content_viewed',
LEARN_MODULE_STARTED: 'learn_module_started',
- LEARN_MODULE_COMPLETED: 'learn_module_completed',
- // Domain guidance (FEAT-457) β no properties, ever. See SAFE_EVENT_TYPES.
+ // Domain guidance (FEAT-457) β no properties, ever.
GUIDANCE_OPENED: 'guidance_opened',
} as const;
diff --git a/app/src/core/analytics/useAnalytics.ts b/app/src/core/analytics/useAnalytics.ts
index f767d4a4..2bff0dea 100644
--- a/app/src/core/analytics/useAnalytics.ts
+++ b/app/src/core/analytics/useAnalytics.ts
@@ -101,48 +101,6 @@ export function useAnalytics() {
[trackEvent]
);
- /**
- * Track feature usage
- */
- const trackCheckInStarted = useCallback(() => {
- trackEvent(AnalyticsEvents.CHECK_IN_STARTED);
- }, [trackEvent]);
-
- const trackCheckInCompleted = useCallback(
- (durationMs?: number) => {
- trackEvent(AnalyticsEvents.CHECK_IN_COMPLETED, {
- ...(durationMs !== undefined && { duration_ms: durationMs }),
- });
- },
- [trackEvent]
- );
-
- const trackAssessmentStarted = useCallback(() => {
- trackEvent(AnalyticsEvents.ASSESSMENT_STARTED);
- }, [trackEvent]);
-
- const trackAssessmentCompleted = useCallback(
- (durationMs?: number) => {
- trackEvent(AnalyticsEvents.ASSESSMENT_COMPLETED, {
- ...(durationMs !== undefined && { duration_ms: durationMs }),
- });
- },
- [trackEvent]
- );
-
- const trackPracticeStarted = useCallback(() => {
- trackEvent(AnalyticsEvents.PRACTICE_STARTED);
- }, [trackEvent]);
-
- const trackPracticeCompleted = useCallback(
- (durationMs?: number) => {
- trackEvent(AnalyticsEvents.PRACTICE_COMPLETED, {
- ...(durationMs !== undefined && { duration_ms: durationMs }),
- });
- },
- [trackEvent]
- );
-
const trackCrisisResourcesViewed = useCallback(() => {
trackEvent(AnalyticsEvents.CRISIS_RESOURCES_VIEWED);
}, [trackEvent]);
@@ -200,31 +158,6 @@ export function useAnalytics() {
[trackEvent]
);
- const trackLearnModuleCompleted = useCallback(
- (moduleId?: string, durationMs?: number) => {
- trackEvent(AnalyticsEvents.LEARN_MODULE_COMPLETED, {
- ...(moduleId !== undefined && { module_id: moduleId }),
- ...(durationMs !== undefined && { duration_ms: durationMs }),
- });
- },
- [trackEvent]
- );
-
- /**
- * Track breathing exercise lifecycle
- */
- const trackBreathingExerciseStarted = useCallback(() => {
- trackEvent(AnalyticsEvents.BREATHING_EXERCISE_STARTED);
- }, [trackEvent]);
-
- const trackBreathingExerciseCompleted = useCallback(
- (durationMs?: number) => {
- trackEvent(AnalyticsEvents.BREATHING_EXERCISE_COMPLETED, {
- ...(durationMs !== undefined && { duration_ms: durationMs }),
- });
- },
- [trackEvent]
- );
/**
* Track onboarding flow
@@ -244,15 +177,6 @@ export function useAnalytics() {
trackEvent(AnalyticsEvents.ONBOARDING_COMPLETED);
}, [trackEvent]);
- /**
- * Track errors (sanitized - no PHI in error messages)
- */
- const trackErrorOccurred = useCallback(
- (errorType: string) => {
- trackEvent(AnalyticsEvents.ERROR_OCCURRED, { error_type: errorType });
- },
- [trackEvent]
- );
return {
// Core methods
@@ -264,12 +188,6 @@ export function useAnalytics() {
trackAppBackgrounded,
// Features
- trackCheckInStarted,
- trackCheckInCompleted,
- trackAssessmentStarted,
- trackAssessmentCompleted,
- trackPracticeStarted,
- trackPracticeCompleted,
trackCrisisResourcesViewed,
trackCrisisHotlineTapped,
trackGuidanceOpened,
@@ -279,11 +197,8 @@ export function useAnalytics() {
// Learn
trackLearnContentViewed,
trackLearnModuleStarted,
- trackLearnModuleCompleted,
// Breathing
- trackBreathingExerciseStarted,
- trackBreathingExerciseCompleted,
// Onboarding
trackOnboardingStarted,
@@ -291,7 +206,6 @@ export function useAnalytics() {
trackOnboardingCompleted,
// Errors
- trackErrorOccurred,
};
}
diff --git a/docs/architecture/analytics-architecture.md b/docs/architecture/analytics-architecture.md
index 69cdecf3..a26aa540 100644
--- a/docs/architecture/analytics-architecture.md
+++ b/docs/architecture/analytics-architecture.md
@@ -169,21 +169,30 @@ Wraps the app and provides PostHog context. Key behaviors:
Whitelist-based validation ensuring only safe events are transmitted.
-**Whitelisted Events (25 total):**
+**Whitelisted Events (13 total).** Every one has a production emitter β that is now
+the entry condition, not an aspiration. INFRA-552 deleted the twelve that had none.
+
- App lifecycle: `app_opened` (`is_cold_start`, `since_last_active` β a coarse bucket,
never a raw elapsed value), `app_backgrounded` (`duration_seconds` β FOREGROUND DWELL
- only, never time away), `session_started`, `session_ended`
+ only, never time away)
- Navigation: `screen_viewed`
-- Features: `check_in_started/completed`, `assessment_started/completed`, `practice_started/completed`, `breathing_exercise_started/completed`
- Crisis: `crisis_resources_viewed`, `crisis_hotline_tapped`
- Settings: `settings_opened`, `consent_changed`
-- Errors: `error_occurred`
- Onboarding: `onboarding_started/completed/step_completed`
-- Learn: `learn_content_viewed`, `learn_module_started/completed`
+- Learn: `learn_content_viewed`, `learn_module_started`
- Guidance: `guidance_opened` (FEAT-457) β **no properties, ever**
-> This list is derived by hand and has drifted before (stated as 27 while the whitelist
-> held 24, pre-FEAT-457). Read `PHIFilter.SAFE_EVENT_TYPES` if the exact set matters.
+Removed by INFRA-552, all with zero production emitters: `check_in_started/completed`,
+`assessment_started/completed`, `practice_started/completed`, `learn_module_completed`,
+`breathing_exercise_started/completed`, `error_occurred` (errors go to Sentry, not
+PostHog), and `session_started`/`session_ended`, which never had a tracker function at
+all. Each is recorded with its reason in the `NARROWED` ledger in
+`app/__tests__/privacy/phiFilterDifferential.privacy.test.ts`.
+
+> This list is still maintained by hand and has drifted before (stated as 27 while the
+> whitelist held 24, pre-FEAT-457). Since INFRA-552 the whitelist is DERIVED from
+> `AnalyticsEvents`, so catalog/whitelist parity can no longer drift β but this prose
+> can. Read `AnalyticsEvents` in `PHIFilter.ts` if the exact set matters.
> Since INFRA-558 the drift is bounded rather than merely warned about: the differential
> suite asserts the live whitelist equals the frozen `d14d6178` baseline plus its
> `WIDENED` ledger, so a name can no longer be added here or there without the other
From f222ebb23240f9f21d5bb28fdd3edfb645817138 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 15:30:34 -0700
Subject: [PATCH 44/90] fix: DEBUG-533 rule the Sentry feedback widget a
zero-988 window, harden the shake
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`crisis` ruling: Sentry.showFeedbackWidget() IS a DEBUG-406 conversion site. It
fails all three legs of the NotificationTimePicker exception, the only RN-modal
occlusion DEBUG-406 let stand.
The mechanism was mis-stated in the item and is corrected in the code. This is
not a native window outside our tree: Sentry.wrap(App) mounts
FeedbackWidgetProvider above GestureHandlerRootView, and its render emits our
whole app as `children` and THEN, as a later sibling, an inset-0 Animated.View
animating to rgba(0,0,0,0.9). That backdrop alone occludes everything;
RootCrisisButton's zIndex 9999 cannot reach past it, because zIndex orders
siblings and the backdrop is a later sibling of the button's ANCESTOR. The RN
is a second, independent occluder stacked on it.
Legs, against NotificationTimePicker:
benign content FAILS - that ruling rests on Settings-only reach; this is
armed at the app root, so it opens over CrisisResources, a
mid-PHQ-9 AssessmentFlow, and VoiceReflectionScreen after
scanOnSave. The occluded content is the wellness-bearing part.
fixed exits FAILS, decisive - Cancel is the LAST child of a ScrollView
under a keyboard (showName/showEmail false, so one required
field), no backdrop tap, onRequestClose is Android-only,
pull-down needs isScrollAtTop && dy>200, dwell unbounded.
iOS-only Modal FAILS AND INVERTS - one Modal on both platforms, and iOS is
the worse one, having no hardware back.
Not fixable in place: the occluder is third-party, so it cannot be hosted in
rootOverlaySlot, given a backdrop handler, bounded, or made to carry a 988
control. The structural remedy is our own form submitting via
Sentry.captureFeedback() - a top-level export and exactly what
FeedbackWidget.js:70 calls. Filed separately; it must also drop
feedbackIntegration or the provider stays mounted.
Shake retune, per the ruling. The trigger was a SINGLE-SAMPLE 1.8g test at 5Hz;
at rest magnitude is already ~1g, so it asked for 0.8g in one instantaneous
reading - cleared by a pocket-pull or a car bump. A TestFlight user reported it
(JAVASCRIPT-REACT-F, 2026-08-28) and was right. An accidental open is a
crisis-reachability event, not a nuisance: it covers 988 with a form the user
cannot reliably leave in one tap.
SHAKE_THRESHOLD 1.8 -> 2.7 SAMPLE_INTERVAL_MS 200 -> 100
SHAKE_MIN_CROSSINGS new -> 3 SHAKE_WINDOW_MS new -> 1000
The burst requirement is the load-bearing half - a hard enough single jolt
clears any threshold. Test-first: two pure reducers, red proved at 11 failed /
3 passed with the rest and custom-threshold cases as green controls.
test:unit 954 passed, test:safety 547 passed.
AC 1 (observed behaviour on a running build) is NOT closed by this commit and
needs the attended session.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/__tests__/unit/bugReportShake.test.ts | 90 +++++++++++++++++--
app/src/core/hooks/useBugReportShake.ts | 80 +++++++++++++++--
.../services/logging/ExternalErrorReporter.ts | 68 ++++++++++++++
.../profile/screens/ProfileScreen.tsx | 9 +-
4 files changed, 232 insertions(+), 15 deletions(-)
diff --git a/app/__tests__/unit/bugReportShake.test.ts b/app/__tests__/unit/bugReportShake.test.ts
index 8dbdb12a..eb0ac50b 100644
--- a/app/__tests__/unit/bugReportShake.test.ts
+++ b/app/__tests__/unit/bugReportShake.test.ts
@@ -1,22 +1,42 @@
/**
- * FEAT-284 shake-to-report β pure shake-detection math.
+ * FEAT-284 shake-to-report β shake-detection math.
*
- * At rest the accelerometer vector magnitude is ~1g (gravity); a deliberate
- * shake spikes well past the threshold. This pins that a gentle hold does NOT
- * trigger and a hard shake does.
+ * Two separable pure pieces, and the split is the point:
+ * β’ `isShake` β single-sample magnitude test.
+ * β’ `isShakeBurst` β the rolling-window crossing counter that turns a
+ * sequence of crossings into a decision.
+ *
+ * DEBUG-533 raised the threshold AND added the burst requirement. A
+ * single-sample test cannot distinguish a deliberate shake from a pocket-pull:
+ * at rest the vector magnitude is already ~1g, so the old 1.8 asked for 0.8g of
+ * net acceleration in one instantaneous reading, sampled at 5Hz. Raising the
+ * threshold alone is not the fix β a hard enough single jolt clears any
+ * threshold you pick β so the consecutive-crossing requirement is the
+ * load-bearing half and is tested as such.
*/
-import { isShake } from '@/core/hooks/useBugReportShake';
+import {
+ isShake,
+ isShakeBurst,
+ recentCrossings,
+} from '@/core/hooks/useBugReportShake';
-describe('isShake', () => {
+describe('isShake β single-sample crossing test', () => {
it('does not trigger at rest (~1g on a single axis)', () => {
expect(isShake({ x: 0, y: 0, z: 1 })).toBe(false);
expect(isShake({ x: 0.1, y: -0.2, z: 0.98 })).toBe(false);
});
+ it('does not trigger on ordinary handling that cleared the old 1.8 bar', () => {
+ // DEBUG-533 regression cases. Both are magnitudes a pocket-pull or a
+ // phone set down on a hard table reaches; both fired before this change.
+ expect(isShake({ x: 1.5, y: 1.5, z: 1.5 })).toBe(false); // ~2.598
+ expect(isShake({ x: 2.2, y: 0, z: 0 })).toBe(false); // 2.2
+ });
+
it('triggers on a hard shake (magnitude well above threshold)', () => {
- expect(isShake({ x: 1.5, y: 1.5, z: 1.5 })).toBe(true);
- expect(isShake({ x: 2.2, y: 0, z: 0 })).toBe(true);
+ expect(isShake({ x: 2, y: 2, z: 1 })).toBe(true); // 3.0
+ expect(isShake({ x: 3.2, y: 0, z: 0 })).toBe(true);
});
it('respects a custom threshold', () => {
@@ -24,3 +44,57 @@ describe('isShake', () => {
expect(isShake({ x: 1, y: 1, z: 1 }, 1.5)).toBe(true);
});
});
+
+describe('recentCrossings β rolling-window prune', () => {
+ it('keeps crossings inside the window and drops the rest', () => {
+ expect(recentCrossings([100, 500, 900], 1000)).toEqual([100, 500, 900]);
+ expect(recentCrossings([0, 500, 900], 1500)).toEqual([500, 900]);
+ });
+
+ it('is inclusive at exactly the window edge', () => {
+ // A burst spanning the full window must still count as one burst.
+ expect(recentCrossings([500], 1500)).toEqual([500]);
+ expect(recentCrossings([499], 1500)).toEqual([]);
+ });
+
+ it('returns a new array rather than mutating its input', () => {
+ const input = [0, 900];
+ const out = recentCrossings(input, 1000);
+ out.push(1000);
+ expect(input).toEqual([0, 900]);
+ });
+
+ it('honours a custom window', () => {
+ expect(recentCrossings([100, 400], 500, 200)).toEqual([400]);
+ });
+});
+
+describe('isShakeBurst β the discrimination that thresholding cannot do', () => {
+ it('does not fire on a single jolt, however hard', () => {
+ // The whole point: one crossing is a car bump, a drop, a pocket-pull.
+ expect(isShakeBurst([1000], 1000)).toBe(false);
+ });
+
+ it('does not fire on two crossings inside the window', () => {
+ expect(isShakeBurst([800, 1000], 1000)).toBe(false);
+ });
+
+ it('fires on three crossings inside the window', () => {
+ expect(isShakeBurst([600, 800, 1000], 1000)).toBe(true);
+ });
+
+ it('does not fire when three crossings are spread beyond the window', () => {
+ // Same count, spread over 3s β walking, not shaking.
+ expect(isShakeBurst([0, 1500, 3000], 3000)).toBe(false);
+ });
+
+ it('counts a deliberate shake, which produces many crossings', () => {
+ const burst = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000];
+ expect(isShakeBurst(burst, 1000)).toBe(true);
+ });
+
+ it('honours custom crossing and window parameters', () => {
+ expect(isShakeBurst([900, 1000], 1000, 2)).toBe(true);
+ expect(isShakeBurst([600, 800, 1000], 1000, 3, 300)).toBe(false);
+ });
+});
diff --git a/app/src/core/hooks/useBugReportShake.ts b/app/src/core/hooks/useBugReportShake.ts
index bd234a20..51c38bd7 100644
--- a/app/src/core/hooks/useBugReportShake.ts
+++ b/app/src/core/hooks/useBugReportShake.ts
@@ -6,6 +6,31 @@
* shake. In the public App Store build the flag is off, so this never subscribes
* β zero sensor cost for end users. Safe no-op on the dev sim (empty Sentry DSN
* β showFeedbackForm short-circuits).
+ *
+ * ββ DEBUG-533: WHY THE TRIGGER IS DELIBERATELY HARD TO FIRE ββ
+ *
+ * What this opens is a zero-988-affordance window β see the ruling recorded at
+ * `ExternalErrorReporter.showFeedbackForm()`. This hook is mounted at the app
+ * root, so the gesture is armed on EVERY screen, including `CrisisResources`
+ * and a mid-assessment `AssessmentFlow`. An accidental open is therefore a
+ * crisis-reachability event, not a nuisance: it covers the 988 affordance with
+ * a form the user did not ask for and cannot reliably leave in one tap.
+ *
+ * The old trigger was a SINGLE-SAMPLE magnitude test at 1.8g sampled at 5Hz.
+ * At rest the vector magnitude is already ~1g, so that asked for 0.8g of net
+ * acceleration in one instantaneous reading β cleared by a pocket-pull, a phone
+ * set down hard, a car bump, or brisk walking. A TestFlight user reported it
+ * (Sentry `JAVASCRIPT-REACT-F`, 2026-08-28) and they were right.
+ *
+ * β οΈ THE BURST REQUIREMENT IS THE LOAD-BEARING HALF, NOT THE THRESHOLD. A hard
+ * enough single jolt clears any threshold you pick; only requiring the
+ * condition to HOLD ACROSS SAMPLES separates shaking from being jostled. Do not
+ * "simplify" this back to a one-sample test by raising SHAKE_THRESHOLD further.
+ *
+ * β οΈ AND THIS IS A RATE CONTROL, NOT AN INVARIANT. It reduces how often the
+ * zero-988 window opens by accident; it cannot make opening it acceptable. The
+ * structural fix is an in-hierarchy form rendered into `rootOverlaySlot`, which
+ * is tracked separately.
*/
import { useEffect, useRef } from 'react';
@@ -13,16 +38,21 @@ import { Accelerometer } from 'expo-sensors';
import { isFeatureEnabled } from '@/core/services/featureFlags';
import { showFeedbackForm } from '@/core/services/logging';
-/** g-force magnitude above the ~1g rest reading that counts as a shake. */
-const SHAKE_THRESHOLD = 1.8;
+/** g-force magnitude above the ~1g rest reading that counts as one crossing. */
+const SHAKE_THRESHOLD = 2.7;
+/** Crossings required inside SHAKE_WINDOW_MS before the widget opens. */
+const SHAKE_MIN_CROSSINGS = 3;
+/** Rolling window (ms) those crossings must fall inside. */
+const SHAKE_WINDOW_MS = 1000;
/** Ignore repeat shakes within this window so one shake opens one widget. */
const SHAKE_DEBOUNCE_MS = 2000;
-/** Accelerometer sample interval (ms) β responsive enough, low battery cost. */
-const SAMPLE_INTERVAL_MS = 200;
+/** Accelerometer sample interval (ms). 10Hz β a 2s shake yields ~20 samples. */
+const SAMPLE_INTERVAL_MS = 100;
/**
- * Pure shake test: total acceleration magnitude exceeds the threshold. At rest
- * the vector magnitude is ~1 (gravity); a deliberate shake spikes well past it.
+ * Pure single-sample test: total acceleration magnitude exceeds the threshold.
+ * At rest the vector magnitude is ~1 (gravity). One crossing on its own decides
+ * nothing β see `isShakeBurst`.
*/
export function isShake(
sample: { x: number; y: number; z: number },
@@ -32,8 +62,36 @@ export function isShake(
return Math.sqrt(x * x + y * y + z * z) > threshold;
}
+/**
+ * Pure prune: the crossings still inside the rolling window at `now`. Inclusive
+ * at the edge so a burst spanning exactly the window still counts as one burst.
+ * Returns a new array; never mutates its input.
+ */
+export function recentCrossings(
+ crossings: readonly number[],
+ now: number,
+ windowMs: number = SHAKE_WINDOW_MS,
+): number[] {
+ return crossings.filter((at) => now - at <= windowMs);
+}
+
+/**
+ * Pure decision: did enough crossings land inside the window to count as a
+ * deliberate shake? A deliberate 2s shake produces ~20 crossings at 10Hz; a
+ * single jolt produces one or two.
+ */
+export function isShakeBurst(
+ crossings: readonly number[],
+ now: number,
+ minCrossings: number = SHAKE_MIN_CROSSINGS,
+ windowMs: number = SHAKE_WINDOW_MS,
+): boolean {
+ return recentCrossings(crossings, now, windowMs).length >= minCrossings;
+}
+
export function useBugReportShake(): void {
const lastShakeAt = useRef(0);
+ const crossings = useRef([]);
useEffect(() => {
if (!isFeatureEnabled('bug_reporting')) return undefined;
@@ -42,8 +100,18 @@ export function useBugReportShake(): void {
const subscription = Accelerometer.addListener((sample) => {
if (!isShake(sample)) return;
const now = Date.now();
+ // Inside the debounce the crossing is dropped outright rather than
+ // accumulated, so the shake that just opened the widget cannot seed the
+ // next burst.
if (now - lastShakeAt.current < SHAKE_DEBOUNCE_MS) return;
+
+ const retained = recentCrossings(crossings.current, now);
+ retained.push(now);
+ crossings.current = retained;
+ if (!isShakeBurst(retained, now)) return;
+
lastShakeAt.current = now;
+ crossings.current = [];
showFeedbackForm();
});
diff --git a/app/src/core/services/logging/ExternalErrorReporter.ts b/app/src/core/services/logging/ExternalErrorReporter.ts
index b858d7d7..12fa5450 100644
--- a/app/src/core/services/logging/ExternalErrorReporter.ts
+++ b/app/src/core/services/logging/ExternalErrorReporter.ts
@@ -522,6 +522,74 @@ export class ExternalErrorReporter {
* entry are harmless in dev. The widget itself requires `Sentry.wrap(App)` β
* see App.tsx. Feedback events are sanitized by the scrubFeedbackEvent
* processor registered in initialize().
+ *
+ * ββ β οΈ THIS OPENS A ZERO-988-AFFORDANCE WINDOW (DEBUG-533 RULING) ββ
+ *
+ * Ruled by `crisis`: this IS a DEBUG-406 conversion site. It fails all three
+ * legs of the `NotificationTimePicker` exception, which is the only RN-modal
+ * occlusion DEBUG-406 let stand. Recorded here rather than only in the work
+ * item, for the reason NotificationTimePicker gives: a ruling that lives where
+ * the code cannot see it is how DEBUG-403's four-site analogy survived review.
+ *
+ * WHAT RENDERS. Not, as first reported, a native window outside our tree.
+ * `Sentry.wrap(App)` mounts `FeedbackWidgetProvider` ABOVE
+ * `GestureHandlerRootView`, and its render emits our whole app as `children`
+ * and THEN, as a later sibling, an `Animated.View` at inset-0 animating to
+ * `rgba(0,0,0,0.9)`, and inside that an RN `` whose sheet is
+ * `flex: 1` below a 64pt spacer. So the occlusion is doubled, and the
+ * important half is the FIRST one: a 90%-opaque full-screen backdrop inside
+ * our own JS hierarchy. `RootCrisisButton`'s `zIndex: 9999` cannot reach past
+ * it β zIndex orders siblings, and that backdrop is a later sibling of the
+ * button's ANCESTOR. An RN change that put the crisis button above ``
+ * would not recover this surface.
+ *
+ * WHY THE NotificationTimePicker EXCEPTION DOES NOT EXTEND HERE:
+ * β’ Benign content β FAILS. That ruling's premise is that the picker is
+ * reachable only from Settings by deliberate tap, so it can neither occlude
+ * nor receive a disclosure. This is armed at the app root, so it opens over
+ * `CrisisResources`, over a mid-PHQ-9 `AssessmentFlow`, and over
+ * `VoiceReflectionScreen` right after `scanOnSave`. The wellness-bearing
+ * content is what it OCCLUDES. And the form is a free-text box whose own
+ * placeholder says "avoid typing personal wellness details here", which
+ * concedes it receives them.
+ * β’ Fixed, non-scrolling, one-tap exits β FAILS, and this is decisive.
+ * Cancel is the LAST CHILD of the widget's `ScrollView`, below the required
+ * textarea and the screenshot controls, with `automaticallyAdjustKeyboard-
+ * Insets` on iOS and `showName`/`showEmail` false so the keyboard is up
+ * whenever the user has engaged at all. There is no backdrop tap (the 64pt
+ * spacer is a bare `View`), `onRequestClose` is Android-back only, and the
+ * pull-down dismiss needs `isScrollAtTop && dy > 200` β dead once scrolled.
+ * Dwell is unbounded.
+ * β’ iOS-only Modal, Android a native OS dialog β FAILS AND INVERTS. One
+ * `` on both platforms, so converting splits nothing; and iOS is the
+ * strictly worse platform here, having no hardware back.
+ *
+ * WHY IT IS NOT FIXED IN PLACE. The occluder is third-party code we do not
+ * render, so we cannot host it in `rootOverlaySlot`, cannot add a backdrop
+ * handler, cannot bound the dwell, and cannot inject a 988 control into
+ * Sentry's sheet. There is no compensating control available. The structural
+ * remedy is our OWN form rendered into `rootOverlaySlot` submitting via
+ * `Sentry.captureFeedback()` β a top-level export of @sentry/react-native
+ * (`index.d.ts:2`) and exactly what `FeedbackWidget.js:70` itself calls, so the
+ * SDK stays the transport and we own the presentation. Tracked separately;
+ * doing it must ALSO drop `feedbackIntegration` above, or
+ * `FeedbackWidgetProvider` stays mounted and a stray call re-opens this path.
+ *
+ * MEANWHILE the exposure is bounded by reachability, not by a fix:
+ * `bug_reporting` is off in the public App Store build, DEBUG-533 made the
+ * shake trigger hard to fire by accident, and the Profile card is a deliberate
+ * tap. None of that satisfies the invariant. Do not read the mitigations as
+ * closing the ruling.
+ *
+ * β οΈ NOTE WHAT NO DETECTOR CAN SEE HERE. `check-modal-occlusion-guard.js`
+ * scans `app/src`, so a `` in node_modules is invisible to it, and
+ * INFRA-531's crisis-constant-import detector matches nothing on this path
+ * because nothing here imports from `features/crisis/`. This is a new shape for
+ * that family β not "consumes a crisis constant while matching no path
+ * pattern", but "mounts a third-party component that occludes the crisis
+ * affordance while importing nothing of ours at all". The Protected Paths rows
+ * for this file and `ProfileScreen.tsx` are what gate it; a call-site rule in
+ * the occlusion guard is tracked separately.
*/
showFeedbackForm(): void {
if (!this.isActive() || !this.sentryModule) return;
diff --git a/app/src/features/profile/screens/ProfileScreen.tsx b/app/src/features/profile/screens/ProfileScreen.tsx
index d3bd7bf1..89e56872 100644
--- a/app/src/features/profile/screens/ProfileScreen.tsx
+++ b/app/src/features/profile/screens/ProfileScreen.tsx
@@ -427,7 +427,14 @@ const ProfileScreen: React.FC = () => {
{/* FEAT-284: internal-only bug/feedback entry. Gated on the build-time
`bug_reporting` flag. Opens Sentry's feedback widget (screenshot +
form); you can also shake the device from anywhere. Discoverable
- fallback for the shake gesture. */}
+ fallback for the shake gesture.
+
+ β οΈ DEBUG-533: what this opens is a zero-988-affordance window β a
+ DEBUG-406 conversion site that cannot be converted in place because
+ the occluder is third-party. The full ruling is recorded at
+ `ExternalErrorReporter.showFeedbackForm()`; read it before adding a
+ second entry point or moving this one onto a non-settings route.
+ This file is a Protected Path for that reason and no other. */}
{isFeatureEnabled('bug_reporting') && (
Date: Sun, 30 Aug 2026 15:47:42 -0700
Subject: [PATCH 45/90] feat: FEAT-567 pin the Epictetus digitization and
repair four mis-attributed passages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Scoped at batch approval to the GATE + the repair it uncovered. The content
expansion is FEAT-569, where AC1's per-principle count gets chosen.
THE FINDING. Building the provenance gate surfaced a live defect it immediately
catches: all four shipped Epictetus passages declared "Elizabeth Carter" and
carried an unattributed MODERNISED revision instead. Verified against the 1759
printing (Wikisource transcription of IA scan allworksofepicte00epic) β Ench. 1
reads "Of Things, some are in our Power", not "Some things are in our control",
and Ench. 8 contains "Don't", impossible in a 1758/59 setting. MIT's Internet
Classics Archive copy is credited to Carter but reads "carried" at Ench. 43 where
the print reads "borne", so it is a different text under a real translator's name.
This is the DEBUG-352 PR-B defect class exactly, and it survived that sweep
because DEBUG-352 pinned only the loci it had already repaired β Long and
Stewart. Epictetus was never checked. The reviser is unattributed, so the shipped
text's copyright status was not merely mislabelled but unverified.
Two further sites carried the same text, found by following the change rather
than by the item's scope: PRACTICE_QUOTES `control-sorting` (whose own comment
claimed it was "byte-identical to that corpus entry"), and
module-3-sphere-sovereignty.json, still credited to Carter. DEBUG-343 did remove
Nicholas White from that module β but substituted the modernised text, so the
comment recording it as fixed was half right. Both re-cut against the pin.
NORMALISATIONS AND ONE CORRECTION, all recorded rather than silent: long-s to s;
a space before punctuation where the transcription italicises a proper noun; and
Ench. 8, where the pinned transcription itself reads "as you with" β a long-s OCR
error for "wiΕΏh". The corpus carries the corrected reading and the suite pins it
in both directions, so the correction is falsifiable rather than folklore. The
transcription defect is named in the docblock; do not "fix" the corpus back to it.
No model transcribed any shipped text. The spans were extracted from the
MediaWiki parse API output by script, after a model-mediated fetch was caught
reporting "wish" where the digitization actually reads "with".
AUTHOR BALANCE ships as a RATCHET, not a flat rule. Enforcing it outright fails
four of five principles today (Marcus was 9/16; interconnected-living is 3/3
Marcus), and a gate that cannot go green trains people to bypass gates. Existing
violations are DECLARED and may not worsen; anything undeclared must comply, a
stale declaration fails, and a non-vacuity check refuses the all-declared state.
CI GAP CLOSED. passagesContent.test.ts held the schema checks and ran on nobody's
PR. Wired by pattern (test:library + a job step), never by renaming toward one.
That wiring immediately earned itself: it caught FromTheSourceSection's two text
probes, which this repair broke and which nothing would otherwise have run.
Every control mutation-verified: undeclaring a real violation reds 2; declaring a
false one reds 2; the modernised readings are pinned negatively; the matcher is
proven to fire against a literal known-bad string.
test:library 32 passed / 4 suites; app/__tests__/unit + practiceQuotes 711 passed
/ 58 suites. Uncovered test files 46 -> 42.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
.github/workflows/ci.yml | 10 +
.../unit/classicalCorpusProvenance.test.ts | 186 ++++++++++++++++++
.../modules/module-3-sphere-sovereignty.json | 2 +-
.../passages/passages-1-aware-presence.json | 2 +-
.../passages-2-radical-acceptance.json | 4 +-
.../passages-3-sphere-sovereignty.json | 8 +-
app/package.json | 1 +
app/scripts/ci-uncovered-tests.json | 14 +-
.../practices/PracticeCompletionScreen.tsx | 15 +-
.../__tests__/FromTheSourceSection.test.tsx | 6 +-
10 files changed, 224 insertions(+), 24 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bd1acac4..9c9c4c53 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -367,6 +367,16 @@ jobs:
# Wired by PATTERN, never by renaming files toward one (DEBUG-468 precedent).
run: cd app && npm run test:analytics -- --ci --testTimeout=20000
+ - name: Classical library tests
+ # FEAT-567. passagesContent.test.ts holds the passage SCHEMA checks β
+ # author/work enum membership, principle match, duplicate ids, required
+ # fields β and matched no CI pattern, so a malformed passage could land
+ # unnoticed. The provenance suite that checks TRANSLATORS already runs
+ # (it lives under app/__tests__/unit/); this is the other half.
+ #
+ # Wired by PATTERN, never by renaming files toward one (DEBUG-468 precedent).
+ run: cd app && npm run test:library -- --ci --testTimeout=20000
+
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v6
diff --git a/app/__tests__/unit/classicalCorpusProvenance.test.ts b/app/__tests__/unit/classicalCorpusProvenance.test.ts
index a024e269..b3aa4c25 100644
--- a/app/__tests__/unit/classicalCorpusProvenance.test.ts
+++ b/app/__tests__/unit/classicalCorpusProvenance.test.ts
@@ -44,6 +44,31 @@
* so elisions stay visible. Long's bracketed glosses are omitted per this
* corpus's existing convention (see 7.29, which drops Long's "[formal]").
*
+ * CANONICAL DIGITIZATION β Epictetus (FEAT-567). Wikisource's transcription of
+ * "All the Works of Epictetus, Which Are Now Extant", trans. Elizabeth Carter,
+ * 1759, from Internet Archive scan `allworksofepicte00epic`. Pinned for exactly
+ * the reason Long was: the digitizations DISAGREE, and not only on orthography.
+ * MIT's Internet Classics Archive text is credited to Carter but is a silently
+ * MODERNISED revision by an unattributed hand β it reads "may be carried" at
+ * Ench. 43 where the 1759 print reads "may be borne", and uses contractions
+ * ("Don't") that cannot occur in a 1758/59 setting. FEAT-567 found all four
+ * shipped Epictetus passages had been drawn from that modernised text while
+ * declaring "Elizabeth Carter", which is the DEBUG-352 defect class exactly β
+ * a real translator's name over another text β and it survived DEBUG-352
+ * because that sweep pinned only the loci it had already repaired.
+ *
+ * TWO NORMALISATIONS ARE APPLIED TO THE PINNED TEXT, both recorded so they are
+ * auditable rather than invisible:
+ * 1. Long-s: the 1759 print sets `ΕΏ`; the corpus uses `s`.
+ * 2. The transcription emits a space before punctuation where the print
+ * italicises a proper noun ("Socrates ." -> "Socrates.").
+ *
+ * ONE LOCUS CORRECTION, likewise recorded rather than silently applied:
+ * Ench. 8 in the pinned transcription reads "as you with; but with them" β a
+ * long-s OCR error for "wiΕΏh". The corpus carries the corrected reading and the
+ * assertion below pins it, so the correction is falsifiable rather than folklore.
+ * Do NOT "fix" the corpus back to the transcription's literal text.
+ *
* LOCATION: `app/__tests__/unit/` on purpose β the sibling provenance suites
* under `app/src/features//__tests__/` match none of CI's
* `--testPathPattern` values, so they never run in CI or precommit.
@@ -126,6 +151,167 @@ describe('classical corpus provenance (DEBUG-352)', () => {
* allowlisted translator, they just weren't that translator's words. Pinning
* the opening clause is what makes a silent re-rewrite fail.
*/
+ /**
+ * AUTHOR BALANCE (FEAT-567, philosopher ruling).
+ *
+ * Marcus Aurelius is by far the easiest of the three to mine, so left to
+ * convenience he dominates every principle β which is precisely what the parent
+ * item's AC4 forbids. Measured before this change: Marcus 9/16 overall, and
+ * `interconnected-living` 100% Marcus with zero Epictetus and zero Seneca.
+ *
+ * Shipped as a RATCHET, not a flat rule, and deliberately so: enforcing it
+ * outright fails four of five principles today, and a gate that cannot go green
+ * is the shape that trains people to bypass gates. So the existing violations are
+ * DECLARED below and may not worsen; anything not declared must comply. The debt
+ * is discharged by FEAT-569, which owns the content.
+ *
+ * Rules (a) and (c) apply only at 4+ passages: below that the ratios are too
+ * coarse to be meaningful β a 3-passage principle cannot have a work supply
+ * "half" of it in any useful sense.
+ */
+ describe('per-principle author balance (FEAT-567)', () => {
+ const PRINCIPLE_FILES = [
+ 'passages-1-aware-presence.json',
+ 'passages-2-radical-acceptance.json',
+ 'passages-3-sphere-sovereignty.json',
+ 'passages-4-virtuous-response.json',
+ 'passages-5-interconnected-living.json',
+ ];
+
+ /** Declared, dischargeable debt β each entry is owed by FEAT-569. */
+ const BALANCE_DEBT: Readonly> = {
+ 'passages-1-aware-presence.json': ['Seneca'],
+ 'passages-2-radical-acceptance.json': ['Seneca'],
+ 'passages-4-virtuous-response.json': ['Epictetus'],
+ 'passages-5-interconnected-living.json': ['Epictetus', 'Seneca'],
+ };
+
+ const REQUIRED = ['Epictetus', 'Seneca'] as const;
+
+ const counts = (file: string) => {
+ const ps = loadPassages(file);
+ const byAuthor: Record = {};
+ const byWork: Record = {};
+ for (const x of ps) {
+ byAuthor[x.author] = (byAuthor[x.author] ?? 0) + 1;
+ byWork[x.work] = (byWork[x.work] ?? 0) + 1;
+ }
+ return { total: ps.length, byAuthor, byWork };
+ };
+
+ it.each(PRINCIPLE_FILES)('%s satisfies the rule, or its gap is declared', (file) => {
+ const { total, byAuthor, byWork } = counts(file);
+ const declared = BALANCE_DEBT[file] ?? [];
+
+ // (b) every principle carries at least one Epictetus and one Seneca β
+ // unless that author is a declared, still-outstanding debt.
+ for (const author of REQUIRED) {
+ if (declared.includes(author)) continue;
+ expect({ file, author, have: byAuthor[author] ?? 0 }).toEqual({
+ file,
+ author,
+ have: expect.any(Number),
+ });
+ expect(byAuthor[author] ?? 0).toBeGreaterThan(0);
+ }
+
+ if (total >= 4) {
+ // (a) Marcus may not exceed half.
+ expect(byAuthor['Marcus Aurelius'] ?? 0).toBeLessThanOrEqual(Math.floor(total / 2));
+ // (c) no single work supplies more than half.
+ for (const [work, n] of Object.entries(byWork)) {
+ expect({ work, n }).toEqual({ work, n: expect.any(Number) });
+ expect(n).toBeLessThanOrEqual(Math.floor(total / 2));
+ }
+ }
+ });
+
+ it('the declared debt is real and not stale', () => {
+ // A principle that has since been balanced must be REMOVED from the debt
+ // list. A stale entry silently exempts a principle that no longer needs it,
+ // which is how a ratchet quietly stops ratcheting.
+ for (const [file, authors] of Object.entries(BALANCE_DEBT)) {
+ const { byAuthor } = counts(file);
+ for (const author of authors) {
+ expect({ file, author, present: (byAuthor[author] ?? 0) > 0 }).toEqual({
+ file,
+ author,
+ present: false,
+ });
+ }
+ }
+ });
+
+ it('the debt may not grow β no undeclared principle is unbalanced', () => {
+ const undeclared = PRINCIPLE_FILES.filter((f) => !(f in BALANCE_DEBT));
+ // Non-vacuity: if every principle were declared, the it.each above would
+ // assert nothing at all and this suite would be theatre.
+ expect(undeclared.length).toBeGreaterThan(0);
+ for (const f of undeclared) {
+ const { byAuthor } = counts(f);
+ for (const author of REQUIRED) expect(byAuthor[author] ?? 0).toBeGreaterThan(0);
+ }
+ });
+ });
+
+ describe('Epictetus is 1759 Carter, not the modernised revision (FEAT-567)', () => {
+ const findById = (file: string, id: string): Passage => {
+ const p = loadPassages(file).find((x) => x.id === id);
+ if (!p) throw new Error(`${id} missing from ${file}`);
+ return p;
+ };
+
+ // Positive pins: an opening clause that ONLY the 1759 setting produces.
+ it.each([
+ ['passages-3-sphere-sovereignty.json', 'epictetus-enchiridion-1', 'Of Things, some are in our Power, and others not.'],
+ ['passages-3-sphere-sovereignty.json', 'epictetus-enchiridion-2', 'Remember that Desire promises the Attainment'],
+ ['passages-1-aware-presence.json', 'epictetus-enchiridion-5', 'Men are disturbed, not by Things, but by the Principles and Notions'],
+ ['passages-2-radical-acceptance.json', 'epictetus-enchiridion-8', 'Require not Things to happen as you wish'],
+ ])('%s / %s opens with the 1759 Carter wording', (file, id, opening) => {
+ expect(findById(file, id).text).toContain(opening);
+ });
+
+ // Negative pins: the modernised readings that WERE shipped. A name check
+ // alone cannot catch this class β those entries did declare an allowlisted
+ // translator, they simply were not that translator's words.
+ it('the modernised revision does not come back', () => {
+ const all = [
+ ...loadPassages('passages-1-aware-presence.json'),
+ ...loadPassages('passages-2-radical-acceptance.json'),
+ ...loadPassages('passages-3-sphere-sovereignty.json'),
+ ].filter((x) => x.author === 'Epictetus');
+
+ for (const p of all) {
+ // Contractions are impossible in a 1758/59 setting and are the cheapest
+ // single tell that a modernised text has been substituted.
+ expect(p.text).not.toMatch(/\b(don't|can't|won't|isn't|doesn't)\b/i);
+ }
+
+ const byId = Object.fromEntries(all.map((x) => [x.id, x.text]));
+ expect(byId['epictetus-enchiridion-1']).not.toContain('Some things are in our control');
+ expect(byId['epictetus-enchiridion-5']).not.toContain('Someone just starting instruction');
+ expect(byId['epictetus-enchiridion-8']).not.toContain('demand that things happen');
+ });
+
+ it('Ench. 8 carries the RECORDED locus correction, not the OCR defect', () => {
+ // The pinned transcription reads "as you with; but with them" β a long-s
+ // misread of "wiΕΏh". The corpus carries the corrected reading. Pinned in
+ // both directions so neither the defect nor a silent re-edit can land.
+ const t = findById('passages-2-radical-acceptance.json', 'epictetus-enchiridion-8').text;
+ expect(t).toContain('as you wish; but wish them to happen as they do happen');
+ expect(t).not.toContain('as you with');
+ });
+
+ it('the matcher still fires (DEBUG-390)', () => {
+ // Prove these assertions can go red: run the same predicate over a literal
+ // known-bad string rather than over corpus state, which would make the
+ // control a second symptom of the same failure.
+ const modernised = "Don't demand that things happen as you wish";
+ expect(modernised).toMatch(/\b(don't|can't|won't|isn't|doesn't)\b/i);
+ expect(modernised).toContain('demand that things happen');
+ });
+ });
+
describe('repaired loci stay verbatim (PG #15877 Long / Stewart)', () => {
const findPassage = (file: string, citation: string): Passage => {
const p = loadPassages(file).find((x) => x.citation === citation);
diff --git a/app/assets/modules/module-3-sphere-sovereignty.json b/app/assets/modules/module-3-sphere-sovereignty.json
index fdacf36f..840dc75d 100644
--- a/app/assets/modules/module-3-sphere-sovereignty.json
+++ b/app/assets/modules/module-3-sphere-sovereignty.json
@@ -6,7 +6,7 @@
"description": "Distinguish what you control from what you don'tβand focus your energy accordingly.",
"estimatedMinutes": 25,
"classicalQuote": {
- "text": "Some things are in our control and others not. Things in our control are opinion, pursuit, desire, aversion, and, in a word, whatever are our own actions. Things not in our control are body, property, reputation, command, and, in one word, whatever are not our own actions.",
+ "text": "Of Things, some are in our Power, and others not. In our Power are Opinion, Pursuit, Desire, Aversion, and in one Word, whatever are our own Actions. Not in our Power, are Body, Property, Reputation, Command, and, in one Word, whatever are not our own Actions.",
"author": "Epictetus",
"source": "Enchiridion 1 (trans. Elizabeth Carter)"
},
diff --git a/app/assets/passages/passages-1-aware-presence.json b/app/assets/passages/passages-1-aware-presence.json
index ec9c83b0..ee1c291b 100644
--- a/app/assets/passages/passages-1-aware-presence.json
+++ b/app/assets/passages/passages-1-aware-presence.json
@@ -30,7 +30,7 @@
"work": "Enchiridion",
"citation": "Enchiridion 5",
"translation": "Elizabeth Carter",
- "text": "Men are disturbed, not by things, but by the principles and notions which they form concerning things. Death, for instance, is not terrible, else it would have appeared so to Socrates. But the terror consists in our notion of death that it is terrible. When therefore we are hindered, or disturbed, or grieved, let us never attribute it to others, but to ourselves; that is, to our own principles. An uninstructed person will lay the fault of his own bad condition upon others. Someone just starting instruction will lay the fault on himself. Some who is perfectly instructed will place blame neither on others nor on himself.",
+ "text": "Men are disturbed, not by Things, but by the Principles and Notions, which they form concerning Things. Death, for Instance, is not terrible, else it would have appeared so to Socrates. But the Terror consists in our Notion of Death, that it is terrible. When therefore we are hindered, or disturbed, or grieved, let us never impute it to others, but to ourselves; that is, to our own Principles. It is the Action of an uninstructed Person to lay the Fault of his own bad Condition upon others; of one entering upon Instruction, to lay the Fault on himself, and of one perfectly instructed, neither on others, nor on himself.",
"context": "From the Handbook; Epictetus separates the bare impression of an event from the judgment we add to it.",
"order": 3
}
diff --git a/app/assets/passages/passages-2-radical-acceptance.json b/app/assets/passages/passages-2-radical-acceptance.json
index 7c706605..df78d97b 100644
--- a/app/assets/passages/passages-2-radical-acceptance.json
+++ b/app/assets/passages/passages-2-radical-acceptance.json
@@ -9,7 +9,7 @@
"citation": "Meditations 4.23",
"translation": "George Long",
"text": "Everything harmonizes with me, which is harmonious to thee, O Universe. Nothing for me is too early nor too late, which is in due time for thee. Everything is fruit to me which thy seasons bring, O Nature: from thee are all things, in thee are all things, to thee all things return.",
- "context": "An address to Nature, the classic expression of the Stoic acceptance of fate \u2014 later called amor fati, a name Nietzsche gave it rather than one the Stoics used.",
+ "context": "An address to Nature, the classic expression of the Stoic acceptance of fate β later called amor fati, a name Nietzsche gave it rather than one the Stoics used.",
"order": 1
},
{
@@ -19,7 +19,7 @@
"work": "Enchiridion",
"citation": "Enchiridion 8",
"translation": "Elizabeth Carter",
- "text": "Don't demand that things happen as you wish, but wish that they happen as they do happen, and you will go on well.",
+ "text": "Require not Things to happen as you wish; but wish them to happen as they do happen; and you will go on well.",
"context": "One of the Handbook's shortest maxims, stating non-resistance to events directly.",
"order": 2
},
diff --git a/app/assets/passages/passages-3-sphere-sovereignty.json b/app/assets/passages/passages-3-sphere-sovereignty.json
index b3a4d847..985552b9 100644
--- a/app/assets/passages/passages-3-sphere-sovereignty.json
+++ b/app/assets/passages/passages-3-sphere-sovereignty.json
@@ -8,7 +8,7 @@
"work": "Enchiridion",
"citation": "Enchiridion 1",
"translation": "Elizabeth Carter",
- "text": "Some things are in our control and others not. Things in our control are opinion, pursuit, desire, aversion, and, in a word, whatever are our own actions. Things not in our control are body, property, reputation, command, and, in one word, whatever are not our own actions. The things in our control are by nature free, unrestrained, unhindered; but those not in our control are weak, slavish, restrained, belonging to others. Remember, then, that if you suppose that things which are slavish by nature are also free, and that what belongs to others is your own, then you will be hindered. You will lament, you will be disturbed, and you will find fault both with gods and men.",
+ "text": "Of Things, some are in our Power, and others not. In our Power are Opinion, Pursuit, Desire, Aversion, and in one Word, whatever are our own Actions. Not in our Power, are Body, Property, Reputation, Command, and, in one Word, whatever are not our own Actions. Now, the Things in our Power are, by Nature, free, unrestrained, unhindered: But those not in our Power, weak, slavish, restrained, belonging to others. Remember then, that, if you suppose Things by Nature slavish, to be free; and what belongs to others, your own; you will be hindered; you will lament; you will be disturbed; you will find fault both with Gods and Men.",
"context": "The opening of the Handbook and the founding statement of the dichotomy of control.",
"order": 1
},
@@ -19,7 +19,7 @@
"work": "Enchiridion",
"citation": "Enchiridion 2",
"translation": "Elizabeth Carter",
- "text": "Remember that following desire promises the attainment of that of which you are desirous; and aversion promises the avoiding that to which you are averse. However, he who fails to obtain the object of his desire is disappointed, and he who incurs the object of his aversion wretched. If, then, you confine your aversion to those objects only which are contrary to the natural use of your faculties, which you have in your own control, you will never incur anything to which you are averse.",
+ "text": "Remember that Desire promises the Attainment of that of which you are desirous; and Aversion promises the Avoiding of that to which you are averse: that he who fails of the Object of his Desire, is disappointed: and he who incurs the Object of his Aversion, wretched. If then, you confine your Aversion to those Objects only, which are contrary to that natural Use of your Faculties, which you have in your own Power, you will never incur any thing to which you are averse.",
"context": "Epictetus applies the dichotomy to desire and aversion, directing them only toward what lies within our power.",
"order": 2
},
@@ -30,8 +30,8 @@
"work": "On Tranquility",
"citation": "On Tranquility of Mind 13",
"translation": "Aubrey Stewart",
- "text": "I will set sail unless anything happens to prevent me, I shall be praetor, if nothing hinders me, my financial operations will succeed, unless anything goes wrong with them. This is why we say that nothing befals the wise man which he did not expect\u2014we do not make him exempt from the chances of human life, but from its mistakes, nor does everything happen to him as he wished it would, but as he thought it would: now his first thought was that his purpose might meet with some resistance, and the pain of disappointed wishes must affect a man's mind less severely if he has not been at all events confident of success.",
- "context": "Seneca gives the reserve clause in its plainest form \u2014 three ordinary plans, each qualified \u2014 and draws the consequence: the wise man is not exempt from chance, only from surprise.",
+ "text": "I will set sail unless anything happens to prevent me, I shall be praetor, if nothing hinders me, my financial operations will succeed, unless anything goes wrong with them. This is why we say that nothing befals the wise man which he did not expectβwe do not make him exempt from the chances of human life, but from its mistakes, nor does everything happen to him as he wished it would, but as he thought it would: now his first thought was that his purpose might meet with some resistance, and the pain of disappointed wishes must affect a man's mind less severely if he has not been at all events confident of success.",
+ "context": "Seneca gives the reserve clause in its plainest form β three ordinary plans, each qualified β and draws the consequence: the wise man is not exempt from chance, only from surprise.",
"order": 3
}
]
diff --git a/app/package.json b/app/package.json
index 9f78641c..197137e2 100644
--- a/app/package.json
+++ b/app/package.json
@@ -31,6 +31,7 @@
"test:scripts": "jest --testPathPattern=__tests__/scripts --verbose --forceExit",
"test:practices": "jest --testPathPattern=src/features/practices --verbose --forceExit",
"test:analytics": "jest --testPathPattern=src/core/analytics --verbose --forceExit",
+ "test:library": "jest --testPathPattern=src/features/library --verbose --forceExit",
"test:encryption": "jest --testPathPattern=EncryptionService --verbose",
"test:crisis-quick": "jest --testPathPattern=\"[Cc]risis\" --ci --silent --testTimeout=5000 --maxWorkers=4",
"test:clinical-quick": "JEST_QUICK=true jest --testNamePattern=\"clinical|phq|gad\"",
diff --git a/app/scripts/ci-uncovered-tests.json b/app/scripts/ci-uncovered-tests.json
index 0e974cc6..36428be8 100644
--- a/app/scripts/ci-uncovered-tests.json
+++ b/app/scripts/ci-uncovered-tests.json
@@ -1,5 +1,5 @@
{
- "note": "INFRA-368. Test files that match NO CI --testPathPattern and therefore run on NOBODY'S pull request. They still run in `npm run precommit` only if their path happens to match one of its patterns; most of these match none of those either. This file exists so that state is DECLARED rather than emergent: `npm run check:ci-test-coverage` fails when a new test file lands ungated and unlisted, and also when a listed file becomes covered (a stale entry hides the next real gap). Regenerate the path list with `--update-allowlist`; it merges, so these annotations survive. Shrinking this list is the point β it is a debt register, not a permanent exemption.",
+ "note": "INFRA-368. Test files that match NO CI --testPathPattern and therefore run on NOBODY'S pull request. They still run in `npm run precommit` only if their path happens to match one of its patterns; most of these match none of those either. This file exists so that state is DECLARED rather than emergent: `npm run check:ci-test-coverage` fails when a new test file lands ungated and unlisted, and also when a listed file becomes covered (a stale entry hides the next real gap). Regenerate the path list with `--update-allowlist`; it merges, so these annotations survive. Shrinking this list is the point \u2014 it is a debt register, not a permanent exemption.",
"measured": {
"on": "chore/INFRA-368-ci-safety-privacy-gates, after back-merging origin/development",
"testFiles": 196,
@@ -13,12 +13,12 @@
},
"why": {
"_": "Every remaining entry is under src/**, i.e. co-located with its consumer rather than under a top-level app/__tests__/ directory that a pattern names. After this change all of app/__tests__/{safety,privacy,compliance,scripts}/ IS gated. These 52 were VERIFIED GREEN and substantive during INFRA-368 planning (1014 assertions, zero .skip, zero trivial placeholders), so they are ungated by accident of directory layout, NOT because they are redundant.",
- "wiring-them-is-deferred": "Wiring the remainder as a required gate is a follow-up, blocked on one file: src/features/assessment/stores/__tests__/assessmentStore.test.ts > 'respects auto-save disabled state' is load-dependent β it passes 3/3 solo and failed 2 of 6 under parallel/batch load. `CI pass` is the SOLE required status check on both main and development with enforce_admins:true, so wiring it unfixed would intermittently block every PR in the repo, and a flaky safety gate trains --admin merge-through, which is strictly worse than no gate. Its root cause is a real product question, not a bad assertion: zustand persist writes on every set() regardless of autoSaveEnabled, which gates only the debounced save β so 'auto-save off' still persists wellness answers. That needs a decision, not a quiet patch inside a CI-plumbing PR.",
- "do-not-fix-by-renaming": "The tempting shortcut β rename a file so it matches an existing pattern β is forbidden. Coverage-by-filename is the exact defect this item documents: 6 of the 12 files under app/__tests__/safety/ run in CI today ONLY because their names contain 'crisis'. Renaming toward a pattern is as fragile as renaming away from one, and dragging a file into test:crisis-quick also subjects it to that job's much shorter --testTimeout=5000. Fix the pattern, never the filename.",
+ "wiring-them-is-deferred": "Wiring the remainder as a required gate is a follow-up, blocked on one file: src/features/assessment/stores/__tests__/assessmentStore.test.ts > 'respects auto-save disabled state' is load-dependent \u2014 it passes 3/3 solo and failed 2 of 6 under parallel/batch load. `CI pass` is the SOLE required status check on both main and development with enforce_admins:true, so wiring it unfixed would intermittently block every PR in the repo, and a flaky safety gate trains --admin merge-through, which is strictly worse than no gate. Its root cause is a real product question, not a bad assertion: zustand persist writes on every set() regardless of autoSaveEnabled, which gates only the debounced save \u2014 so 'auto-save off' still persists wellness answers. That needs a decision, not a quiet patch inside a CI-plumbing PR.",
+ "do-not-fix-by-renaming": "The tempting shortcut \u2014 rename a file so it matches an existing pattern \u2014 is forbidden. Coverage-by-filename is the exact defect this item documents: 6 of the 12 files under app/__tests__/safety/ run in CI today ONLY because their names contain 'crisis'. Renaming toward a pattern is as fragile as renaming away from one, and dragging a file into test:crisis-quick also subjects it to that job's much shorter --testTimeout=5000. Fix the pattern, never the filename.",
"src/core/config/env.test.ts": "Worth singling out: the only full env-schema coverage in the repo, and it is not even under a __tests__/ directory, so any 'just add a src/**/__tests__ pattern' shortcut would still miss it.",
- "caught-in-flight": "DailyLoopCompleteScreen.completionRegister.test.tsx was added to this list by the gate itself, not by hand. It landed on development from a sibling work item WHILE INFRA-368 was in progress, and back-merging origin/development into this branch made `npm run check:ci-test-coverage` fail immediately: a brand-new test file matching no CI pattern, i.e. running on nobody's pull request. That is this item's thesis reproducing itself within hours on unrelated work, and it is the argument for keeping this ledger enforced rather than reporting-only β nobody would have noticed otherwise.",
+ "caught-in-flight": "DailyLoopCompleteScreen.completionRegister.test.tsx was added to this list by the gate itself, not by hand. It landed on development from a sibling work item WHILE INFRA-368 was in progress, and back-merging origin/development into this branch made `npm run check:ci-test-coverage` fail immediately: a brand-new test file matching no CI pattern, i.e. running on nobody's pull request. That is this item's thesis reproducing itself within hours on unrelated work, and it is the argument for keeping this ledger enforced rather than reporting-only \u2014 nobody would have noticed otherwise.",
"practices-wired-debug-468": "DEBUG-468 shrank this list by 10 (55 -> 45) by adding a `test:practices` step to the Unit + integration job. Three of the ten were landing in the same PR; the other seven had run on nobody's pull request since they were written. Scoped to src/features/practices/ and NOT widened to src/**, because the general case is still blocked by the assessmentStore.test.ts flake recorded in wiring-them-is-deferred.",
- "src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx": "DEBUG-516. Same class as its consumer's suite, src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx, which is already listed here: co-located under src/**, so no CI --testPathPattern reaches it, and it matches none of precommit's patterns either (unit|safety|clinical|privacy) β so it runs on no PR and in no hook, only on demand. NOT fixed by renaming toward a pattern (see do-not-fix-by-renaming). Worth flagging when the deferred wiring happens: this hook is the single source for the keyboard frame that VoiceReflectionScreen's pinned action footer is inset against, and that footer is the only route to scanOnSave, the app's sole crisis scan of text a user typed or corrected. The behavioural contract IS gated β Phase 2.5 runs journal-crisis-scan on any features/journal change β so this suite's absence costs the subscription-shape and snapshot pins, not the reachability contract."
+ "src/core/hooks/__tests__/useKeyboardFrameHeight.test.tsx": "DEBUG-516. Same class as its consumer's suite, src/features/journal/screens/__tests__/VoiceReflectionScreen.behavioral.test.tsx, which is already listed here: co-located under src/**, so no CI --testPathPattern reaches it, and it matches none of precommit's patterns either (unit|safety|clinical|privacy) \u2014 so it runs on no PR and in no hook, only on demand. NOT fixed by renaming toward a pattern (see do-not-fix-by-renaming). Worth flagging when the deferred wiring happens: this hook is the single source for the keyboard frame that VoiceReflectionScreen's pinned action footer is inset against, and that footer is the only route to scanOnSave, the app's sole crisis scan of text a user typed or corrected. The behavioural contract IS gated \u2014 Phase 2.5 runs journal-crisis-scan on any features/journal change \u2014 so this suite's absence costs the subscription-shape and snapshot pins, not the reachability contract."
},
"uncovered": [
"src/core/config/__tests__/env.quick.test.ts",
@@ -62,10 +62,6 @@
"src/features/learn/__tests__/moduleClassicalQuotes.test.ts",
"src/features/learn/practices/__tests__/practiceQuotes.test.ts",
"src/features/learn/stores/__tests__/educationStore.test.ts",
- "src/features/library/__tests__/ClassicalLibraryScreen.test.tsx",
- "src/features/library/__tests__/FromTheSourceSection.test.tsx",
- "src/features/library/__tests__/PassageReaderScreen.test.tsx",
- "src/features/library/__tests__/passagesContent.test.ts",
"src/features/onboarding/screens/__tests__/OnboardingScreen.test.tsx"
]
}
diff --git a/app/src/features/learn/practices/PracticeCompletionScreen.tsx b/app/src/features/learn/practices/PracticeCompletionScreen.tsx
index 64967cba..fd2a171d 100644
--- a/app/src/features/learn/practices/PracticeCompletionScreen.tsx
+++ b/app/src/features/learn/practices/PracticeCompletionScreen.tsx
@@ -146,11 +146,18 @@ export const PRACTICE_QUOTES: Record = {
// public-domain Epictetus is Elizabeth Carter (1758), per the `translation`
// field on every Enchiridion entry in passages-3-sphere-sovereignty.json.
// Now byte-identical to that corpus entry's opening sentence.
- // NOTE: the FULL White paragraph still ships in
- // assets/modules/module-3-sphere-sovereignty.json β a larger exposure than
- // this one, tracked separately as DEBUG-343.
+ // FEAT-567 re-cut this against the PINNED Carter digitization (Wikisource's
+ // transcription of the 1759 printing, IA scan allworksofepicte00epic). The
+ // previous wording β "Some things are in our control and others not." β was
+ // not Carter either: it came from the MIT Internet Classics Archive text,
+ // which is credited to Carter but is an unattributed MODERNISED revision. The
+ // 1759 print reads as below. See classicalCorpusProvenance.test.ts.
+ // (The DEBUG-343 note that used to sit here said the full White paragraph still
+ // shipped in module-3-sphere-sovereignty.json. DEBUG-343 did remove White β but
+ // it substituted the same MODERNISED text this entry carried, still credited to
+ // Carter. FEAT-567 re-cut that module quote against the pinned 1759 printing too.)
'control-sorting': {
- text: 'Some things are in our control and others not.',
+ text: 'Of Things, some are in our Power, and others not.',
author: 'Epictetus',
source: 'Enchiridion 1',
translation: 'Elizabeth Carter',
diff --git a/app/src/features/library/__tests__/FromTheSourceSection.test.tsx b/app/src/features/library/__tests__/FromTheSourceSection.test.tsx
index 84e16ed4..ff8c095e 100644
--- a/app/src/features/library/__tests__/FromTheSourceSection.test.tsx
+++ b/app/src/features/library/__tests__/FromTheSourceSection.test.tsx
@@ -35,9 +35,9 @@ describe('FromTheSourceSection', () => {
it('expands the first passage by default and collapses the rest', () => {
const { queryByText } = render( );
// First passage (Enchiridion 1) text is visible.
- expect(queryByText(/Some things are in our control/)).toBeTruthy();
+ expect(queryByText(/Of Things, some are in our Power/)).toBeTruthy();
// Second passage (Enchiridion 2) text is hidden until expanded.
- expect(queryByText(/following desire promises/)).toBeNull();
+ expect(queryByText(/Desire promises the Attainment/)).toBeNull();
});
it('reveals a passage when its header is tapped', () => {
@@ -45,7 +45,7 @@ describe('FromTheSourceSection', () => {
);
fireEvent.press(getByLabelText('Expand passage: Epictetus, Enchiridion 2'));
- expect(queryByText(/following desire promises/)).toBeTruthy();
+ expect(queryByText(/Desire promises the Attainment/)).toBeTruthy();
// Translator attribution surfaces on expand (passages 1 & 2 are both Carter,
// so both are now visible β assert at least one).
expect(queryAllByText(/trans\. Elizabeth Carter/).length).toBeGreaterThan(0);
From 09162b5c9bbbe39470e04eca738c6b8c6662d0c3 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 16:02:05 -0700
Subject: [PATCH 46/90] chore: correct the stale practice_haptics ship-dark
note in featureFlags.ts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The comment still said the flag "stays false in production until that checklist
is signed off". INFRA-395 (Done) carried the on-device 60fps / cue-latency
sign-off, and practice_haptics is now true in BOTH prod sources -
.config/.env.production and the EAS production env, which is the one a shipped
build actually reads.
Also records that the e2e-sim profile keeps it false, so the gate build does not
exercise haptics - the inverse of the usual ships-dark drift and easy to misread
off the eas.json blob alone.
Comment-only; the inert filter drops it from Phase 2.5.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/src/core/services/featureFlags.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/app/src/core/services/featureFlags.ts b/app/src/core/services/featureFlags.ts
index 3767a775..549cf240 100644
--- a/app/src/core/services/featureFlags.ts
+++ b/app/src/core/services/featureFlags.ts
@@ -66,10 +66,14 @@ export type FeatureFlag =
// low-vision and eyes-closed practitioners, so its availability must not be
// coupled to analytics consent (INFRA-199 carve-out, same reasoning as
// `bug_reporting`). A user who declined analytics must not thereby lose their
- // only non-visual cue channel. Ships dark: the item's 60fps / cue-latency /
+ // only non-visual cue channel. It SHIPPED dark: the item's 60fps / cue-latency /
// degradation checks are on-device manual validation that CI cannot run
// (100% ubuntu, and the iOS simulator emits no haptics at all), so the flag
- // stays false in production until that checklist is signed off.
+ // stayed false in production until that checklist was signed off. INFRA-395
+ // carried that sign-off and it is now `practice_haptics:true` in BOTH prod
+ // sources β `.config/.env.production` and the EAS `production` env, which is
+ // what a shipped build actually reads. Note the e2e-sim profile keeps it
+ // FALSE, so the gate build does not exercise it.
| 'practice_haptics'
// INFRA-395 briefly added a `haptic_trace` diagnostic flag here and REMOVED it
// again. Recorded so nobody re-derives it: the goal was a cue-latency trace
From fb4a1078fc59512bb64d57942206130a13f9c294 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 16:08:10 -0700
Subject: [PATCH 47/90] fix: DEBUG-524 pin voice-journal record-tap process
liveness
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the AC 3 regression pin, ahead of any fix, so the abort can be
observed before it is addressed.
The pin is a host-side probe rather than a Maestro assertion. The abort
is time-based from the record tap and attributes itself to whichever step
is executing ~15s later, so it can surface as an unrelated assertion
failure or as no crash verdict at all. Maestro cannot express the test
honestly: `extendedWaitUntil: visible:` on an already-visible element
returns instantly, `notVisible:` on a live element fails whether or not
the app is healthy, and a `repeat:` block is host-speed dependent, so a
faster machine would produce a weaker test.
The oracle is the app's pid sampled either side of a MEASURED interval.
It cannot be mistaken for a step failure and cannot be satisfied by a
relaunch. A SpringBoard positive control runs first, so a blind oracle
cannot masquerade as a dead app.
- app/.maestro/journal-record-liveness.yaml β drives to phase:'recording'
and stops; asserts journal-stop-button so a swallowed record tap cannot
pass as survival. Tagged safety-host-probe, not safety: it is meaningless
without the probe's dwell, and the 12-flow tripwire is unchanged.
- app/scripts/e2e-audio-liveness-probe.sh β routed through e2e-safety.sh,
never `maestro test`, to keep provenance, device resolution, the sim
lock and the driver reset.
---
app/.maestro/journal-record-liveness.yaml | 74 +++++++++++++
app/package.json | 1 +
app/scripts/e2e-audio-liveness-probe.sh | 129 ++++++++++++++++++++++
3 files changed, 204 insertions(+)
create mode 100644 app/.maestro/journal-record-liveness.yaml
create mode 100755 app/scripts/e2e-audio-liveness-probe.sh
diff --git a/app/.maestro/journal-record-liveness.yaml b/app/.maestro/journal-record-liveness.yaml
new file mode 100644
index 00000000..c8d2b28f
--- /dev/null
+++ b/app/.maestro/journal-record-liveness.yaml
@@ -0,0 +1,74 @@
+appId: fyi.being.app
+tags:
+ - safety-host-probe
+name: "Voice journal record tap does not abort the process (DEBUG-524)"
+# e2e-certifies: any
+# DEBUG-524 β drives the app to `phase: 'recording'` and STOPS. The verdict is the
+# host-side PID sample in scripts/e2e-audio-liveness-probe.sh, not this file.
+---
+# WHY THE ASSERTION IS NOT IN THIS FILE
+# The oracle is the app's PID sampled either side of a MEASURED interval, which is
+# host-side by nature. Every in-flow way to burn the ~15s abort window is a shape
+# DEBUG-524's crisis ruling rejects outright:
+#
+# - `extendedWaitUntil: visible:` on an element that is ALREADY visible returns in
+# milliseconds. Zero dwell, green β the exact "green because fast" failure the
+# work item forbids closing on.
+# - `extendedWaitUntil: notVisible:` on a live element times out whether the app is
+# healthy or dead, so it discriminates nothing.
+# - a `repeat:` block is host-speed dependent, so a FASTER machine produces a
+# SHORTER dwell and a weaker test.
+#
+# WHY THE TAG IS `safety-host-probe` AND NOT `safety`
+# `npm run e2e:safety` globs flows tagged exactly `- safety`; this one is excluded on
+# purpose, on the `safety-device-only` / `safety-dynamic-type` precedent. Running it
+# bare proves nothing β without the probe's dwell there is no window and no verdict.
+# It is still routed through e2e-safety.sh (never `maestro test` directly) so it keeps
+# provenance verification, device resolution, the sim lock and the driver reset. It is
+# deliberately NOT `_`-prefixed: e2e-safety.sh refuses helper subflows outright
+# (DEBUG-505, exit 2), so a `_` name could not be routed through the pre-flight at all.
+#
+# NOTHING MAY BE APPENDED AFTER THE RECORD TAP, except the precondition assert that
+# closes this file. The abort lands ~15s later wherever the flow has got to and
+# attributes itself to whatever step is executing β see the measured note at
+# profile-voice-reflection-xxxl.yaml:118-140. This flow ending at the tap is what
+# leaves the window clean for the probe to own.
+- launchApp:
+ clearState: true
+ clearKeychain: true # SecureStore-backed consent survives clearState (INFRA-179)
+- runFlow: _seeded-home.yaml # INFRA-217: e2e-sim seeds onboarding; start at home
+- tapOn:
+ id: "tab-profile"
+- scrollUntilVisible:
+ element:
+ id: "profile-card-voice-reflection"
+ direction: DOWN
+ centerElement: true # DEBUG-465/477 β a pinned tab bar shares coordinates with clipped content
+ timeout: 40000 # DEBUG-507 β 20000 measured at 95% of budget on an idle host
+# DEBUG-477/479 absorbing tap. XCUITest swallows the synthesised first tap after a flick
+# on a MID-LIST card, and FEAT-287 added a card beneath this one, ending its immunity.
+- tapOn:
+ id: "tab-profile"
+- tapOn:
+ id: "profile-card-voice-reflection"
+- assertVisible:
+ id: "voice-reflection-screen"
+
+# ββ THE ABORT WINDOW OPENS HERE ββββββββββββββββββββββββββββββββββββββββββββββ
+- tapOn:
+ id: "journal-record-button"
+
+# PRECONDITION, NOT DECORATION β the probe is UNSOUND without it.
+# journal-stop-button renders ONLY under `phase === 'recording'`
+# (VoiceReflectionScreen.tsx:512). If the record tap were swallowed β the DEBUG-479
+# class, live on this very card β the recognizer would never start, no AVAudioEngine
+# would be constructed, no RPC would be entered, and the probe would observe a
+# perfectly stable PID and report PASS. That false green is indistinguishable from a
+# fix. Asserting the phase is what makes a stable PID mean "survived the window"
+# rather than "never entered it".
+#
+# It is also the probe's liveness baseline: this assert passing is the evidence the
+# process was ALIVE when the window opened, which is what lets the probe read a
+# missing PID as an abort rather than as a blind oracle.
+- assertVisible:
+ id: "journal-stop-button"
diff --git a/app/package.json b/app/package.json
index 9f78641c..5dd26228 100644
--- a/app/package.json
+++ b/app/package.json
@@ -113,6 +113,7 @@
"e2e:safety:daily-loop": "bash scripts/e2e-safety.sh daily-loop-quick-depth",
"e2e:safety:ax5": "bash scripts/e2e-dynamic-type.sh daily-loop-ax5-entry",
"e2e:safety:xxxl": "E2E_DYNAMIC_TYPE_SIZE=extra-extra-extra-large bash scripts/e2e-dynamic-type.sh profile-voice-reflection-xxxl",
+ "e2e:safety:audio-liveness": "bash scripts/e2e-audio-liveness-probe.sh",
"e2e:safety:telemetry": "bash -c '. scripts/e2e-telemetry.sh; e2e_telemetry_summary \"${1:-}\"' --",
"close:detached": "bash scripts/b-close-run.sh",
"close:status": "bash -c '. scripts/b-close-verdict.sh; b_close_status' --"
diff --git a/app/scripts/e2e-audio-liveness-probe.sh b/app/scripts/e2e-audio-liveness-probe.sh
new file mode 100755
index 00000000..864c79a6
--- /dev/null
+++ b/app/scripts/e2e-audio-liveness-probe.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+#
+# DEBUG-524 β AC 3 regression pin for the voice-journal audio abort.
+#
+# THE DEFECT
+# Tapping `journal-record-button` enters ExpoSpeechRecognizer.prepareMicrophoneRecognition,
+# which touches `AVAudioEngine.inputNode` synchronously on a Swift cooperative-pool thread.
+# That drives AURemoteIO::Cleanup -> a blocking Mach RPC to the simulator's audio daemon;
+# when it does not return, _ReportRPCTimeout calls abort(). The process dies roughly 15
+# SECONDS after the tap, wherever the flow has got to by then.
+#
+# WHY THIS IS A SHELL PROBE AND NOT A MAESTRO ASSERTION
+# The abort is time-based from the tap and mis-attributes itself to whatever step is
+# executing when it lands β one observed run surfaced as a bare `Element not found` with no
+# crash verdict at all. So the pin must (a) dwell past the deadline and (b) read an oracle
+# that cannot be mistaken for a step failure. Maestro can do neither honestly: every in-flow
+# dwell is either instant (`extendedWaitUntil: visible:` on an already-visible element),
+# fails in both directions (`notVisible:` on a live element), or is host-speed dependent
+# (`repeat:`), which makes a FASTER machine produce a WEAKER test.
+#
+# THE ORACLE IS THE PROCESS ID, sampled either side of an interval this script MEASURES
+# rather than configures. A PID cannot be mis-attributed to an assertion, and β unlike any
+# in-app element β it cannot be satisfied by a relaunch, because a new process gets a new
+# PID. `journal-record-liveness.yaml` leaves the app in `phase: 'recording'` and asserts it,
+# which is what licenses reading a MISSING pid as an abort rather than as a blind oracle.
+#
+# THIS MUST NEVER BE "FIXED" BY SHORTENING THE DWELL. A pass here means the process
+# outlived the window; a shorter window means only that the harness outran the bug. That is
+# the one closure DEBUG-524 explicitly forbids.
+#
+# EXIT CODES (deliberately the e2e-safety.sh alphabet)
+# 0 the process survived a measured dwell past the abort deadline
+# 1 REGRESSION β the process died inside the window (the defect)
+# 2 the harness could not produce a verdict (no device, bad precondition, oracle blind)
+
+set -u
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BUNDLE_ID="fyi.being.app"
+FLOW="journal-record-liveness"
+
+# 35s against an observed ~15s latency, measured 3/3. The floor is what the run is judged
+# against; the sleep is merely how the floor is reached, so a slow host overshooting is
+# harmless and a fast host cannot undershoot.
+DWELL_S="${E2E_ABORT_DWELL_S:-35}"
+MIN_DWELL_S="${E2E_ABORT_MIN_DWELL_S:-30}"
+
+. "$SCRIPT_DIR/e2e-sim-device.sh"
+
+fail_harness() { echo "β DEBUG-524 probe β $1" >&2; exit 2; }
+
+# PID for a launchd label on the booted sim. Column 1 is the pid, column 3 the label;
+# a non-running job prints `-` in column 1, which the numeric guard drops.
+_launchctl_pid() {
+ xcrun simctl spawn "$SIM_UDID" launchctl list 2>/dev/null \
+ | awk -v pat="$1" 'index($3, pat) { print $1; exit }' \
+ | grep -E '^[0-9]+$' || true
+}
+
+SIM_UDID="$(e2e_resolve_sim_device "DEBUG-524 audio liveness probe")" || exit 2
+e2e_describe_sim_device "$SIM_UDID"
+
+# ββ POSITIVE CONTROL (crisis ruling 3.4) βββββββββββββββββββββββββββββββββββββββββββββ
+# An oracle that silently stops matching looks EXACTLY like a healthy app: no pid, or a
+# parse that never fires, would otherwise read as "process gone" or be papered over. Prove
+# the read works against a job that is always running on a booted sim BEFORE trusting it
+# about ours. Without this the probe cannot tell "the app died" from "I cannot see pids".
+CONTROL_PID="$(_launchctl_pid 'com.apple.SpringBoard')"
+[ -n "$CONTROL_PID" ] \
+ || fail_harness "oracle is blind: could not read a pid for com.apple.SpringBoard on $SIM_UDID. Refusing to report a verdict about $BUNDLE_ID from a read that does not work."
+echo "β
oracle control β SpringBoard pid $CONTROL_PID readable on $SIM_UDID"
+
+# Attribution only, never the verdict: local .ips reports rotate and can be off entirely,
+# so their absence proves nothing. Snapshot before so a new one can be named after.
+DIAG_DIR="$HOME/Library/Logs/DiagnosticReports"
+DIAG_BEFORE="$(ls "$DIAG_DIR" 2>/dev/null | grep -c '^Being' || true)"
+
+# ββ Establish the precondition βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Routed through e2e-safety.sh, never `maestro test` β a bare invocation bypasses
+# provenance verification, device resolution, the sim lock and the driver reset, which is
+# precisely the hole INFRA-405 closed for the other per-flow scripts.
+echo "βΆοΈ driving to phase:'recording' via $FLOW"
+bash "$SCRIPT_DIR/e2e-safety.sh" "$FLOW"
+FLOW_RC=$?
+[ "$FLOW_RC" -eq 0 ] \
+ || fail_harness "could not establish the precondition ($FLOW exited $FLOW_RC). No window was opened, so there is no verdict about the audio path."
+
+# ββ The window βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+T0="$(date +%s)"
+PID_BEFORE="$(_launchctl_pid "$BUNDLE_ID")"
+if [ -z "$PID_BEFORE" ]; then
+ # The flow's final assertVisible proved the process was alive moments ago, and the control
+ # above proved the read works β so an absent pid here is the abort landing early, not a
+ # blind oracle.
+ echo "β DEBUG-524 REGRESSION β $BUNDLE_ID was alive at the end of $FLOW and is gone before the dwell began." >&2
+ echo " The abort landed inside the flow's own tail. Check $DIAG_DIR for AURemoteIO::Cleanup / _ReportRPCTimeout." >&2
+ exit 1
+fi
+echo "β±οΈ dwelling ${DWELL_S}s from the record tap β app pid $PID_BEFORE"
+sleep "$DWELL_S"
+
+T1="$(date +%s)"
+ELAPSED=$(( T1 - T0 ))
+# The floor is asserted against MEASURED wall clock, not against DWELL_S. A configured
+# timeout that returns early is the single failure mode this whole pin exists to exclude.
+[ "$ELAPSED" -ge "$MIN_DWELL_S" ] \
+ || fail_harness "dwell was ${ELAPSED}s, below the ${MIN_DWELL_S}s floor β the window was never open long enough to cross the ~15s abort deadline, so a pass would be meaningless."
+
+PID_AFTER="$(_launchctl_pid "$BUNDLE_ID")"
+DIAG_AFTER="$(ls "$DIAG_DIR" 2>/dev/null | grep -c '^Being' || true)"
+
+if [ -z "$PID_AFTER" ]; then
+ echo "β DEBUG-524 REGRESSION β $BUNDLE_ID died during a ${ELAPSED}s dwell after the record tap (was pid $PID_BEFORE)." >&2
+ echo " Being crash reports in $DIAG_DIR: ${DIAG_BEFORE} -> ${DIAG_AFTER}" >&2
+ echo " Expected stack: ExpoSpeechRecognizer.prepareMicrophoneRecognition -> AVAudioEngine.inputNode" >&2
+ echo " -> AURemoteIO::Cleanup -> _ReportRPCTimeout -> abort()" >&2
+ exit 1
+fi
+
+if [ "$PID_AFTER" != "$PID_BEFORE" ]; then
+ # Someone reinstalled or relaunched under us. Not a pass: the process that survived is not
+ # the process that entered the window.
+ fail_harness "app pid changed under the probe ($PID_BEFORE -> $PID_AFTER). A relaunch cannot be read as survival; re-run with no peer gate active."
+fi
+
+echo "β
DEBUG-524 β $BUNDLE_ID survived a measured ${ELAPSED}s dwell past the record tap (pid $PID_BEFORE unchanged)."
+[ "$DIAG_AFTER" -gt "$DIAG_BEFORE" ] \
+ && echo "β οΈ note: Being crash reports grew ${DIAG_BEFORE} -> ${DIAG_AFTER} during the run β a DIFFERENT crash may have occurred."
+exit 0
From 1553e1de802a1833cbb2f6d693b4ca4996ac661b Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 16:11:07 -0700
Subject: [PATCH 48/90] chore: MAINT-566 rewrite the evening VirtuousResponse
placeholder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Source: a TestFlight reader on the example under the virtue-review prompt β
"'Short on temperance at dinner' is never said by any human."
philosopher ruling, which is what settles the AC2/AC3 tension: what must survive
is the discernment MOVE, not the virtue NOUNS. The virtueChips selector directly
above this field already names all four cardinal virtues with their glosses, so
that UI is the doctrine-carrying surface β and this file's own INVARIANT block
calls naming a virtue "optional scaffolding, never a gate" for this beat.
Repeating the names as bare labels is exactly what produced the audit-report
register the reader flagged; dropping them is not dropping doctrine. The
replacement still models all three parts of the field's label: where the virtues
were met, where they fell short, and how to meet it next time.
AC4's SWEEP IS PERFORMED, and its finding is no change. All three lenses
independently reached the same conclusion: the flat (:275 "Say the hard thing,
kindly, and keep it brief.") and morning (:347 "Stay measured in the afternoon
rush.") VirtuousResponse placeholders do NOT share the defect β both are already
natural single clauses with no virtue-tag enumeration. AC4 says sweep, not
rewrite, so rewriting working copy to satisfy a premise inspection disproved
would be solution-scope-matching. Recorded here so the sweep is auditable rather
than assumed skipped.
Deliberately out of scope: PREMEDITATIO (:178) and CLOSING.notePlaceholder (:524).
Different beat and different screen, both forward-intention register rather than
retrospective audit, and the premeditatio one is marked crisis-reviewed in this
file's own INVARIANTS β so touching it would need a crisis pass AC3 does not name.
No test or Maestro flow pins this literal string: tenseMode.test.ts asserts only
that placeholders are non-empty, and no flow references the copy. RED-GATED because
features/practices/dailyloop/ is a Protected Path, not because a crisis affordance
moved β VirtuousResponse never carries supportLine (showsSupportLine selects only
RadicalAcceptance and SphereSovereignty), so the two are mutually exclusive per step.
typecheck clean; tenseMode 54 passed; test:practices 243 passed / 16 suites.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
.../features/practices/dailyloop/config/tenseMode.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/app/src/features/practices/dailyloop/config/tenseMode.ts b/app/src/features/practices/dailyloop/config/tenseMode.ts
index b35c103d..9596a4ef 100644
--- a/app/src/features/practices/dailyloop/config/tenseMode.ts
+++ b/app/src/features/practices/dailyloop/config/tenseMode.ts
@@ -416,7 +416,17 @@ const EVENING: ModeConfig = {
key: 'response',
label: 'Where did you meet them, or fall short β and how would you meet it next time?',
hint: 'Honest review, with compassion β not self-flagellation.',
- placeholder: "E.g., 'Courage in the meeting; short on temperance at dinner.'",
+ // MAINT-566. The virtue NAMES need not appear here; the discernment MOVE
+ // must. The virtueChips selector directly above already names all four
+ // cardinal virtues with glosses, so that UI is the doctrine-carrying
+ // surface β and this file's own INVARIANT block calls naming a virtue
+ // "optional scaffolding, never a gate" for this beat. Repeating the names
+ // as bare labels is what produced the audit-report register a reader
+ // reported as "never said by any human"; dropping them is not dropping
+ // doctrine. The example still models all three parts of the label above:
+ // met, fell short, and the next-time correction.
+ placeholder:
+ "E.g., 'I spoke up in the meeting even though I was nervous. I snapped at dinner when I got tired β next time I'll eat something first.'",
},
],
},
From 8f75ed4887823083f0d991827da46d3de5eea370 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Sun, 30 Aug 2026 17:16:12 -0700
Subject: [PATCH 49/90] =?UTF-8?q?fix:=20DEBUG-533=20AC=201=20=E2=80=94=20r?=
=?UTF-8?q?ecord=20the=20on-device=20measurement=20behind=20the=20ruling?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ruling was derived from Sentry's source. AC 1 asked for a running build,
because the question is what iOS composites rather than what the API implies.
Measured on a Release build, iPhone SE 3rd gen / iOS 18.6, Profile entry:
crisis-button-root 0 occurrences in the hierarchy while the widget is up,
having asserted VISIBLE on Profile three steps earlier in
the same run. Present before, gone after, same selector.
app hierarchy no Profile markers survive - the tree is replaced.
dwell 75s untouched: still open, still absent.
keyboard up Cancel is not on screen AT ALL. Only a sliver of Send
report clears the keyboard. No visible exit.
The keyboard result is worse than the source reading implied, so the comment now
says so rather than only that Cancel is the ScrollView's last child.
Also records the two traps that nearly produced wrong readings here:
feedback-form-modal's testID does NOT propagate to the native modal host (absent
even with the widget open - assert on content instead), and Cancel IS listed in
the hierarchy while invisible, because XCUITest retains ScrollView-clipped
elements (DEBUG-465). Screenshot is authoritative for on-screen; the hierarchy
is authoritative for the crisis button's absence.
Comment-only; the inert filter drops it from Phase 2.5.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../services/logging/ExternalErrorReporter.ts | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/app/src/core/services/logging/ExternalErrorReporter.ts b/app/src/core/services/logging/ExternalErrorReporter.ts
index 12fa5450..90dfffc8 100644
--- a/app/src/core/services/logging/ExternalErrorReporter.ts
+++ b/app/src/core/services/logging/ExternalErrorReporter.ts
@@ -564,6 +564,28 @@ export class ExternalErrorReporter {
* `` on both platforms, so converting splits nothing; and iOS is the
* strictly worse platform here, having no hardware back.
*
+ * ββ MEASURED, NOT INFERRED (DEBUG-533 AC 1) ββ
+ *
+ * On a Release build, iPhone SE 3rd gen / iOS 18.6, opened from the Profile
+ * card. `crisis-button-root` appears ZERO times in the accessibility
+ * hierarchy while the widget is up, having asserted VISIBLE on Profile three
+ * steps earlier in the same run β present before, gone after, same selector.
+ * No Profile markers survive either: the whole app hierarchy is replaced.
+ * After 75s untouched, still open and still absent β dwell is unbounded.
+ *
+ * The keyboard finding is WORSE than the source reading above implies. With
+ * three lines typed, `Cancel` is not on screen AT ALL β the keyboard covers
+ * it, leaving only a sliver of `Send report`. There is no visible exit.
+ *
+ * Two traps for whoever verifies this next. (1) Do NOT assert on the
+ * `feedback-form-modal` testID: Sentry sets it on the `` and it does
+ * not propagate to the native modal host, so it is absent even when the
+ * widget is plainly open β assert on content ("Report a bug"). (2) Do NOT
+ * read `Cancel` out of a hierarchy dump and call it reachable: XCUITest
+ * retains ScrollView-clipped elements (DEBUG-465), so it is listed while
+ * invisible. The screenshot is the authority for on-screen; the hierarchy is
+ * the authority for the crisis button's ABSENCE.
+ *
* WHY IT IS NOT FIXED IN PLACE. The occluder is third-party code we do not
* render, so we cannot host it in `rootOverlaySlot`, cannot add a backdrop
* handler, cannot bound the dwell, and cannot inject a 988 control into
From 76521a95d0200b55fbe370026243a35de44bb17b Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 31 Aug 2026 01:10:00 -0700
Subject: [PATCH 50/90] fix: DEBUG-573 fail the install when a patch does not
apply
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Splits out the one durable finding from a falsified fix attempt.
patch-package does not fail locally: shouldExitWithError requires
--error-on-fail, CI, or NODE_ENV=test (patch-package/dist/index.js:91-94).
The Maestro safety gate is local-only by design (INFRA-171), so the one
machine that builds the artifact Phase 2.5 certifies was the one machine
where a failed patch warned and continued β and provenance still reads
CLEAN, because the fingerprint covers the repo and not node_modules. Every
gate in that chain reports success while shipping an unpatched binary.
This protects the existing expo-modules-jsi patch, whose silent
non-application is a Swift 6.2 hard-error build regression.
No recogniser patch is included: the candidate was measured and falsified
(teardown counts identical, 5 AUIOServer_Cleanup either way).
---
app/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/package.json b/app/package.json
index 002afdf6..2d89aa6e 100644
--- a/app/package.json
+++ b/app/package.json
@@ -85,7 +85,7 @@
"check:modal-occlusion": "node scripts/check-modal-occlusion-guard.js",
"check:crisis-keyboard-accessory": "node scripts/check-crisis-keyboard-accessory-guard.js",
"prepare": "[ ! -e ../.git ] && exit 0; cd .. && husky app/.husky",
- "postinstall": "patch-package && node scripts/generate-legal-content.js",
+ "postinstall": "patch-package --error-on-fail && node scripts/generate-legal-content.js",
"prepush": "npm run check:crisis-hotline && npm run check:workflow-scripts",
"version-check": "node -e \"const pkg=require('./package.json'); if(pkg.dependencies.react !== '19.2.3') throw new Error('React must be 19.2.3 for RN 0.85.x compatibility')\"",
"security:sast": "echo \"\ud83d\udd0d Running SAST Analysis...\" && audit-ci --config .audit-ci.json && echo \"\u2705 SAST Analysis completed\"",
From c83d90751494f116c6bdd6217fedf5c2f4ecdb4d Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:18:14 -0700
Subject: [PATCH 51/90] =?UTF-8?q?chore:=20DEBUG-573=20allowlist=20GHSA-vcc?=
=?UTF-8?q?3=20=E2=80=94=20the=20fix=20exists=20but=20breaks=20the=20consu?=
=?UTF-8?q?mer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
decode-uri-component DoS advisory, published 2026-08-31T22:10:20Z, red-gated
every PR in the repo on an unchanged tree.
Allowlisted for a reason this file has not recorded before: the fix EXISTS
and is forceable, but taking it breaks the consumer. firstPatched is 0.5.0,
which is ESM-only ("type":"module", no require condition in exports, ships a
single ESM index.js). Its only consumer, query-string@7.1.3, is CommonJS and
does require('decode-uri-component') at index.js:3 β an override resolves the
call to a namespace object, not a function. 0.4.1 is also ESM and still in
the <=0.4.2 range, so no CJS-compatible fixed version exists anywhere on the
line, and @react-navigation/core@7.21.13 still asks query-string ^7.1.3.
Reachability is stated honestly and differs from every other entry: there IS
an input path (deep links -> react-navigation linking -> query-string.parse).
Impact is a local DoS requiring the user to open a hostile link, no data
exposure. The risk is accepted because the alternative is worse β forcing the
ESM version would green this gate while breaking deep-link parsing, which
routes through the screen hosting the pre-consent 988 footer.
Lockfile untouched; allowlist-only. Verified locally: Passed npm security audit.
---
app/.audit-ci.json | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/app/.audit-ci.json b/app/.audit-ci.json
index 22217b97..41b91841 100644
--- a/app/.audit-ci.json
+++ b/app/.audit-ci.json
@@ -10,7 +10,8 @@
"GHSA-22p9-wv53-3rq4",
"GHSA-v245-v573-v5vm",
"GHSA-5p2g-fcmc-qvqq",
- "GHSA-w3rx-r6r6-pgpr"
+ "GHSA-w3rx-r6r6-pgpr",
+ "GHSA-vcc3-ghjq-m6fr"
],
- "$comment": "MAINT-182. First three CVEs are transitive through @expo/config-plugins@56.0.8 (build-time only, no patched stable available as of 2026-05-27). GHSA-ph9p (tmp path traversal) and GHSA-6vfc (markdown-it ReDoS) only run during prebuild/codegen. GHSA-w5hq (uuid buffer bounds) is in dev tooling. Review when Expo ships @expo/config-plugins >=56.0.9 stable: drop these GHSAs and verify `npm audit --audit-level moderate` passes. --- MAINT-281 (2026-06-15) added the last two (both quadratic-complexity DoS, no fix in range, require attacker-controlled input which Being has no path for): GHSA-h67p (js-yaml) is dev/build-time ONLY \u2014 transitive via ts-jest, @expo/cli/@expo/xcpretty, and babel-jest; fix is a breaking ts-jest major bump. GHSA-6v5v (markdown-it) is runtime via react-native-markdown-display@7, but its ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the DoS is unreachable; no patched markdown-it in react-native-markdown-display@7's range. Drop GHSA-6v5v if react-native-markdown-display upgrades markdown-it; drop GHSA-h67p on the next ts-jest major. The high-severity form-data CVE (GHSA-hmw2-7cc7-3qxx) disclosed the same day was FIXED via lockfile (npm audit fix), not allowlisted. --- MAINT-294 (2026-07-08) added GHSA-22p9-wv53-3rq4 (linkify-it 'LinkifyIt#match' scan loop, quadratic-complexity ReDoS, high; range <=5.0.0, fixAvailable:false). Direct twin of GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it@2.2.0, and its ONLY call site is LegalDocumentScreen rendering bundled first-party legal docs (legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the quadratic blow-up needs attacker-controlled input Being has no path to. No fix in range (would need breaking markdown-it/react-native-markdown-display majors, risking the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-22p9 together with GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- INFRA-302 (2026-07-21) three new HIGH DoS advisories published to the GitHub DB. Two were FIXED via same-major overrides (not allowlisted): GHSA-395f-4hp3-45gv (shell-quote quadratic parse() DoS) -> shell-quote ^1.10.0 (was 1.8.4, single copy); GHSA-3jxr-9vmj-r5cp (brace-expansion exponential expansion DoS) -> version-keyed overrides pinning the 1.x eslint/jest tooling copies to 1.1.16 and the 5.x copy to 5.0.7. The THIRD, GHSA-52cp-r559-cp3m (js-yaml quadratic merge-key DoS; range <=3.14.2 || 4.0.0-4.2.0, fixAvailable but not in-range for the 3.x path), is allowlisted here as a direct sibling of the already-allowlisted GHSA-h67p (js-yaml, MAINT-281): the top-level js-yaml@3.14.2 consumer has NO same-major fix (all 3.x is vulnerable) and the single GHSA covers both the 3.x and 4.x paths, so a clean override is impossible without a breaking 3->4 major bump of a dev/build-time tooling dep (ts-jest, @expo/cli/@expo/xcpretty, eslintrc, babel-jest). Dev/build-time only, operates on trusted first-party source, no attacker-controlled-input path -> unreachable DoS. Drop GHSA-52cp together with GHSA-h67p on the next ts-jest major (both js-yaml). Also allowlisted GHSA-v245-v573-v5vm (linkify-it, published same window) as a direct sibling of GHSA-22p9/GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it, whose ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so unreachable; no fix in range without breaking markdown-it/react-native-markdown-display majors (risks the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-v245 with GHSA-22p9/GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- FEAT-301 (2026-07-25) a NEW brace-expansion advisory GHSA-mh99-v99m-4gvg (unbounded-expansion OOM DoS; range <=5.0.7, HIGH) landed on the exact versions INFRA-302 pinned. The 5.x line (minimatch@10) is bumped to 5.0.8 via overrides (same-major fix). The 1.x line (minimatch@3 under eslint/jest tooling) has NO same-major fix \u2014 1.1.16 is the newest 1.x and is still <=5.0.7, and npm's only offered 'fix' is a breaking jest major downgrade \u2014 and the single GHSA covers both the 1.x and 5.x paths, so it is allowlisted here. brace-expansion is transitive via minimatch/glob (Node filesystem tooling), NEVER bundled into the RN runtime, and operates on trusted first-party glob patterns during dev/build/lint/test \u2014 no attacker-controlled-input path, so the DoS is unreachable. Drop GHSA-mh99 when the eslint/jest tooling upgrades minimatch@3 -> a brace-expansion line with a >5.0.7 backport (or the 1.x consumers are gone). --- INFRA-312 (2026-07-26) TRIAGE NOTE, no allowlist change. If this gate fails with the bare message `code undefined:` and nothing else, that is audit-ci failing to CLASSIFY an error from npm \u2014 it is NOT a CVE finding. A real finding always names its GHSA and prints the dependency path. Do not theorise from `code undefined:`; run the underlying command directly and read what npm actually says: `cd app && npm audit --audit-level moderate`. FIRST OCCURRENCE was a TRANSIENT npm REGISTRY INCIDENT, not a repo problem. For roughly an hour npm's legacy `/-/npm/v1/security/audits/quick` endpoint answered `400 Bad Request \u2014 Invalid package tree` for this ~1,185-package tree while happily auditing a 20-package control tree, and the newer `security/advisories/bulk` endpoint returned an undecompressed (gzip) body. It blocked every PR in the repo with zero repo changes, then recovered on its own: an untouched `development` worktree went from failing to `Passed npm security audit` with no edit of any kind. LESSON \u2014 the retry window matters. A `gh run rerun --failed` ~30 min in still failed, which made the outage look permanent and sent the investigation through eight hypotheses (all recorded on INFRA-312). Before changing ANYTHING here, re-run the audit against a clean worktree and wait longer than half an hour. Two specific red herrings to skip: npm's own 'run npm install to rebuild your package-lock.json' hint (the lockfile was byte-identical and structurally clean), and pinning a newer npm for the bulk endpoint (tried in the since-closed PR #222 \u2014 npm 11.18.0 was confirmed active in CI and failed identically). --- FEAT-313/close (2026-08-03) a FOURTH brace-expansion advisory, GHSA-rgw5-rvv9-x895 (\"DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation\", HIGH, range <=1.1.17 || 4.0.0 - 5.0.8), landed on BOTH pins simultaneously \u2014 the 1.x pin 1.1.16 (INFRA-302) and the 5.x pin 5.0.8 (FEAT-301). It blocked every PR in the repo. Unlike the previous round, a same-major fix exists on BOTH lines this time, so NOTHING was allowlisted: overrides+resolutions bumped to brace-expansion 1.1.18 and 5.0.9, which clear the range. Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 (a blanket `npm audit fix` would have bumped past it and broken `npm ci` \u2014 never run one here). REMOVED GHSA-mh99-v99m-4gvg from the allowlist in the same change: it was added by FEAT-301 solely because the 1.x line then had no same-major fix, and 1.1.18 discharges that exact documented drop-condition \u2014 audit-ci now reports it under \"Consider not allowlisting\". Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it too: it was already stale BEFORE this change and its drop-condition is the @expo/config-plugins bump, which is unrelated scope. NOTE THE PATTERN \u2014 this is the fourth brace-expansion advisory in ~2 weeks (GHSA-3jxr INFRA-302, GHSA-mh99 FEAT-301, GHSA-rgw5 here), each one re-hitting whatever version the previous round pinned. Expect a fifth; check `npm view brace-expansion versions` for a newer same-major patch BEFORE reaching for the allowlist. --- INFRA-350 (2026-08-06) a THIRD js-yaml advisory, GHSA-5p4m-2wfm-xmqj (\"Quadratic CPU consumption in !!omap resolution (3.x and 4.x) - CVE-2026-59870 fix not backported\", HIGH, range >=3.0.0 <3.15.1 || >=4.0.0 <4.3.1), landed on the current tree and blocked every PR in the repo with zero repo changes - found while closing INFRA-337, whose diff was .github/-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree. UNLIKE the two earlier js-yaml rounds this one was FIXED, NOT ALLOWLISTED: same-major patches now exist on BOTH lines (3.15.1 and 4.3.1), and every requested range is satisfied by them - @istanbuljs/load-nyc-config asks ^3.13.1, @expo/xcpretty ^4.1.0, @eslint/eslintrc ^4.1.1 - so version-keyed overrides+resolutions pin js-yaml@^3.13.1 -> 3.15.1 and js-yaml@^4.1.0 / ^4.1.1 -> 4.3.1. REMOVED GHSA-h67p-54hq-rp68 (MAINT-281) and GHSA-52cp-r559-cp3m (INFRA-302) from the allowlist in the same change: both were added solely because no same-major fix existed for the 3.x line, and 3.15.1/4.3.1 clear BOTH of their vulnerable ranges (h67p: <3.15.0 || 4.0.0-4.1.1; 52cp: <3.15.0 || 4.0.0-4.3.0), discharging their documented drop-conditions exactly as FEAT-313 retired GHSA-mh99 once brace-expansion 1.1.18 landed. Note their recorded drop-condition (\"on the next ts-jest major\") was already obsolete: ts-jest no longer appears anywhere in the js-yaml dependency tree. All three js-yaml paths are dev/build-time only (eslint config loading, @expo/xcpretty build-log formatting, babel-plugin-istanbul coverage config) and never bundled into the RN runtime, so the DoS was unreachable regardless - the fix is taken because it is available and clean, not because exposure changed. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change, and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 made. Verified npx patch-package still resolves expo-modules-jsi@56.0.7 after the install; a blanket `npm audit fix` would bump past that pin and break `npm ci` - never run one here. PATTERN: this is the third third-party advisory publication in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, this). Check `npm view versions` for a same-major patch FIRST; the allowlist is the fallback, not the reflex. --- INFRA-359 (2026-08-07) TWO HIGH image-size advisories plus one nanoid advisory became APPLICABLE to this tree and blocked every PR in the repo with zero repo changes - found while closing DEBUG-342 (PR #252), whose diff is UI-token-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 9af868c2. CORRECTION TO THE FIRST WRITE-UP OF THIS ENTRY: it originally said the advisories 'published in that window'. THEY DID NOT, and the distinction matters for how you watch for the next one. published_at is 2026-07-29 (GHSA-2v37, nanoid) and 2026-06-10 (GHSA-5p2g and GHSA-w3rx, image-size) - weeks and months earlier. What changed is updated_at: all three were updated 2026-08-07 between 20:50 and 20:55 UTC. The proof that nothing on our side moved is the scheduled CI run: the daily cron ran at 09:53 UTC on sha 9af868c2 and PASSED, and the gate failed at 22:52 UTC on THAT SAME SHA - identical tree, identical toolchain, 13 hours apart. (9af868c2 is post-INFRA-346, so the Node 20 -> 24 / npm 10 -> 11 move is also exonerated; it had already been green under the new toolchain for hours.) The advisory-side change is therefore established; its exact nature is not - the API exposes no diff, and a widened affected range, a re-review, or an npm-DB ingestion change would all look the same from here. PRACTICAL CONSEQUENCE: any watcher keyed on advisory PUBLICATION date would have missed all three of these. The only thing that reliably catches this class is resolving the ACTUAL tree against the advisory DB on a schedule - i.e. exactly what this gate does. See INFRA-362 for routing the scheduled run's failure somewhere a human sees it. SPLIT DECISION, per the standing rule that the allowlist is the fallback and not the reflex - the GitHub advisory API was queried directly for each one rather than trusting `npm audit`'s fixAvailable field. FIXED, NOT ALLOWLISTED: GHSA-2v37-7h3g-55p8 (nanoid, 'custom generators can loop indefinitely when size is zero', vulnerable <3.3.17, firstPatched 3.3.17). The tree held nanoid@3.3.12 and every requester is on the 3.x line - @react-navigation/core, /native and /routers ask ^3.3.11, postcss asks ^3.3.16 - so version-keyed overrides+resolutions pin both ranges to 3.3.18 (newest 3.x, clears the range). No 4.x/5.x nanoid exists in the tree, so the advisory's second range (>=4.0.0 <5.1.6) is not reachable here; the keys are version-scoped anyway so a future 5.x consumer will not be dragged backwards. ALLOWLISTED (both image-size, no alternative): GHSA-5p2g-fcmc-qvqq (JXL and HEIF parsers, DoS via infinite loop) and GHSA-w3rx-r6r6-pgpr (ICNS parser, DoS via infinite loop). The advisory API reports vulnerable '<= 2.0.2' with firstPatched NONE on BOTH - i.e. EVERY published version of image-size is vulnerable, including the 2.0.2 latest, so there is no same-major fix and no cross-major fix either. npm's proposed remedy is expo@53.0.27, a major DOWNGRADE from the SDK 56 this project pins, which is not a real option. image-size is a BUILD-TIME bundler dependency reached only through metro (metro asks ^1.0.2; ~10 paths via @expo/cli, @expo/metro-config, @react-native/metro-config, react-native, react-native-reanimated, react-native-screens, react-native-worklets) and is NEVER bundled into the RN runtime. Metro reads image dimensions at bundle time from the developer's own asset directory - first-party files in the repo - so the malformed-image infinite loop needs attacker-controlled input Being has no path to; worst case is a hung local or CI build, not a user-facing or data-integrity issue. Drop BOTH when metro ships a bump to an image-size line with a patched release (watch `npm view image-size versions` - as of this change 2.0.2 is latest and still vulnerable). Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 after the install, and `npx expo export --platform ios` still bundles - the nanoid override touches @react-navigation and postcss, and CI cannot catch a Metro break because ci.yml:222 records that CI does not run Metro. A blanket `npm audit fix` would bump past the expo-modules-jsi pin and break `npm ci` - never run one here. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 and INFRA-350 made. PATTERN: fourth third-party ADVISORY-SIDE EVENT in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, INFRA-350 js-yaml, this). Note they are not all the same mechanism - a registry outage, new publications, and now a metadata update to months-old advisories - which is why the detection has to be 'audit the real tree on a schedule' rather than 'watch for new CVEs'. Historical note: the daily cron ALREADY caught one of these ahead of time - the scheduled run on 2026-07-21 failed on Security + compliance with no PR in flight, which is INFRA-302 - but nothing notified anyone, so it was rediscovered the expensive way during a close."
+ "$comment": "MAINT-182. First three CVEs are transitive through @expo/config-plugins@56.0.8 (build-time only, no patched stable available as of 2026-05-27). GHSA-ph9p (tmp path traversal) and GHSA-6vfc (markdown-it ReDoS) only run during prebuild/codegen. GHSA-w5hq (uuid buffer bounds) is in dev tooling. Review when Expo ships @expo/config-plugins >=56.0.9 stable: drop these GHSAs and verify `npm audit --audit-level moderate` passes. --- MAINT-281 (2026-06-15) added the last two (both quadratic-complexity DoS, no fix in range, require attacker-controlled input which Being has no path for): GHSA-h67p (js-yaml) is dev/build-time ONLY β transitive via ts-jest, @expo/cli/@expo/xcpretty, and babel-jest; fix is a breaking ts-jest major bump. GHSA-6v5v (markdown-it) is runtime via react-native-markdown-display@7, but its ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) β no remote/user markdown, so the DoS is unreachable; no patched markdown-it in react-native-markdown-display@7's range. Drop GHSA-6v5v if react-native-markdown-display upgrades markdown-it; drop GHSA-h67p on the next ts-jest major. The high-severity form-data CVE (GHSA-hmw2-7cc7-3qxx) disclosed the same day was FIXED via lockfile (npm audit fix), not allowlisted. --- MAINT-294 (2026-07-08) added GHSA-22p9-wv53-3rq4 (linkify-it 'LinkifyIt#match' scan loop, quadratic-complexity ReDoS, high; range <=5.0.0, fixAvailable:false). Direct twin of GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it@2.2.0, and its ONLY call site is LegalDocumentScreen rendering bundled first-party legal docs (legalContent.generated.ts <- docs/legal/*.md) β no remote/user markdown, so the quadratic blow-up needs attacker-controlled input Being has no path to. No fix in range (would need breaking markdown-it/react-native-markdown-display majors, risking the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-22p9 together with GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- INFRA-302 (2026-07-21) three new HIGH DoS advisories published to the GitHub DB. Two were FIXED via same-major overrides (not allowlisted): GHSA-395f-4hp3-45gv (shell-quote quadratic parse() DoS) -> shell-quote ^1.10.0 (was 1.8.4, single copy); GHSA-3jxr-9vmj-r5cp (brace-expansion exponential expansion DoS) -> version-keyed overrides pinning the 1.x eslint/jest tooling copies to 1.1.16 and the 5.x copy to 5.0.7. The THIRD, GHSA-52cp-r559-cp3m (js-yaml quadratic merge-key DoS; range <=3.14.2 || 4.0.0-4.2.0, fixAvailable but not in-range for the 3.x path), is allowlisted here as a direct sibling of the already-allowlisted GHSA-h67p (js-yaml, MAINT-281): the top-level js-yaml@3.14.2 consumer has NO same-major fix (all 3.x is vulnerable) and the single GHSA covers both the 3.x and 4.x paths, so a clean override is impossible without a breaking 3->4 major bump of a dev/build-time tooling dep (ts-jest, @expo/cli/@expo/xcpretty, eslintrc, babel-jest). Dev/build-time only, operates on trusted first-party source, no attacker-controlled-input path -> unreachable DoS. Drop GHSA-52cp together with GHSA-h67p on the next ts-jest major (both js-yaml). Also allowlisted GHSA-v245-v573-v5vm (linkify-it, published same window) as a direct sibling of GHSA-22p9/GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it, whose ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) β no remote/user markdown, so unreachable; no fix in range without breaking markdown-it/react-native-markdown-display majors (risks the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-v245 with GHSA-22p9/GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- FEAT-301 (2026-07-25) a NEW brace-expansion advisory GHSA-mh99-v99m-4gvg (unbounded-expansion OOM DoS; range <=5.0.7, HIGH) landed on the exact versions INFRA-302 pinned. The 5.x line (minimatch@10) is bumped to 5.0.8 via overrides (same-major fix). The 1.x line (minimatch@3 under eslint/jest tooling) has NO same-major fix β 1.1.16 is the newest 1.x and is still <=5.0.7, and npm's only offered 'fix' is a breaking jest major downgrade β and the single GHSA covers both the 1.x and 5.x paths, so it is allowlisted here. brace-expansion is transitive via minimatch/glob (Node filesystem tooling), NEVER bundled into the RN runtime, and operates on trusted first-party glob patterns during dev/build/lint/test β no attacker-controlled-input path, so the DoS is unreachable. Drop GHSA-mh99 when the eslint/jest tooling upgrades minimatch@3 -> a brace-expansion line with a >5.0.7 backport (or the 1.x consumers are gone). --- INFRA-312 (2026-07-26) TRIAGE NOTE, no allowlist change. If this gate fails with the bare message `code undefined:` and nothing else, that is audit-ci failing to CLASSIFY an error from npm β it is NOT a CVE finding. A real finding always names its GHSA and prints the dependency path. Do not theorise from `code undefined:`; run the underlying command directly and read what npm actually says: `cd app && npm audit --audit-level moderate`. FIRST OCCURRENCE was a TRANSIENT npm REGISTRY INCIDENT, not a repo problem. For roughly an hour npm's legacy `/-/npm/v1/security/audits/quick` endpoint answered `400 Bad Request β Invalid package tree` for this ~1,185-package tree while happily auditing a 20-package control tree, and the newer `security/advisories/bulk` endpoint returned an undecompressed (gzip) body. It blocked every PR in the repo with zero repo changes, then recovered on its own: an untouched `development` worktree went from failing to `Passed npm security audit` with no edit of any kind. LESSON β the retry window matters. A `gh run rerun --failed` ~30 min in still failed, which made the outage look permanent and sent the investigation through eight hypotheses (all recorded on INFRA-312). Before changing ANYTHING here, re-run the audit against a clean worktree and wait longer than half an hour. Two specific red herrings to skip: npm's own 'run npm install to rebuild your package-lock.json' hint (the lockfile was byte-identical and structurally clean), and pinning a newer npm for the bulk endpoint (tried in the since-closed PR #222 β npm 11.18.0 was confirmed active in CI and failed identically). --- FEAT-313/close (2026-08-03) a FOURTH brace-expansion advisory, GHSA-rgw5-rvv9-x895 (\"DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation\", HIGH, range <=1.1.17 || 4.0.0 - 5.0.8), landed on BOTH pins simultaneously β the 1.x pin 1.1.16 (INFRA-302) and the 5.x pin 5.0.8 (FEAT-301). It blocked every PR in the repo. Unlike the previous round, a same-major fix exists on BOTH lines this time, so NOTHING was allowlisted: overrides+resolutions bumped to brace-expansion 1.1.18 and 5.0.9, which clear the range. Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 (a blanket `npm audit fix` would have bumped past it and broken `npm ci` β never run one here). REMOVED GHSA-mh99-v99m-4gvg from the allowlist in the same change: it was added by FEAT-301 solely because the 1.x line then had no same-major fix, and 1.1.18 discharges that exact documented drop-condition β audit-ci now reports it under \"Consider not allowlisting\". Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it too: it was already stale BEFORE this change and its drop-condition is the @expo/config-plugins bump, which is unrelated scope. NOTE THE PATTERN β this is the fourth brace-expansion advisory in ~2 weeks (GHSA-3jxr INFRA-302, GHSA-mh99 FEAT-301, GHSA-rgw5 here), each one re-hitting whatever version the previous round pinned. Expect a fifth; check `npm view brace-expansion versions` for a newer same-major patch BEFORE reaching for the allowlist. --- INFRA-350 (2026-08-06) a THIRD js-yaml advisory, GHSA-5p4m-2wfm-xmqj (\"Quadratic CPU consumption in !!omap resolution (3.x and 4.x) - CVE-2026-59870 fix not backported\", HIGH, range >=3.0.0 <3.15.1 || >=4.0.0 <4.3.1), landed on the current tree and blocked every PR in the repo with zero repo changes - found while closing INFRA-337, whose diff was .github/-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree. UNLIKE the two earlier js-yaml rounds this one was FIXED, NOT ALLOWLISTED: same-major patches now exist on BOTH lines (3.15.1 and 4.3.1), and every requested range is satisfied by them - @istanbuljs/load-nyc-config asks ^3.13.1, @expo/xcpretty ^4.1.0, @eslint/eslintrc ^4.1.1 - so version-keyed overrides+resolutions pin js-yaml@^3.13.1 -> 3.15.1 and js-yaml@^4.1.0 / ^4.1.1 -> 4.3.1. REMOVED GHSA-h67p-54hq-rp68 (MAINT-281) and GHSA-52cp-r559-cp3m (INFRA-302) from the allowlist in the same change: both were added solely because no same-major fix existed for the 3.x line, and 3.15.1/4.3.1 clear BOTH of their vulnerable ranges (h67p: <3.15.0 || 4.0.0-4.1.1; 52cp: <3.15.0 || 4.0.0-4.3.0), discharging their documented drop-conditions exactly as FEAT-313 retired GHSA-mh99 once brace-expansion 1.1.18 landed. Note their recorded drop-condition (\"on the next ts-jest major\") was already obsolete: ts-jest no longer appears anywhere in the js-yaml dependency tree. All three js-yaml paths are dev/build-time only (eslint config loading, @expo/xcpretty build-log formatting, babel-plugin-istanbul coverage config) and never bundled into the RN runtime, so the DoS was unreachable regardless - the fix is taken because it is available and clean, not because exposure changed. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change, and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 made. Verified npx patch-package still resolves expo-modules-jsi@56.0.7 after the install; a blanket `npm audit fix` would bump past that pin and break `npm ci` - never run one here. PATTERN: this is the third third-party advisory publication in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, this). Check `npm view versions` for a same-major patch FIRST; the allowlist is the fallback, not the reflex. --- INFRA-359 (2026-08-07) TWO HIGH image-size advisories plus one nanoid advisory became APPLICABLE to this tree and blocked every PR in the repo with zero repo changes - found while closing DEBUG-342 (PR #252), whose diff is UI-token-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 9af868c2. CORRECTION TO THE FIRST WRITE-UP OF THIS ENTRY: it originally said the advisories 'published in that window'. THEY DID NOT, and the distinction matters for how you watch for the next one. published_at is 2026-07-29 (GHSA-2v37, nanoid) and 2026-06-10 (GHSA-5p2g and GHSA-w3rx, image-size) - weeks and months earlier. What changed is updated_at: all three were updated 2026-08-07 between 20:50 and 20:55 UTC. The proof that nothing on our side moved is the scheduled CI run: the daily cron ran at 09:53 UTC on sha 9af868c2 and PASSED, and the gate failed at 22:52 UTC on THAT SAME SHA - identical tree, identical toolchain, 13 hours apart. (9af868c2 is post-INFRA-346, so the Node 20 -> 24 / npm 10 -> 11 move is also exonerated; it had already been green under the new toolchain for hours.) The advisory-side change is therefore established; its exact nature is not - the API exposes no diff, and a widened affected range, a re-review, or an npm-DB ingestion change would all look the same from here. PRACTICAL CONSEQUENCE: any watcher keyed on advisory PUBLICATION date would have missed all three of these. The only thing that reliably catches this class is resolving the ACTUAL tree against the advisory DB on a schedule - i.e. exactly what this gate does. See INFRA-362 for routing the scheduled run's failure somewhere a human sees it. SPLIT DECISION, per the standing rule that the allowlist is the fallback and not the reflex - the GitHub advisory API was queried directly for each one rather than trusting `npm audit`'s fixAvailable field. FIXED, NOT ALLOWLISTED: GHSA-2v37-7h3g-55p8 (nanoid, 'custom generators can loop indefinitely when size is zero', vulnerable <3.3.17, firstPatched 3.3.17). The tree held nanoid@3.3.12 and every requester is on the 3.x line - @react-navigation/core, /native and /routers ask ^3.3.11, postcss asks ^3.3.16 - so version-keyed overrides+resolutions pin both ranges to 3.3.18 (newest 3.x, clears the range). No 4.x/5.x nanoid exists in the tree, so the advisory's second range (>=4.0.0 <5.1.6) is not reachable here; the keys are version-scoped anyway so a future 5.x consumer will not be dragged backwards. ALLOWLISTED (both image-size, no alternative): GHSA-5p2g-fcmc-qvqq (JXL and HEIF parsers, DoS via infinite loop) and GHSA-w3rx-r6r6-pgpr (ICNS parser, DoS via infinite loop). The advisory API reports vulnerable '<= 2.0.2' with firstPatched NONE on BOTH - i.e. EVERY published version of image-size is vulnerable, including the 2.0.2 latest, so there is no same-major fix and no cross-major fix either. npm's proposed remedy is expo@53.0.27, a major DOWNGRADE from the SDK 56 this project pins, which is not a real option. image-size is a BUILD-TIME bundler dependency reached only through metro (metro asks ^1.0.2; ~10 paths via @expo/cli, @expo/metro-config, @react-native/metro-config, react-native, react-native-reanimated, react-native-screens, react-native-worklets) and is NEVER bundled into the RN runtime. Metro reads image dimensions at bundle time from the developer's own asset directory - first-party files in the repo - so the malformed-image infinite loop needs attacker-controlled input Being has no path to; worst case is a hung local or CI build, not a user-facing or data-integrity issue. Drop BOTH when metro ships a bump to an image-size line with a patched release (watch `npm view image-size versions` - as of this change 2.0.2 is latest and still vulnerable). Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 after the install, and `npx expo export --platform ios` still bundles - the nanoid override touches @react-navigation and postcss, and CI cannot catch a Metro break because ci.yml:222 records that CI does not run Metro. A blanket `npm audit fix` would bump past the expo-modules-jsi pin and break `npm ci` - never run one here. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 and INFRA-350 made. PATTERN: fourth third-party ADVISORY-SIDE EVENT in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, INFRA-350 js-yaml, this). Note they are not all the same mechanism - a registry outage, new publications, and now a metadata update to months-old advisories - which is why the detection has to be 'audit the real tree on a schedule' rather than 'watch for new CVEs'. Historical note: the daily cron ALREADY caught one of these ahead of time - the scheduled run on 2026-07-21 failed on Security + compliance with no PR in flight, which is INFRA-302 - but nothing notified anyone, so it was rediscovered the expensive way during a close. --- DEBUG-573 (2026-08-31) GHSA-vcc3-ghjq-m6fr (decode-uri-component, \"Denial of service via exponential decoding of malformed percent-encoded input\", MODERATE; range <=0.4.2, firstPatched 0.5.0) published 2026-08-31T22:10:20Z and blocked every PR in the repo with zero repo changes - found on a branch whose only diff was one word in a postinstall script, and reproduced against the untouched dependency tree. ALLOWLISTED, and for a NEW reason this file has not recorded before: the fix EXISTS and is forceable via overrides, but taking it BREAKS THE CONSUMER. decode-uri-component@0.5.0 is ESM-ONLY - package.json declares \"type\":\"module\", its exports map has NO require condition, and the tarball ships a single ESM index.js with `export default`. Its only consumer here, query-string@7.1.3, is CommonJS and does `const decodeComponent = require('decode-uri-component')` at index.js:3, so an override resolves the call to a module namespace object rather than a function. 0.4.1 is ALSO \"type\":\"module\" and still inside the <=0.4.2 range, so there is NO CJS-compatible fixed version at any point on the line. Upgrading the parent does not help either: @react-navigation/core@7.21.13 (latest at this change) still depends on query-string ^7.1.3. REACHABILITY IS STATED HONESTLY AND DIFFERS FROM EVERY OTHER ENTRY ABOVE - do not read this as another 'no attacker-controlled-input path' case. There IS an input path: query-string parses deep links via @react-navigation linking, and Being handles deep links (app/.maestro/deeplink-consent-gate.yaml, daily-loop-deeplink.yaml), so a crafted link's query string reaches the decoder. The accepted risk is bounded rather than absent: impact is a local DoS (the app hanging on the user's own device, recoverable by force-quit), it requires the user to open a hostile link, and there is no data exposure. That risk was accepted specifically BECAUSE the alternative is worse - forcing the ESM version would have made this gate green while breaking deep-link parsing, which routes through CombinedLegalGateScreen, the screen hosting the pre-consent 988 footer (INFRA-416). A green security gate over a broken crisis path is not a trade this repo takes. Drop GHSA-vcc3 when ANY of: query-string ships a CJS-compatible line off decode-uri-component; @react-navigation moves to query-string >=8; or decode-uri-component backports the fix to a 0.2.x/0.3.x CJS release (watch `npm view decode-uri-component versions` - as of this change 0.5.0 is the only patched version and it is ESM). NOT verified and deliberately not attempted here: Metro's ESM/CJS interop may or may not paper over the require(); proving that needs a Release build plus a deep-link flow run, and an unproven interop assumption on the deep-link path is not a security fix. PATTERN: fifth advisory-side event in ~6 weeks to red-gate every PR on an unchanged tree, and the first where `npm view versions` shows a patch that must NOT be taken - check the shipped tarball's module format before reaching for an override, not just the version number."
}
From 1d9f289740e77d65d2e1d99006fc96b0b0d348b0 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:18:28 -0700
Subject: [PATCH 52/90] chore: INFRA-532 give WeeklyReflectionComposer real
gate coverage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WeeklyReflectionComposer is a Protected Path and a DEBUG-406 conversion
site, but its Phase 2.5 entry could only ever be a printed notice. The
recorded reason (its only flow is safety-device-only) understated the
problem: WeeklyReflectionCard returns null below MIN_CHECK_INS_TO_SHOW=4
and every sim flow launches with clearState+clearKeychain, so the card β
and the composer β were absent from the gate build entirely. The item's
premise that this composer is "the reachable one" was false; it is
data-gated rather than flag-gated.
- e2eSeed.ts: seed four check-ins through the real markCheckInComplete
API (no new seam β INFRA-377's precedent requires that no real mutator
can produce the state, which does not hold here). Placed after all
three marker early-returns so the ungranted/stale/ineligible boots keep
byte-identical state. 'daily' is excluded deliberately: it is the only
type a production surface reads back (CleanHomeScreen ->
isCheckInCompletedToday), and seeding it would flip the Home card for
every flow in the suite, including daily-loop-ax5-entry.
- crisis-button-reachability.yaml: tap-through segment in the Insights
block. Two arms β occlusion (tap the FAB from the open sheet and land
on CrisisResources) and mis-tap (the inverse hazard: at zIndex 9999 the
FAB wins an overlapping tap, firing an audit-logged crisis nav AND
swallowing the user's action). Save is probed as the rightmost control.
Centre taps only, so this is necessary, not sufficient β the marginal
geometry case stays with modalOcclusionConversions.test.tsx.
- e2eSeedGate.config.test.ts: pin the two properties that make the seed
safe and are invisible in a flow diff β no 'daily', and placement after
the markers. Comment-stripped (DEBUG-390: 'daily' appears in the prose
explaining its exclusion) with matchers proven against known-bad
literals. Both mechanisms verified red by mutation.
Crisis planning pass: agents/crisis.md
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/.maestro/crisis-button-reachability.yaml | 81 +++++++++++++++++
.../safety/e2eSeedGate.config.test.ts | 89 +++++++++++++++++++
app/src/core/config/e2eSeed.ts | 60 +++++++++++++
3 files changed, 230 insertions(+)
diff --git a/app/.maestro/crisis-button-reachability.yaml b/app/.maestro/crisis-button-reachability.yaml
index c8ec1dc5..dc9b6f52 100644
--- a/app/.maestro/crisis-button-reachability.yaml
+++ b/app/.maestro/crisis-button-reachability.yaml
@@ -211,6 +211,87 @@ name: "Crisis button (single root mount) reaches CrisisResources from every surf
- tapOn:
id: "nav-back-button"
+# ββ INFRA-532: the weekly-reflection composer must not occlude the button βββββββ
+# WeeklyReflectionComposer is a DEBUG-406 conversion site (it was an RN ,
+# which renders in a separate native window ABOVE the JS hierarchy β a
+# zero-988-affordance state while open). It publishes into the root overlay slot,
+# which paints immediately BELOW the crisis button.
+#
+# REACHABLE ONLY BECAUSE OF THE SEED. WeeklyReflectionCard returns null below
+# MIN_CHECK_INS_TO_SHOW = 4, and clearState+clearKeychain zeroes the store, so
+# before INFRA-532 this card did not exist in the gate build at all. e2eSeed.ts
+# seeds four non-'daily' check-ins. If that seed is removed or the constant is
+# raised, this segment reds β which is the intended coupling, not a fragility.
+#
+# THE KEYBOARD STAYS DOWN. autoFocus was removed from the composer deliberately
+# (DEBUG-406: it stole VoiceOver focus from the title and raised the keyboard into
+# UIRemoteKeyboardWindow, above the app window where zIndex is meaningless). Do
+# not tap the input here β the keyboard-up contract is device-only and is NOT
+# covered by this flow.
+- tapOn:
+ id: "tab-insights"
+- scrollUntilVisible:
+ element:
+ id: "weekly-reflection-card"
+ direction: DOWN
+ centerElement: true # DEBUG-465: the card is inside InsightsScreen's ScrollView
+ # while the crisis FAB is pinned outside it, so a card left
+ # at the bottom edge shares coordinates with the FAB.
+- tapOn:
+ id: "weekly-reflection-prompt"
+- assertVisible:
+ id: "weekly-reflection-overlay"
+
+# OCCLUSION ARM β tap-through, not assertVisible. Under a native-layer occlusion
+# bug the hierarchy dump can still LIST a view another native window is covering,
+# so assertVisible alone would have passed against the original defect.
+# Only tap-and-land is evidence.
+- assertVisible:
+ id: "crisis-button-root"
+- tapOn:
+ id: "crisis-button-root"
+- assertVisible:
+ id: "crisis-resources-screen"
+- extendedWaitUntil:
+ notVisible: "Unable to Call"
+ timeout: 3000
+- tapOn:
+ id: "nav-back-button"
+# Post-condition, not a tap (DEBUG-403 shape: a tap that reported COMPLETED while
+# the app never received it). The sheet survives the excursion.
+- assertVisible:
+ id: "weekly-reflection-overlay"
+
+# MIS-TAP ARM β the INVERSE hazard, and the reason this segment is not just a
+# copy of the ThresholdEducationModal block. At zIndex 9999 the crisis FAB WINS an
+# overlapping tap, so an action-row geometry regression fires an audit-logged
+# crisis navigation the user did not ask for AND swallows the action they did.
+# Save is the RIGHTMOST control (the row is justifyContent: 'flex-end'), so it is
+# nearest the contested column and is what a regression reaches first; it is
+# disabled at empty text, making this a side-effect-free probe.
+#
+# NECESSARY, NOT SUFFICIENT: Maestro taps element CENTRES, which never enter the
+# contested column, so this catches a GROSS regression (padding dropped, row
+# re-justified, FAB moved or grown) and cannot catch a marginal one. The marginal
+# case is pinned CI-side by __tests__/safety/modalOcclusionConversions.test.tsx,
+# which asserts the row's paddingRight === OVERLAY_ACTION_ROW_PADDING_RIGHT.
+- tapOn:
+ id: "weekly-reflection-save"
+- tapOn:
+ id: "weekly-reflection-cancel"
+# Settle on the POSITIVE post-condition first: a stolen tap leaves the sheet up
+# and reds here, rather than racing a bare assertNotVisible against a transition.
+- extendedWaitUntil:
+ notVisible:
+ id: "weekly-reflection-overlay"
+ timeout: 5000
+# All three are required. The first two alone would pass a state where the taps
+# did nothing at all.
+- assertNotVisible:
+ id: "crisis-resources-screen" # neither tap reached the FAB
+- assertVisible:
+ id: "weekly-reflection-prompt" # the user's own action landed, not swallowed
+
# ββ Profile tab ββββββββββββββββββββββββββββββββββββββββββββββββββ
# FEAT-212: Profile migrated to a nested React Navigation stack
# (ProfileStackNavigator). MAINT-290 removed the per-navigator crisis overlay
diff --git a/app/__tests__/safety/e2eSeedGate.config.test.ts b/app/__tests__/safety/e2eSeedGate.config.test.ts
index 467f0218..b418feba 100644
--- a/app/__tests__/safety/e2eSeedGate.config.test.ts
+++ b/app/__tests__/safety/e2eSeedGate.config.test.ts
@@ -255,3 +255,92 @@ describe('INFRA-377 stale-consent forge is reachable only through the store seam
expect(seedSource.length).toBeGreaterThan(1000);
});
});
+
+/**
+ * INFRA-532 β the check-in seed that makes WeeklyReflectionComposer reachable.
+ *
+ * `WeeklyReflectionCard` renders null below MIN_CHECK_INS_TO_SHOW = 4, so the
+ * composer β a DEBUG-406 conversion site and a Protected Path β was absent from
+ * the gate build entirely and could only be given a printed notice. The seed
+ * writes four check-ins so `crisis-button-reachability` can tap through it.
+ *
+ * These pins protect the two properties that make that seed safe, neither of
+ * which is visible in a diff of the flow it enables.
+ *
+ * NOTE ON COMMENT-STRIPPING (DEBUG-390): this file's existing pins match import
+ * syntax and storage-key literals, which the codebase never names in prose. That
+ * is NOT true here β `'daily'` appears in `e2eSeed.ts`'s own comment explaining
+ * why it is excluded, so a bare `not.toContain("'daily'")` would match the
+ * warning and fail correct code. Strip comments first and match call-shaped
+ * patterns, then prove the matchers still fire.
+ */
+describe('INFRA-532 check-in seed stays inert to every other surface', () => {
+ /** `e2eSeed.ts` with block and line comments removed. */
+ const strippedSeedSource = seedSource
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+
+ it("never seeds a 'daily' check-in", () => {
+ // 'daily' is the ONLY check-in type a production surface reads back
+ // (CleanHomeScreen -> isCheckInCompletedToday('daily')). Seeding it would
+ // flip the Home check-in card's completed state for every flow in the suite,
+ // including daily-loop-ax5-entry, which taps that card. The four types that
+ // ARE seeded have no reader outside getCheckInHistory.
+ expect(strippedSeedSource).not.toMatch(/['"]daily['"]/);
+ });
+
+ it('seeds exactly the four types the card needs, through the real store API', () => {
+ const listMatch = strippedSeedSource.match(
+ /E2E_SEEDED_CHECK_IN_TYPES:\s*readonly CheckInType\[\]\s*=\s*\[([\s\S]*?)\]/,
+ );
+ expect(listMatch).not.toBeNull();
+
+ const seeded = (listMatch as RegExpMatchArray)[1]
+ .split(',')
+ .map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
+ .filter(Boolean);
+ expect(seeded).toEqual(['morning', 'midday', 'evening', 'learn']);
+
+ // Through the real mutator, not a seam. INFRA-377's seam precedent requires
+ // that no real API can produce the state; markCheckInComplete can, so a new
+ // seam would be unjustified power.
+ expect(strippedSeedSource).toMatch(/markCheckInComplete\(/);
+ expect(strippedSeedSource).not.toMatch(/__seed[A-Za-z]*CheckIn[A-Za-z]*ForE2E/);
+ // And never by reaching around the store to its storage key.
+ expect(seedSource).not.toContain('stoic_practice_state');
+ });
+
+ it('writes the check-ins AFTER all three marker early-returns', () => {
+ // The ungranted / stale / ineligible boot states must keep byte-identical
+ // state, or deeplink-consent-gate, reconsent-stale and
+ // reconsent-stale-ineligible silently start booting into a seeded store.
+ const lastMarkerIdx = strippedSeedSource.indexOf('isStaleIneligibleBootRequested(launchUrl)');
+ const checkInIdx = strippedSeedSource.indexOf('markCheckInComplete(');
+ expect(lastMarkerIdx).toBeGreaterThan(-1);
+ expect(checkInIdx).toBeGreaterThan(lastMarkerIdx);
+
+ // And after grantConsent, so the seeded store is written into a fully
+ // consented state rather than ahead of it.
+ expect(checkInIdx).toBeGreaterThan(strippedSeedSource.indexOf('grantConsent(preferences'));
+ });
+
+ it('the assertions above can still fail (DEBUG-390 control)', () => {
+ // Each matcher, proven against a literal known-bad string. Without this,
+ // comment-stripping plus a narrow regex is exactly the combination that can
+ // silently match nothing and read as a pass.
+ expect("await practice.markCheckInComplete('daily');").toMatch(/['"]daily['"]/);
+ expect('await practice.markCheckInComplete(type);').toMatch(/markCheckInComplete\(/);
+ expect('await __seedCheckInHistoryForE2E({});').toMatch(
+ /__seed[A-Za-z]*CheckIn[A-Za-z]*ForE2E/,
+ );
+ expect("const K = 'stoic_practice_state';").toContain('stoic_practice_state');
+ expect(
+ "const E2E_SEEDED_CHECK_IN_TYPES: readonly CheckInType[] = ['morning'];",
+ ).toMatch(/E2E_SEEDED_CHECK_IN_TYPES:\s*readonly CheckInType\[\]\s*=\s*\[([\s\S]*?)\]/);
+
+ // The stripped source must still be substantial β a stripper that ate the
+ // file would make every `not.toMatch` above vacuously true.
+ expect(strippedSeedSource.length).toBeGreaterThan(1000);
+ expect(strippedSeedSource).toContain('maybeSeedE2EOnboardedState');
+ });
+});
diff --git a/app/src/core/config/e2eSeed.ts b/app/src/core/config/e2eSeed.ts
index 4b2aca84..c5b41114 100644
--- a/app/src/core/config/e2eSeed.ts
+++ b/app/src/core/config/e2eSeed.ts
@@ -50,6 +50,10 @@ import {
type AgeVerification,
} from '../stores/consentStore';
import { logSystem, logError, LogCategory } from '../services/logging';
+import {
+ useStoicPracticeStore,
+ type CheckInType,
+} from '@/features/practices/stores/stoicPracticeStore';
/**
* Deterministic eligible birth year for the seeded age verification. Any year
@@ -58,6 +62,24 @@ import { logSystem, logError, LogCategory } from '../services/logging';
*/
const SEED_BIRTH_YEAR = 1990;
+/**
+ * The check-in types seeded to satisfy `WeeklyReflectionCard`'s
+ * MIN_CHECK_INS_TO_SHOW = 4 gate (INFRA-532).
+ *
+ * EXACTLY FOUR, AND DELIBERATELY WITHOUT 'daily'. `markCheckInComplete` de-dupes
+ * on (type, date), so reaching four in one launch requires four distinct types.
+ * 'daily' is the one type a production surface reads back
+ * (`CleanHomeScreen` β `isCheckInCompletedToday('daily')`); the four here have no
+ * reader outside `getCheckInHistory`, so the seed is inert to every other flow.
+ * Pinned by `__tests__/safety/e2eSeedGate.config.test.ts`.
+ */
+const E2E_SEEDED_CHECK_IN_TYPES: readonly CheckInType[] = [
+ 'morning',
+ 'midday',
+ 'evening',
+ 'learn',
+];
+
/** Whether the e2e-sim onboarding seed is enabled for this build. */
export const isE2EOnboardingSeedEnabled = (): boolean =>
env.EXPO_PUBLIC_E2E_SEED_ONBOARDED === 'true';
@@ -388,6 +410,44 @@ export async function maybeSeedE2EOnboardedState(): Promise {
};
await grantConsent(preferences, ageVerification);
+ // 4. Weekly-reflection precondition (INFRA-532).
+ //
+ // WHY THIS EXISTS. `WeeklyReflectionCard` returns null below
+ // MIN_CHECK_INS_TO_SHOW = 4 check-ins in the trailing 7 days, so on a
+ // `clearState` + `clearKeychain` launch the card β and therefore
+ // `WeeklyReflectionComposer`, a DEBUG-406 conversion site and a Protected
+ // Path β is absent from the hierarchy entirely. Without this the composer
+ // is unreachable in the gate build and its Phase 2.5 entry can only ever
+ // be a printed notice. `crisis-button-reachability` taps through it.
+ //
+ // WHY THE REAL API AND NOT A SEAM. INFRA-377's `__seedStaleβ¦ForE2E` seam
+ // exists because no real mutator can stamp an old consent version. That
+ // precondition is absent here: `markCheckInComplete` writes a complete,
+ // well-formed record through the normal path. Nothing is forged, so no
+ // new seam is justified.
+ //
+ // WHY NOT 'daily'. It de-dupes on (type, date), so four records means four
+ // distinct types. 'daily' is excluded deliberately β it is the ONLY type
+ // any consumer reads outside this card (`CleanHomeScreen` β
+ // `isCheckInCompletedToday('daily')`), and seeding it would flip the Home
+ // check-in card's completed state for every flow in the suite, including
+ // `daily-loop-ax5-entry`, which taps that card. The four seeded here have
+ // no production writer and no reader but `getCheckInHistory`.
+ //
+ // PLACEMENT IS LOAD-BEARING. This sits after `grantConsent`, below all
+ // three marker early-returns, so the ungranted / stale / ineligible boot
+ // states keep byte-identical state and their flows are unaffected.
+ //
+ // COMPLIANCE: these are fabricated wellness records written to the
+ // encrypted store. They exist only under SEED_ACTIVE, which is scoped to
+ // the non-shippable `e2e-sim` EAS profile, so no boundary moves β but the
+ // gate build does contain check-in records no user created.
+ const practice = useStoicPracticeStore.getState();
+ await practice.loadPersistedState();
+ for (const type of E2E_SEEDED_CHECK_IN_TYPES) {
+ await practice.markCheckInComplete(type);
+ }
+
logSystem('[E2ESeed] Post-onboarding state seeded; navigator will route to Main');
} catch (error) {
logError(
From 57c247f249be07a06fd22cb56fcd32fa3c315112 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 31 Aug 2026 18:57:46 -0700
Subject: [PATCH 53/90] fix: DEBUG-575 move root-slot focus trap off the
overlay onto the navigator
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
INFRA-532's new gate segment failed on its first run: crisis-button-root
was absent from the accessibility tree with WeeklyReflectionComposer open,
while the button was plainly painted on screen. Differential maestro
hierarchy dumps on the gate sim, same screen, nothing else changed:
composer open -> 0 occurrences, composer dismissed -> 1.
RootOverlaySlot renders a bare fragment, so a published overlay is a
DIRECT NATIVE SIBLING of RootCrisisButton and CrisisKeyboardAccessory.
accessibilityViewIsModal prunes the receiver's SIBLINGS, so the composer's
focus trap deleted both root crisis affordances from the tree β a zero-988
state for VoiceOver, Switch Control, Voice Control and Full Keyboard
Access. ThresholdEducationModal sets the same prop and is unaffected: it
mounts inline in ProfileScreen, where the crisis button is an ancestor's
sibling and out of prune scope. Same prop, same run, opposite outcome,
explained entirely by mount depth.
This is DEBUG-406's own defect displaced one layer down. Moving these
overlays into the slot fixed occlusion in the paint layer and made them
siblings for the first time; nobody re-derived what the hand-rolled trap
would then do. Visual occlusion a screenshot catches became tree-level
occlusion it cannot.
Fix: NavigatorA11yHost wraps ONLY Stack.Navigator and hides it while the
slot is occupied. RootOverlaySlot, RootCrisisBoundary and
CrisisKeyboardAccessory stay outside β putting any inside would hide 988
along with the navigator. Both slot composers drop the prop; with the host
in place it is not redundant but actively harmful. Same shape as
DailyLoopNavigator's resume-modal host.
Rejected: scoping the flag to the sheet (removes the prune radius, leaving
no trap); re-exposing the button via a11y props (no opt-out exists for a
modal's sibling); moving the button into the modal subtree (inverts
MAINT-290's single-root-mount invariant).
Also closes a latent Android gap: accessibilityViewIsModal is iOS-only, so
TalkBack could previously wander the Insights content behind an open sheet.
Pins: modalOcclusionConversions splits by mount site β the trap test
asserted toBe(true) for all three and so PINNED THE DEFECT. New
rootOverlayFocusTrap.test.tsx covers the positive half. Both mutation-
verified: moving RootCrisisBoundary inside the host reds the structural
pin; dropping the host props reds the behavioural ones. Source assertions
are comment-stripped with a DEBUG-390 control, since these files now carry
prose naming the anti-pattern.
Still unproven here and covered by the pending gate re-run: that the host
actually prunes the Stack.Navigator subtree on a real device.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/.eslint-baseline.json | 3 +-
.../safety/modalOcclusionConversions.test.tsx | 30 +++-
.../core/navigation/CleanRootNavigator.tsx | 31 +++-
app/src/core/navigation/NavigatorA11yHost.tsx | 72 ++++++++
.../__tests__/rootOverlayFocusTrap.test.tsx | 170 ++++++++++++++++++
app/src/core/navigation/rootOverlaySlot.tsx | 52 +++++-
.../components/SessionNoteComposer.tsx | 10 +-
.../components/WeeklyReflectionComposer.tsx | 13 +-
8 files changed, 367 insertions(+), 14 deletions(-)
create mode 100644 app/src/core/navigation/NavigatorA11yHost.tsx
create mode 100644 app/src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index 86af1e7b..189bb5a3 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -229,5 +229,6 @@
"src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx": 1,
"src/core/analytics/__tests__/AppLifecycleTracker.test.tsx": 1,
"src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx": 1,
- "src/core/analytics/__tests__/appLifecycleTelemetry.test.ts": 1
+ "src/core/analytics/__tests__/appLifecycleTelemetry.test.ts": 1,
+ "src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx": 1
}
diff --git a/app/__tests__/safety/modalOcclusionConversions.test.tsx b/app/__tests__/safety/modalOcclusionConversions.test.tsx
index c8941724..a09ce28d 100644
--- a/app/__tests__/safety/modalOcclusionConversions.test.tsx
+++ b/app/__tests__/safety/modalOcclusionConversions.test.tsx
@@ -42,6 +42,7 @@ const noop = (): void => undefined;
const CASES = [
{
name: 'ThresholdEducationModal',
+ mount: 'inline' as const,
overlayTestId: 'threshold-education-overlay',
element: (visible: boolean) => (
@@ -49,6 +50,7 @@ const CASES = [
},
{
name: 'SessionNoteComposer',
+ mount: 'root-slot' as const,
overlayTestId: 'session-note-overlay',
element: (visible: boolean) => (
(
{
+describe.each(CASES)('DEBUG-406 Β· $name occlusion guards', ({ overlayTestId, element, mount }) => {
it('renders no RN β the occlusion shape must not return', () => {
const { UNSAFE_queryAllByType } = render(element(true));
expect(UNSAFE_queryAllByType(Modal)).toHaveLength(0);
@@ -87,9 +90,30 @@ describe.each(CASES)('DEBUG-406 Β· $name occlusion guards', ({ overlayTestId, el
expect(queryByTestId(overlayTestId)).toBeNull();
});
- it('traps iOS accessibility focus via accessibilityViewIsModal', () => {
+ // DEBUG-575 β SPLIT BY MOUNT SITE. This used to assert `toBe(true)` for all
+ // three, which PINNED A DEFECT: `accessibilityViewIsModal` prunes the
+ // RECEIVER'S SIBLINGS, and the two root-slot overlays are direct native
+ // siblings of RootCrisisButton and CrisisKeyboardAccessory (RootOverlaySlot
+ // renders a bare fragment). So on those two the prop deleted both crisis
+ // affordances from the accessibility tree β measured on device as zero
+ // `crisis-button-root` nodes with the sheet open, the button still painted.
+ // ThresholdEducationModal mounts INLINE in ProfileScreen, where the crisis
+ // button is an ancestor's sibling and out of prune scope, so it keeps the prop.
+ //
+ // Asserted on the RENDERED TREE, never on source text: both composers now
+ // carry prose naming this anti-pattern, which is exactly the DEBUG-390
+ // collision a source-string matcher would trip over.
+ it('supplies its focus trap in the way its mount site allows', () => {
const { getByTestId } = render(element(true));
- expect(getByTestId(overlayTestId).props.accessibilityViewIsModal).toBe(true);
+ const isModal = getByTestId(overlayTestId).props.accessibilityViewIsModal;
+
+ if (mount === 'inline') {
+ expect(isModal).toBe(true);
+ } else {
+ // Root-slot: the trap is CleanRootNavigator's host instead, pinned by
+ // core/navigation/__tests__/rootOverlayFocusTrap.test.tsx.
+ expect(isModal).not.toBe(true);
+ }
});
it('is a full-bleed absolute layer, so its box is its host', () => {
diff --git a/app/src/core/navigation/CleanRootNavigator.tsx b/app/src/core/navigation/CleanRootNavigator.tsx
index 1dea7b89..1b67b62c 100644
--- a/app/src/core/navigation/CleanRootNavigator.tsx
+++ b/app/src/core/navigation/CleanRootNavigator.tsx
@@ -24,7 +24,8 @@ import CrisisResourcesScreen from '@/features/crisis/screens/CrisisResourcesScre
import RootCrisisButton from '@/features/crisis/components/RootCrisisButton';
// DEBUG-450 β eager import on the crisis path (CLAUDE.md rule), same as the button above.
import CrisisKeyboardAccessory from '@/features/crisis/components/CrisisKeyboardAccessory';
-import { RootOverlaySlot } from '@/core/navigation/rootOverlaySlot';
+import { RootOverlaySlot, useIsRootOverlayOccupied } from '@/core/navigation/rootOverlaySlot';
+import NavigatorA11yHost from '@/core/navigation/NavigatorA11yHost';
// DEBUG-341: eager, never lazy (CLAUDE.md crisis-path rule). Rendered by LoadingScreen
// above and by the overlay boundary below.
import Static988Button from '@/features/crisis/components/Static988Button';
@@ -231,6 +232,12 @@ const CleanRootNavigator: React.FC = () => {
// (suppression + immersive/standard mode). Tracked via NavigationContainer below.
const [activeRootRoute, setActiveRootRoute] = useState(undefined);
+ // DEBUG-575: drives the accessibility focus trap on the navigator host below.
+ // Read here rather than inside the host so the subscription is part of this
+ // component's normal render, and keyed on ownerId so a re-render of the
+ // publishing component does not re-render the whole navigator.
+ const rootOverlayOccupied = useIsRootOverlayOccupied();
+
useEffect(() => {
let cancelled = false;
@@ -401,6 +408,27 @@ const CleanRootNavigator: React.FC = () => {
onStateChange={() => setActiveRootRoute(getActiveRootRouteName())}
>
+ {/* DEBUG-575 β THE FOCUS TRAP FOR EVERY ROOT-SLOT OVERLAY LIVES HERE.
+ Not on the overlay. An overlay published into RootOverlaySlot is a
+ direct native SIBLING of RootCrisisButton and CrisisKeyboardAccessory
+ (the slot renders a bare fragment and adds no view), so
+ `accessibilityViewIsModal` on the overlay prunes BOTH crisis
+ affordances out of the accessibility tree β measured: zero
+ `crisis-button-root` nodes with the weekly-reflection composer open,
+ while the button was plainly painted on screen.
+
+ Hiding the navigator subtree instead confines assistive technology to
+ the overlay PLUS the crisis affordances, which is the trap actually
+ wanted. Scope is load-bearing and mirrors PracticeScreenLayout's rule:
+ this host wraps ONLY Stack.Navigator. RootOverlaySlot,
+ RootCrisisBoundary and CrisisKeyboardAccessory are deliberately
+ OUTSIDE it β wrapping them would hide the overlay along with
+ everything else, and hide 988 along with it.
+
+ `importantForAccessibility` carries Android, where
+ `accessibilityViewIsModal` is a no-op and nothing trapped focus at
+ all before this. Pinned by rootOverlayFocusTrap.test.tsx. */}
+
{
/>
+
{/* MAINT-290: single persistent crisis-button overlay. Sibling of the root
Stack.Navigator (JS stack β renders above stack modals too), so 988 access
diff --git a/app/src/core/navigation/NavigatorA11yHost.tsx b/app/src/core/navigation/NavigatorA11yHost.tsx
new file mode 100644
index 00000000..a23f2ab8
--- /dev/null
+++ b/app/src/core/navigation/NavigatorA11yHost.tsx
@@ -0,0 +1,72 @@
+/**
+ * Accessibility focus-trap host for root-slot overlays (DEBUG-575).
+ *
+ * Wraps the navigator subtree and hides it from assistive technology while an
+ * overlay holds the root slot. This is where the focus trap for every root-slot
+ * overlay lives β NOT on the overlay itself.
+ *
+ * ββ WHY NOT `accessibilityViewIsModal` ON THE OVERLAY ββ
+ *
+ * `RootOverlaySlot` renders a bare fragment, so a published overlay is a direct
+ * native SIBLING of `RootCrisisButton` and `CrisisKeyboardAccessory`. That prop
+ * prunes the RECEIVER'S SIBLINGS from the accessibility tree, so setting it on
+ * the overlay removed both root crisis affordances. Measured on the gate sim:
+ * zero `crisis-button-root` nodes in the hierarchy with the weekly-reflection
+ * composer open, one again the moment it closed, with the button plainly painted
+ * on screen throughout. Tree-level occlusion no screenshot can catch.
+ *
+ * Hiding the navigator subtree instead confines assistive technology to the
+ * overlay PLUS the crisis affordances, which is the trap that was actually
+ * wanted. Same shape as `DailyLoopNavigator`'s resume-modal host and the
+ * containment rule `PracticeScreenLayout` states.
+ *
+ * ββ WHAT MUST STAY OUTSIDE THIS HOST ββ
+ *
+ * `RootOverlaySlot`, `RootCrisisBoundary` and `CrisisKeyboardAccessory`. Putting
+ * any of them inside would hide the overlay along with the navigator β and hide
+ * 988 with it. That is the whole failure this component exists to prevent, so it
+ * takes ONLY the navigator as children and is never given the crisis affordances.
+ *
+ * ββ WHY IT IS ITS OWN MODULE ββ
+ *
+ * `CleanRootNavigator` cannot be rendered in jest: importing it drags in the
+ * entire screen tree, and several transitive deps are outside
+ * `transformIgnorePatterns`. The same reasoning `CleanTabNavigator.accessibility.test.tsx`
+ * records for its own wrapper. Presentational and self-contained, so it is tested
+ * directly.
+ *
+ * `importantForAccessibility` is not redundant with `accessibilityElementsHidden`:
+ * the first carries Android (where `accessibilityViewIsModal` is a no-op and
+ * nothing trapped focus at all before this), the second carries iOS.
+ */
+
+import React from 'react';
+import { View, StyleSheet, type ViewStyle, type StyleProp } from 'react-native';
+
+interface NavigatorA11yHostProps {
+ /** True while an overlay holds the root slot. */
+ hidden: boolean;
+ style?: StyleProp;
+ children: React.ReactNode;
+}
+
+export const NavigatorA11yHost: React.FC = ({
+ hidden,
+ style,
+ children,
+}) => (
+
+ {children}
+
+);
+
+const styles = StyleSheet.create({
+ fill: { flex: 1 },
+});
+
+export default NavigatorA11yHost;
diff --git a/app/src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx b/app/src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx
new file mode 100644
index 00000000..e90c0336
--- /dev/null
+++ b/app/src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx
@@ -0,0 +1,170 @@
+/**
+ * DEBUG-575 β the focus trap for root-slot overlays lives on the navigator host,
+ * and the crisis affordances stay OUTSIDE it.
+ *
+ * The defect this pins against: `accessibilityViewIsModal` on an overlay
+ * published into `RootOverlaySlot` pruned both `RootCrisisButton` and
+ * `CrisisKeyboardAccessory` out of the accessibility tree, because the slot
+ * renders a bare fragment and the overlay is therefore their direct native
+ * SIBLING. Measured on the gate sim: zero `crisis-button-root` nodes with the
+ * weekly-reflection composer open, one again once dismissed, with the button
+ * painted on screen the whole time. A zero-988 state for assistive technology
+ * that no screenshot and no `` guard could catch.
+ *
+ * `modalOcclusionConversions.test.tsx` pins the negative half (the two slot
+ * composers must NOT set the prop). This file pins the positive half: something
+ * still traps focus, and it excludes the crisis affordances.
+ *
+ * WHY THE WIRING IS PINNED FROM SOURCE. `CleanRootNavigator` cannot be rendered
+ * here β importing it drags in the whole screen tree and several transitive deps
+ * sit outside `transformIgnorePatterns`, the same constraint
+ * `CleanTabNavigator.accessibility.test.tsx` records. The host component itself
+ * is therefore tested behaviourally, and only its PLACEMENT is read from source.
+ * Comments are stripped first: `CleanRootNavigator` and both composers now carry
+ * prose naming this anti-pattern, which is exactly the DEBUG-390 collision where
+ * a bare identifier match fails on correct code.
+ */
+import React from 'react';
+import fs from 'fs';
+import path from 'path';
+import { Text } from 'react-native';
+import { render, act } from '@testing-library/react-native';
+
+import NavigatorA11yHost from '../NavigatorA11yHost';
+import { useRootOverlayStore, useIsRootOverlayOccupied } from '../rootOverlaySlot';
+
+const navigatorSource = fs.readFileSync(
+ path.join(__dirname, '..', 'CleanRootNavigator.tsx'),
+ 'utf8',
+);
+
+/** Source with block and line comments removed (DEBUG-390). */
+const stripped = navigatorSource
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/^\s*\/\/.*$/gm, '');
+
+describe('DEBUG-575 Β· NavigatorA11yHost hides its subtree on demand', () => {
+ it('hides descendants from assistive tech while an overlay holds the slot', () => {
+ const { getByTestId } = render(
+
+ navigator
+ ,
+ );
+ // `includeHiddenElements` is REQUIRED here and is itself evidence: RNTL
+ // excludes accessibility-hidden nodes from queries by default, so the host
+ // is only findable this way precisely BECAUSE the hiding took effect. A
+ // future regression that drops the props makes the plain query start
+ // working β which is why the sibling test below asserts the enabled case
+ // with a plain query.
+ const host = getByTestId('navigator-a11y-host', { includeHiddenElements: true });
+ // Both platforms. accessibilityViewIsModal is iOS-only and was never the
+ // right tool here; importantForAccessibility is what carries Android, where
+ // nothing trapped focus at all before this.
+ expect(host.props.accessibilityElementsHidden).toBe(true);
+ expect(host.props.importantForAccessibility).toBe('no-hide-descendants');
+ });
+
+ it('leaves the subtree fully reachable when the slot is empty', () => {
+ const { getByTestId } = render(
+
+ navigator
+ ,
+ );
+ // Plain query, deliberately: with the slot empty the navigator must be
+ // reachable by assistive tech, so RNTL must find it without the escape hatch.
+ const host = getByTestId('navigator-a11y-host');
+ expect(host.props.accessibilityElementsHidden).toBe(false);
+ expect(host.props.importantForAccessibility).toBe('auto');
+ });
+
+ it('never sets accessibilityViewIsModal β that is the defect, not the fix', () => {
+ const { getByTestId } = render(
+
+ navigator
+ ,
+ );
+ expect(
+ getByTestId('navigator-a11y-host', { includeHiddenElements: true }).props
+ .accessibilityViewIsModal,
+ ).not.toBe(true);
+ });
+});
+
+describe('DEBUG-575 Β· slot occupancy drives the trap', () => {
+ afterEach(() => {
+ act(() => useRootOverlayStore.getState().release('probe'));
+ });
+
+ const Probe: React.FC = () => (
+ {String(useIsRootOverlayOccupied())}
+ );
+
+ it('is false with an empty slot and true once an overlay claims it', () => {
+ const { getByTestId } = render( );
+ expect(getByTestId('occupied').props.children).toBe('false');
+
+ act(() => useRootOverlayStore.getState().claim('probe', o ));
+ expect(getByTestId('occupied').props.children).toBe('true');
+
+ act(() => useRootOverlayStore.getState().release('probe'));
+ expect(getByTestId('occupied').props.children).toBe('false');
+ });
+});
+
+describe('DEBUG-575 Β· the crisis affordances are outside the host', () => {
+ // The load-bearing structural claim. If any crisis affordance moved INSIDE the
+ // host, hiding the navigator would hide 988 too β reintroducing the very
+ // zero-affordance state this fix removes, and the flow would not catch it
+ // because the flow asserts the button is reachable while the sheet is up,
+ // which is exactly when the host is hidden.
+ const idx = (needle: string) => stripped.indexOf(needle);
+
+ it('wraps the Stack.Navigator, and only the Stack.Navigator', () => {
+ const hostOpen = idx('');
+ const hostClose = idx(' ');
+
+ expect(hostOpen).toBeGreaterThan(-1);
+ expect(hostClose).toBeGreaterThan(-1);
+ expect(hostOpen).toBeLessThan(navOpen);
+ expect(navClose).toBeLessThan(hostClose);
+ });
+
+ it('leaves the slot and both crisis affordances outside it', () => {
+ const hostClose = idx('');
+ for (const affordance of [
+ ' {
+ expect(stripped).toMatch(/ {
+ // Prove each matcher fires against a literal known-bad string, and that the
+ // stripper did not eat the file β a vacuous `indexOf` of -1 would otherwise
+ // make the ordering assertions silently meaningless.
+ expect('').toMatch(
+ / {
return <>{node}>;
};
+/**
+ * Whether any overlay currently holds the slot (DEBUG-575).
+ *
+ * `CleanRootNavigator` subscribes to this to hide the `Stack.Navigator` subtree
+ * from assistive technology while an overlay is up. A boolean is sufficient
+ * because mutual exclusion is already an invariant of this store: at most one
+ * overlay holds the slot at a time.
+ *
+ * Keyed on `ownerId` rather than `node` deliberately β `node` is a fresh element
+ * on every render of the publishing component, so a selector on it would return
+ * a new reference each time and re-render the whole navigator.
+ */
+export const useIsRootOverlayOccupied = (): boolean =>
+ useRootOverlayStore((s) => s.ownerId !== null);
+
export default RootOverlaySlot;
diff --git a/app/src/features/insights/components/SessionNoteComposer.tsx b/app/src/features/insights/components/SessionNoteComposer.tsx
index ba79dd61..0b242c8b 100644
--- a/app/src/features/insights/components/SessionNoteComposer.tsx
+++ b/app/src/features/insights/components/SessionNoteComposer.tsx
@@ -157,7 +157,15 @@ const SessionNoteComposer: React.FC = ({
return (
true}
onMoveShouldSetResponder={() => true}
diff --git a/app/src/features/insights/components/WeeklyReflectionComposer.tsx b/app/src/features/insights/components/WeeklyReflectionComposer.tsx
index 4c6df3cc..e23ec7ec 100644
--- a/app/src/features/insights/components/WeeklyReflectionComposer.tsx
+++ b/app/src/features/insights/components/WeeklyReflectionComposer.tsx
@@ -47,7 +47,8 @@
* button so it structurally cannot cover it.
*
* ββ WHAT SUPPLIED FOR FREE AND IS NOW HAND-ROLLED ββ
- * β’ iOS focus trap β `accessibilityViewIsModal`
+ * β’ the focus trap β CleanRootNavigator's host (DEBUG-575; it is
+ * NOT `accessibilityViewIsModal` β see below)
* β’ Android back-to-dismiss β BackHandler, live only while visible
* β’ touch isolation β the overlay root claims the responder
* β’ the surface-change announcement β focus moves to the title
@@ -161,7 +162,15 @@ const WeeklyReflectionComposer: React.FC = ({
return (
true}
onMoveShouldSetResponder={() => true}
From bd227bdee90314d05af86243fd6bb56eaeb3a716 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 31 Aug 2026 20:59:04 -0700
Subject: [PATCH 54/90] =?UTF-8?q?fix:=20DEBUG-575=20finding=202=20?=
=?UTF-8?q?=E2=80=94=20release=20the=20root=20slot=20on=20entry=20to=20a?=
=?UTF-8?q?=20crisis=20route?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Second defect, found by re-running the flow after finding 1 landed. The a11y
fix put crisis-button-root back in the tree, so the segment could finally
TAP it β and the tap did not deliver.
RootOverlaySlot paints above every navigator route, CrisisResources
included (a Stack.Screen with presentation:'modal', so no separate native
window to escape into). Nothing released the slot on navigation, so the
overlay stayed painted over the destination. Not dimmed β DEBUG-406 made
these backdrops OPAQUE to fix a WCAG 1.4.11 contrast failure β and the
overlay root claims the touch responder, so the crisis screen was invisible
AND inert. RootCrisisButton then self-suppressed on that route, so the
button the user had just pressed vanished with nothing replacing it.
PREDATES THIS BRANCH AND HITS SIGHTED USERS. The paint order has been this
way since DEBUG-406 (slot at 726 vs at 694 in b03277b8).
Finding 1 was merely stopping AT users one step earlier, masking this.
Fixing the a11y defect brought them to parity β including parity in this.
It also violates an invariant already written down in
crisis-zero-988-windows.test.tsx: "a route may suppress the root crisis
overlay ONLY IF every reachable render state of that route mounts its own
crisis affordance." CrisisResources earns its suppression on "it IS the
destination"; the render state CrisisResources-under-an-opaque-overlay
mounts none. That file's own next line says nothing checked it.
Enforced as a slot invariant, driven by navigation state β not in
RootCrisisButton's handler. The button knows nothing about overlays and
must not start (MAINT-290); it is not the only entrant (CrisisKeyboardAccessory,
being://crisis deep links, and the 400ms retry inside navigateToCrisisResources
reach the same route); and the release must come AFTER the navigate, which a
tap handler cannot express, since that util requires its first attempt stay
first and stay synchronous. Keyed on its own CRISIS_DESTINATION_ROUTES, never
on SUPPRESSED_ROUTES, which also holds AssessmentFlow and LegalGate.
claim() refuses symmetrically while a crisis route is active.
THE DRAFT IS PRESERVED. Release discards nothing: text is lifted to
WeeklyReflectionCard and fed back through initialText. In-memory only, no
persistence on a crisis path, and not auto-reopened on return. Discarding
5000 characters about a hard week at the moment someone reaches for 988 is
a harm on its own, and worse second-order: a 988 button that costs you
something is one you hesitate over. Cancel still discards, so DEBUG-406's
Cancel contract is unchanged.
useRootOverlay gains onRevoked so both consumers close instead of
re-publishing over CrisisResources on return.
Pins: rootOverlayCrisisRoute.test.tsx (7 cases, mutation-verified β
removing the release reds two). A unit test because no static rule reaches
this: the two-list checker, INFRA-531's import detector and
check-modal-occlusion-guard.js all missed it, and none could catch a runtime
relation between two independent subtrees. All three defects on this branch
were found by running the thing.
Flow: the occlusion arm now asserts sheet-gone AND screen-visible β either
alone would have passed against this defect. The old "sheet survives the
excursion" post-condition was the defect written down as a contract.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/.maestro/crisis-button-reachability.yaml | 26 ++++-
.../safety/rootOverlayCrisisRoute.test.tsx | 106 ++++++++++++++++++
.../core/navigation/CleanRootNavigator.tsx | 28 ++++-
app/src/core/navigation/rootOverlaySlot.tsx | 105 ++++++++++++++++-
.../components/WeeklyReflectionCard.tsx | 45 ++++++--
.../components/WeeklyReflectionComposer.tsx | 15 ++-
.../components/WellnessScreeningTrends.tsx | 7 +-
7 files changed, 313 insertions(+), 19 deletions(-)
create mode 100644 app/__tests__/safety/rootOverlayCrisisRoute.test.tsx
diff --git a/app/.maestro/crisis-button-reachability.yaml b/app/.maestro/crisis-button-reachability.yaml
index dc9b6f52..ed7d3a0e 100644
--- a/app/.maestro/crisis-button-reachability.yaml
+++ b/app/.maestro/crisis-button-reachability.yaml
@@ -250,6 +250,17 @@ name: "Crisis button (single root mount) reaches CrisisResources from every surf
id: "crisis-button-root"
- tapOn:
id: "crisis-button-root"
+# DEBUG-575 finding 2 β BOTH of the next two are required and neither substitutes
+# for the other. Before the fix the SHEET-GONE assertion was what failed: the route
+# committed to CrisisResources while this composer's OPAQUE inset-0 backdrop covered
+# it and its responder swallowed every touch, so the crisis screen was mounted,
+# listed in the hierarchy, and completely useless. Asserting only that the screen
+# exists would have passed against that. Asserting the sheet is gone is what proves
+# the destination is actually on screen and reachable.
+- extendedWaitUntil:
+ notVisible:
+ id: "weekly-reflection-overlay"
+ timeout: 3000
- assertVisible:
id: "crisis-resources-screen"
- extendedWaitUntil:
@@ -257,8 +268,19 @@ name: "Crisis button (single root mount) reaches CrisisResources from every surf
timeout: 3000
- tapOn:
id: "nav-back-button"
-# Post-condition, not a tap (DEBUG-403 shape: a tap that reported COMPLETED while
-# the app never received it). The sheet survives the excursion.
+# INVERTED BY DEBUG-575 finding 2. This used to assert the sheet SURVIVES the
+# excursion β that was the defect written down as a contract. The slot is released
+# on entry to the crisis route, so the user returns to Insights with the card's own
+# control restored and no sheet. The draft is preserved in memory and fed back on
+# re-open; that is unobservable to Maestro and is pinned in jest instead.
+- assertNotVisible:
+ id: "weekly-reflection-overlay"
+- assertVisible:
+ id: "weekly-reflection-prompt"
+
+# The mis-tap arm below needs the sheet up again β the release above closed it.
+- tapOn:
+ id: "weekly-reflection-prompt"
- assertVisible:
id: "weekly-reflection-overlay"
diff --git a/app/__tests__/safety/rootOverlayCrisisRoute.test.tsx b/app/__tests__/safety/rootOverlayCrisisRoute.test.tsx
new file mode 100644
index 00000000..dbc922b9
--- /dev/null
+++ b/app/__tests__/safety/rootOverlayCrisisRoute.test.tsx
@@ -0,0 +1,106 @@
+/**
+ * DEBUG-575 finding 2 β no overlay may hold the root slot on a crisis route.
+ *
+ * THE DEFECT. `RootOverlaySlot` paints above EVERY navigator route, including
+ * `CrisisResources` (a `Stack.Screen` with `presentation: 'modal'` β a JS stack
+ * modal, so there is no separate native window to escape into). An overlay left
+ * holding the slot therefore covered the destination the crisis button had just
+ * navigated to. Not dimmed: DEBUG-406 made these backdrops OPAQUE to satisfy
+ * WCAG 1.4.11, and the overlay root claims the touch responder, so the crisis
+ * screen was invisible AND inert. `RootCrisisButton` then suppressed itself on
+ * that route, so the affordance the user had just pressed vanished with nothing
+ * replacing it β a zero-988 state produced BY the crisis tap.
+ *
+ * This violates the invariant `crisis-zero-988-windows.test.tsx` already states:
+ * a route may suppress the root crisis overlay ONLY IF every reachable render
+ * state of that route mounts its own crisis affordance.
+ *
+ * WHY A UNIT TEST AND NOT A STATIC RULE. No static analysis reaches this. The
+ * two-list reconciliation, INFRA-531's crisis-import detector and
+ * check-modal-occlusion-guard.js all missed it, and none of them could catch it:
+ * "an overlay published into a slot that paints above the navigator, while the
+ * navigator's active route is the crisis destination" is a RUNTIME relation
+ * between two independent subtrees. All three defects on this branch were found
+ * by running the thing, not by reading it.
+ *
+ * The Maestro segment covers the user-visible half. This covers the invariant
+ * directly, in milliseconds, and can go red.
+ */
+import React from 'react';
+import { Text } from 'react-native';
+import {
+ useRootOverlayStore,
+ CRISIS_DESTINATION_ROUTES,
+} from '@/core/navigation/rootOverlaySlot';
+
+const node = overlay ;
+
+const reset = () =>
+ useRootOverlayStore.setState({
+ ownerId: null,
+ node: null,
+ crisisRouteActive: false,
+ });
+
+describe('DEBUG-575 Β· the slot is released when a crisis route becomes active', () => {
+ beforeEach(reset);
+
+ it('releases an overlay that is holding the slot', () => {
+ const s = useRootOverlayStore.getState();
+ s.claim('weekly-reflection-composer', node);
+ expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer');
+
+ useRootOverlayStore.getState().syncActiveRoute('CrisisResources');
+
+ const after = useRootOverlayStore.getState();
+ expect(after.ownerId).toBeNull();
+ expect(after.node).toBeNull();
+ });
+
+ it('releases unconditionally β the owner cannot veto it', () => {
+ // Deliberately NOT the guarded `release(id)` path: the owner did not ask for
+ // this and must not be able to keep the slot. 988 wins over what is on screen.
+ useRootOverlayStore.getState().claim('session-note-composer', node);
+ useRootOverlayStore.getState().syncActiveRoute('CrisisResources');
+ expect(useRootOverlayStore.getState().ownerId).toBeNull();
+ });
+
+ it('refuses a NEW claim while a crisis route is active', () => {
+ useRootOverlayStore.getState().syncActiveRoute('CrisisResources');
+ useRootOverlayStore.getState().claim('weekly-reflection-composer', node);
+ expect(useRootOverlayStore.getState().ownerId).toBeNull();
+ });
+
+ it('allows claims again once the user leaves the crisis route', () => {
+ useRootOverlayStore.getState().syncActiveRoute('CrisisResources');
+ useRootOverlayStore.getState().syncActiveRoute('Main');
+ useRootOverlayStore.getState().claim('weekly-reflection-composer', node);
+ expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer');
+ });
+
+ it('leaves the slot alone on every non-crisis route', () => {
+ // Route-keyed, NOT release-on-any-navigation: killing overlays on unrelated
+ // pushes would be a behaviour change nobody asked for.
+ useRootOverlayStore.getState().claim('weekly-reflection-composer', node);
+ for (const route of ['Main', 'AssessmentFlow', 'LegalGate', 'VoiceReflection']) {
+ useRootOverlayStore.getState().syncActiveRoute(route);
+ expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer');
+ }
+ });
+
+ it('is keyed on its own constant, not on SUPPRESSED_ROUTES', () => {
+ // SUPPRESSED_ROUTES means "the FAB steps aside here" and also holds
+ // AssessmentFlow and LegalGate, which are not crisis DESTINATIONS. Reusing a
+ // set whose meaning is adjacent-but-different is how the guidance/ and
+ // consent/ two-list failures started.
+ expect(CRISIS_DESTINATION_ROUTES).toEqual(['CrisisResources']);
+ expect(CRISIS_DESTINATION_ROUTES).not.toContain('AssessmentFlow');
+ expect(CRISIS_DESTINATION_ROUTES).not.toContain('LegalGate');
+ });
+
+ it('tolerates an undefined route name without releasing', () => {
+ useRootOverlayStore.getState().claim('weekly-reflection-composer', node);
+ useRootOverlayStore.getState().syncActiveRoute(undefined);
+ expect(useRootOverlayStore.getState().ownerId).toBe('weekly-reflection-composer');
+ });
+});
diff --git a/app/src/core/navigation/CleanRootNavigator.tsx b/app/src/core/navigation/CleanRootNavigator.tsx
index 1b67b62c..e9582858 100644
--- a/app/src/core/navigation/CleanRootNavigator.tsx
+++ b/app/src/core/navigation/CleanRootNavigator.tsx
@@ -24,7 +24,11 @@ import CrisisResourcesScreen from '@/features/crisis/screens/CrisisResourcesScre
import RootCrisisButton from '@/features/crisis/components/RootCrisisButton';
// DEBUG-450 β eager import on the crisis path (CLAUDE.md rule), same as the button above.
import CrisisKeyboardAccessory from '@/features/crisis/components/CrisisKeyboardAccessory';
-import { RootOverlaySlot, useIsRootOverlayOccupied } from '@/core/navigation/rootOverlaySlot';
+import {
+ RootOverlaySlot,
+ useIsRootOverlayOccupied,
+ useRootOverlayStore,
+} from '@/core/navigation/rootOverlaySlot';
import NavigatorA11yHost from '@/core/navigation/NavigatorA11yHost';
// DEBUG-341: eager, never lazy (CLAUDE.md crisis-path rule). Rendered by LoadingScreen
// above and by the overlay boundary below.
@@ -404,8 +408,26 @@ const CleanRootNavigator: React.FC = () => {
setActiveRootRoute(getActiveRootRouteName() ?? initialRoute)}
- onStateChange={() => setActiveRootRoute(getActiveRootRouteName())}
+ /* DEBUG-575 finding 2 β `syncActiveRoute` enforces the slot's crisis-route
+ invariant: no overlay may hold the slot while CrisisResources is active,
+ because the slot paints ABOVE every navigator route and these backdrops
+ are opaque, so the crisis screen would be both invisible and inert.
+ Driven from navigation state rather than from any control, so the FAB,
+ CrisisKeyboardAccessory, `being://crisis` deep links and the 400ms retry
+ inside navigateToCrisisResources are all covered without enumerating
+ them. It runs AFTER the state commit, which is also why it cannot live
+ in a tap handler β that util requires its first attempt stay first and
+ stay synchronous. */
+ onReady={() => {
+ const r = getActiveRootRouteName() ?? initialRoute;
+ useRootOverlayStore.getState().syncActiveRoute(r);
+ setActiveRootRoute(r);
+ }}
+ onStateChange={() => {
+ const r = getActiveRootRouteName();
+ useRootOverlayStore.getState().syncActiveRoute(r);
+ setActiveRootRoute(r);
+ }}
>
{/* DEBUG-575 β THE FOCUS TRAP FOR EVERY ROOT-SLOT OVERLAY LIVES HERE.
diff --git a/app/src/core/navigation/rootOverlaySlot.tsx b/app/src/core/navigation/rootOverlaySlot.tsx
index c4e4c93d..79a48dbe 100644
--- a/app/src/core/navigation/rootOverlaySlot.tsx
+++ b/app/src/core/navigation/rootOverlaySlot.tsx
@@ -47,6 +47,37 @@
* overlays whose BackHandlers would fire LIFO and whose dismissals would leave
* the other orphaned.
*
+ * ββ AND NO OVERLAY MAY HOLD THE SLOT ON A CRISIS ROUTE (DEBUG-575 finding 2) ββ
+ *
+ * The slot paints above EVERY navigator route, `CrisisResources` included β it is
+ * a `Stack.Screen` with `presentation: 'modal'`, a JS stack modal with no separate
+ * native window to escape into. So an overlay left holding the slot when the user
+ * taps the crisis button covers the destination it just sent them to. Not dimmed:
+ * DEBUG-406 made these backdrops OPAQUE to satisfy WCAG 1.4.11, and the overlay
+ * root claims the touch responder, so the crisis screen is invisible AND inert.
+ * `RootCrisisButton` then suppresses itself on that route, so the affordance the
+ * user just pressed vanishes and nothing replaces it.
+ *
+ * That is the invariant `crisis-zero-988-windows.test.tsx` already states β "a
+ * route may suppress the root crisis overlay ONLY IF every reachable render state
+ * of that route mounts its own crisis affordance" β violated by a render state
+ * DEBUG-406 created and nobody re-checked.
+ *
+ * Enforced HERE rather than in `RootCrisisButton`'s handler, for three reasons:
+ * the button knows nothing about overlays and must not start (MAINT-290's whole
+ * point); it is not the only entrant, so a handler-side release covers one of N
+ * (`CrisisKeyboardAccessory`, `being://crisis` deep links, and the 400ms retry
+ * inside `navigateToCrisisResources` all reach the same route); and the release
+ * must come AFTER the navigate, which a tap handler cannot express β that util
+ * requires its first attempt stay first and stay synchronous.
+ *
+ * Driven by navigation state, so every entrant is covered by construction rather
+ * than by enumeration. Keyed on an explicit crisis-route set, NOT on
+ * `RootCrisisButton.SUPPRESSED_ROUTES`: that set means "the FAB steps aside here"
+ * and also holds `AssessmentFlow` and `LegalGate`, which are not crisis
+ * destinations. Reusing a set whose meaning is adjacent-but-different is how the
+ * `guidance/` and `consent/` two-list failures started.
+ *
* ββ THE FOCUS TRAP IS THE SLOT'S JOB, NOT THE OVERLAY'S (DEBUG-575) ββ
*
* This section used to say the focus trap "remains the overlay's own
@@ -85,21 +116,49 @@ import React, { useEffect } from 'react';
import { create } from 'zustand';
import { logSystem } from '@/core/services/logging';
+/**
+ * Routes on which no overlay may hold the slot (DEBUG-575).
+ *
+ * Deliberately its own named constant rather than a reuse of
+ * `RootCrisisButton.SUPPRESSED_ROUTES` β see the docblock above.
+ */
+export const CRISIS_DESTINATION_ROUTES: readonly string[] = ['CrisisResources'];
+
+const isCrisisRoute = (routeName?: string | null): boolean =>
+ typeof routeName === 'string' && CRISIS_DESTINATION_ROUTES.includes(routeName);
+
interface RootOverlayState {
/** Identity of the overlay currently holding the slot, or null. */
ownerId: string | null;
/** The element to render. */
node: React.ReactNode | null;
+ /** True while a crisis destination is the active root route. */
+ crisisRouteActive: boolean;
claim: (id: string, node: React.ReactNode) => void;
release: (id: string) => void;
+ /** Called from CleanRootNavigator's onStateChange / onReady. */
+ syncActiveRoute: (routeName?: string | null) => void;
}
export const useRootOverlayStore = create((set, get) => ({
ownerId: null,
node: null,
+ crisisRouteActive: false,
claim: (id, node) => {
- const { ownerId } = get();
+ const { ownerId, crisisRouteActive } = get();
+
+ // DEBUG-575 finding 2, the symmetric half: refuse rather than paint over the
+ // crisis destination. Nothing publishes from CrisisResources today β both
+ // claimants live in features/insights/components/ β but this closes the
+ // direction a future overlay would otherwise walk into, and it is two lines.
+ if (crisisRouteActive) {
+ logSystem(
+ `Root overlay slot claim by "${id}" REFUSED β a crisis route is active`,
+ );
+ return;
+ }
+
if (ownerId !== null && ownerId !== id) {
// Not a crash β a stacked overlay is a UI bug, not a safety one, and
// throwing here would take down a screen. But it must be loud: the
@@ -118,6 +177,23 @@ export const useRootOverlayStore = create((set, get) => ({
if (get().ownerId !== id) return;
set({ ownerId: null, node: null });
},
+
+ syncActiveRoute: (routeName) => {
+ const active = isCrisisRoute(routeName);
+ const { ownerId } = get();
+
+ // Unconditional release, NOT the guarded `release(id)` above: the owner did
+ // not ask for this and must not be able to veto it. 988 reachability wins
+ // over whatever is on screen.
+ if (active && ownerId !== null) {
+ logSystem(
+ `Root overlay slot released β "${ownerId}" cannot hold it on crisis route "${routeName}"`,
+ );
+ set({ ownerId: null, node: null, crisisRouteActive: true });
+ return;
+ }
+ set({ crisisRouteActive: active });
+ },
}));
/**
@@ -134,9 +210,36 @@ export function useRootOverlay(
id: string,
visible: boolean,
render: () => React.ReactNode,
+ onRevoked?: () => void,
): void {
const claim = useRootOverlayStore((s) => s.claim);
const release = useRootOverlayStore((s) => s.release);
+ const currentOwner = useRootOverlayStore((s) => s.ownerId);
+
+ // DEBUG-575 finding 2. The slot can now be taken away without the owner
+ // asking β `syncActiveRoute` releases it unconditionally when a crisis route
+ // becomes active. Centralised here rather than left to each consumer: both
+ // callers would otherwise have to remember the invariant, which is how the
+ // two-list failures start.
+ //
+ // Without this the owner's `visible` stays true, so the claim effect below
+ // re-runs and re-publishes the moment the user navigates BACK β throwing a
+ // reflection sheet at someone returning from crisis resources. Telling the
+ // owner to close is what makes the release stick.
+ const onRevokedRef = React.useRef(onRevoked);
+ onRevokedRef.current = onRevoked;
+ const heldRef = React.useRef(false);
+
+ useEffect(() => {
+ if (currentOwner === id) {
+ heldRef.current = true;
+ return;
+ }
+ if (heldRef.current) {
+ heldRef.current = false;
+ if (visible) onRevokedRef.current?.();
+ }
+ }, [currentOwner, id, visible]);
// Effect, not render-phase: mutating a store during render is unsafe under
// concurrent rendering, and the slot is a side effect on shared state.
diff --git a/app/src/features/insights/components/WeeklyReflectionCard.tsx b/app/src/features/insights/components/WeeklyReflectionCard.tsx
index 0df53be6..eff8cc5d 100644
--- a/app/src/features/insights/components/WeeklyReflectionCard.tsx
+++ b/app/src/features/insights/components/WeeklyReflectionCard.tsx
@@ -38,6 +38,19 @@ const FRAMING = 'For deepening, not catching up. Daily practice remains the work
const WeeklyReflectionCard: React.FC = () => {
const [composerOpen, setComposerOpen] = useState(false);
+
+ // DEBUG-575 finding 2 β the in-memory draft.
+ //
+ // A crisis tap now RELEASES the slot, so the sheet closes without the user
+ // asking. Discarding up to 5000 characters about a hard week at the moment
+ // someone reaches for 988 is a harm on its own, and the second-order cost is
+ // worse: a user who learns it happens weighs the cost before tapping 988 next
+ // time. A crisis affordance must never be something you hesitate over.
+ //
+ // In-memory ONLY, deliberately: no SecureStore, no persistence layer on a
+ // crisis path. It does not outlive the session. Cancel still discards, so
+ // DEBUG-406's Cancel contract is unchanged β only the revoke path preserves.
+ const [draft, setDraft] = useState(null);
// Focus returns here when the composer closes β the overlay is no longer an
// RN , so nothing restores it for free.
const triggerRef = useRef | null>(null);
@@ -68,6 +81,7 @@ const WeeklyReflectionCard: React.FC = () => {
const handleSave = useCallback(
async (text: string) => {
await addWeeklyReflection(text);
+ setDraft(null); // saved β the preserved draft is spent
setComposerOpen(false);
},
[addWeeklyReflection]
@@ -75,15 +89,28 @@ const WeeklyReflectionCard: React.FC = () => {
// DEBUG-406: publish the composer into the root overlay slot. Declared before
// the early return below so the hook order is stable across renders.
- useRootOverlay('weekly-reflection-composer', composerOpen, () => (
- setComposerOpen(false)}
- returnFocusRef={triggerRef}
- />
- ));
+ useRootOverlay(
+ 'weekly-reflection-composer',
+ composerOpen,
+ () => (
+ {
+ setDraft(null);
+ setComposerOpen(false);
+ }}
+ onDraftChange={setDraft}
+ returnFocusRef={triggerRef}
+ />
+ ),
+ // Revoked by a crisis route. Close, but KEEP the draft β it is fed back via
+ // initialText above. Deliberately NOT auto-reopened on return: someone
+ // coming back from crisis resources should not be handed a reflection sheet.
+ // Their re-entry point is the card's own control, unchanged.
+ () => setComposerOpen(false),
+ );
if (checkInsThisWeek < MIN_CHECK_INS_TO_SHOW) {
return null;
diff --git a/app/src/features/insights/components/WeeklyReflectionComposer.tsx b/app/src/features/insights/components/WeeklyReflectionComposer.tsx
index e23ec7ec..4f9ff73e 100644
--- a/app/src/features/insights/components/WeeklyReflectionComposer.tsx
+++ b/app/src/features/insights/components/WeeklyReflectionComposer.tsx
@@ -98,6 +98,12 @@ interface WeeklyReflectionComposerProps {
initialText: string;
onSave: (text: string) => void | Promise;
onCancel: () => void;
+ /**
+ * Reports the in-progress text so the host can preserve it if the slot is
+ * revoked by a crisis route (DEBUG-575 finding 2). Not a controlled-input
+ * conversion: `text` stays local, this only mirrors it upward.
+ */
+ onDraftChange?: (text: string) => void;
/** Control that opened the sheet; focus returns here on close. */
returnFocusRef?: React.RefObject | null>;
}
@@ -107,6 +113,7 @@ const WeeklyReflectionComposer: React.FC = ({
initialText,
onSave,
onCancel,
+ onDraftChange,
returnFocusRef,
}) => {
const [text, setText] = useState(initialText);
@@ -198,9 +205,11 @@ const WeeklyReflectionComposer: React.FC = ({
{...crisisAccessoryProps()} /* DEBUG-450 */
style={styles.input}
value={text}
- onChangeText={(next) =>
- setText(next.length > MAX_LEN ? next.slice(0, MAX_LEN) : next)
- }
+ onChangeText={(next) => {
+ const clamped = next.length > MAX_LEN ? next.slice(0, MAX_LEN) : next;
+ setText(clamped);
+ onDraftChange?.(clamped);
+ }}
placeholder="Write what you noticed this weekβ¦"
placeholderTextColor={colorSystem.gray[400]}
multiline
diff --git a/app/src/features/insights/components/WellnessScreeningTrends.tsx b/app/src/features/insights/components/WellnessScreeningTrends.tsx
index 9772e538..2b90aa68 100644
--- a/app/src/features/insights/components/WellnessScreeningTrends.tsx
+++ b/app/src/features/insights/components/WellnessScreeningTrends.tsx
@@ -584,7 +584,12 @@ const WellnessScreeningTrends: React.FC = ({
}}
onCancel={() => setEditing(null)}
/>
- ));
+ ),
+ // DEBUG-575 finding 2 β the slot is revoked when a crisis route becomes
+ // active, so close rather than re-publishing over CrisisResources on return.
+ // No draft preservation here: unlike the weekly reflection, this composer
+ // edits an EXISTING stored note and its text is already persisted.
+ () => setEditing(null));
// Don't show the section until there's at least one completed screening.
if (!hasPhq9 && !hasGad7) return null;
From 0c2d9273bff014dfdb9129ade0a7f58d842083be Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Mon, 31 Aug 2026 21:25:24 -0700
Subject: [PATCH 55/90] chore: INFRA-532 move the focus-trap pin into the
CI-run safety suite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
check-ci-test-coverage flagged it: under src/core/navigation/__tests__/ the
file matched no CI --testPathPattern, so it would have run on nobody's PR β
a pin that cannot go red in CI is not a pin. Its sibling
rootOverlayCrisisRoute.test.tsx was already in __tests__/safety/, which the
Safety + privacy gates job runs, and both are safety pins on crisis
affordance reachability.
Relocated rather than renamed toward a pattern: the whole
core/navigation/__tests__/ directory is ungated (dailyLoopDeepLink.test.ts
sits in the same hole), so wiring by filename would have been gaming the
substring match rather than gaining coverage.
Imports rebased to @/core/navigation/*; the CleanRootNavigator source read
re-pathed; cross-reference in modalOcclusionConversions updated.
Verified: coverage gate green (264 covered / 42 allowlisted), 8/8 pass,
lint baseline back to 436/461 with the src/ entry dropped.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
app/.eslint-baseline.json | 3 +--
app/__tests__/safety/modalOcclusionConversions.test.tsx | 2 +-
.../safety}/rootOverlayFocusTrap.test.tsx | 9 ++++++---
3 files changed, 8 insertions(+), 6 deletions(-)
rename app/{src/core/navigation/__tests__ => __tests__/safety}/rootOverlayFocusTrap.test.tsx (96%)
diff --git a/app/.eslint-baseline.json b/app/.eslint-baseline.json
index 189bb5a3..86af1e7b 100644
--- a/app/.eslint-baseline.json
+++ b/app/.eslint-baseline.json
@@ -229,6 +229,5 @@
"src/core/analytics/__tests__/PostHogProvider.consentRemount.privacy.test.tsx": 1,
"src/core/analytics/__tests__/AppLifecycleTracker.test.tsx": 1,
"src/core/analytics/__tests__/PostHogProvider.lifecycleTracker.test.tsx": 1,
- "src/core/analytics/__tests__/appLifecycleTelemetry.test.ts": 1,
- "src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx": 1
+ "src/core/analytics/__tests__/appLifecycleTelemetry.test.ts": 1
}
diff --git a/app/__tests__/safety/modalOcclusionConversions.test.tsx b/app/__tests__/safety/modalOcclusionConversions.test.tsx
index a09ce28d..707e90d9 100644
--- a/app/__tests__/safety/modalOcclusionConversions.test.tsx
+++ b/app/__tests__/safety/modalOcclusionConversions.test.tsx
@@ -111,7 +111,7 @@ describe.each(CASES)('DEBUG-406 Β· $name occlusion guards', ({ overlayTestId, el
expect(isModal).toBe(true);
} else {
// Root-slot: the trap is CleanRootNavigator's host instead, pinned by
- // core/navigation/__tests__/rootOverlayFocusTrap.test.tsx.
+ // __tests__/safety/rootOverlayFocusTrap.test.tsx.
expect(isModal).not.toBe(true);
}
});
diff --git a/app/src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx b/app/__tests__/safety/rootOverlayFocusTrap.test.tsx
similarity index 96%
rename from app/src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx
rename to app/__tests__/safety/rootOverlayFocusTrap.test.tsx
index e90c0336..1100c365 100644
--- a/app/src/core/navigation/__tests__/rootOverlayFocusTrap.test.tsx
+++ b/app/__tests__/safety/rootOverlayFocusTrap.test.tsx
@@ -30,11 +30,14 @@ import path from 'path';
import { Text } from 'react-native';
import { render, act } from '@testing-library/react-native';
-import NavigatorA11yHost from '../NavigatorA11yHost';
-import { useRootOverlayStore, useIsRootOverlayOccupied } from '../rootOverlaySlot';
+import NavigatorA11yHost from '@/core/navigation/NavigatorA11yHost';
+import {
+ useRootOverlayStore,
+ useIsRootOverlayOccupied,
+} from '@/core/navigation/rootOverlaySlot';
const navigatorSource = fs.readFileSync(
- path.join(__dirname, '..', 'CleanRootNavigator.tsx'),
+ path.join(__dirname, '..', '..', 'src', 'core', 'navigation', 'CleanRootNavigator.tsx'),
'utf8',
);
From ef8a017da1f9d6da28bde68b75d71ad83ab0f87e Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Wed, 2 Sep 2026 22:14:50 -0700
Subject: [PATCH 56/90] chore: INFRA-576 fix three un-allowlisted CVEs blocking
every PR
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two HIGH browserslist advisories (GHSA-73wf-gq98-2v4g uncaught crash /
prototype write via untrusted browserslist-stats.json; GHSA-c83g-rgw3-j3cx
unbounded memory growth leading to OOM) and one MODERATE @xmldom/xmldom
advisory (GHSA-6gmq-8vp8-gcm6 XML fragment injection during
requireWellFormed serialization) published 2026-09-01/09-02 and red-gated
`Security + compliance` on every open and future PR, on an unchanged tree.
All three FIXED via same-major overrides+resolutions; nothing allowlisted:
- browserslist -> ^4.28.7 (resolves 4.28.8). One deduped node, every
requester on 4.x, so unkeyed.
- @xmldom/xmldom@^0.8.8 -> 0.8.15 and @xmldom/xmldom@^0.9.10 -> 0.9.12.
The advisory reports two ranges with separate first-patched versions and
the tree held one node on each; each requested range admits its own
patch, so neither line crosses a major.
Also RETIRED GHSA-ph9p-34f9-6g65 (tmp path traversal) from the allowlist.
Three prior items declined on the recorded ground that its drop-condition
was an @expo/config-plugins bump. That was wrong: the tree's only tmp is
0.2.7 via patch-package, above the 0.2.6 first-patched and on no
@expo/config-plugins path. audit-ci had been flagging it as
"Consider not allowlisting"; that hint is now clear.
Reviewed the eight remaining allowlisted-but-vulnerable advisories: no new
same-major fix exists for any (markdown-it 10 -> 14.2.0, linkify-it 2.2 ->
5.0.2 and uuid 7.0.3 -> 11.1.1 are breaking major jumps; image-size still
reports firstPatched NONE on every version; decode-uri-component 0.5.0
remains the ESM-only trap). Every documented drop-condition still holds.
Verified: `npx audit-ci --config .audit-ci.json` -> "Passed npm security
audit" (exit 0); `npx patch-package` still resolves expo-modules-jsi@56.0.12;
`npx expo export --platform ios` bundles 2580 modules, since browserslist is
on the babel/metro path and CI does not run Metro.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
app/.audit-ci.json | 3 +--
app/package-lock.json | 58 +++++++++++++++++++++----------------------
app/package.json | 10 ++++++--
3 files changed, 38 insertions(+), 33 deletions(-)
diff --git a/app/.audit-ci.json b/app/.audit-ci.json
index 41b91841..782d7071 100644
--- a/app/.audit-ci.json
+++ b/app/.audit-ci.json
@@ -3,7 +3,6 @@
"moderate": true,
"report-type": "summary",
"allowlist": [
- "GHSA-ph9p-34f9-6g65",
"GHSA-6vfc-qv3f-vr6c",
"GHSA-w5hq-g745-h8pq",
"GHSA-6v5v-wf23-fmfq",
@@ -13,5 +12,5 @@
"GHSA-w3rx-r6r6-pgpr",
"GHSA-vcc3-ghjq-m6fr"
],
- "$comment": "MAINT-182. First three CVEs are transitive through @expo/config-plugins@56.0.8 (build-time only, no patched stable available as of 2026-05-27). GHSA-ph9p (tmp path traversal) and GHSA-6vfc (markdown-it ReDoS) only run during prebuild/codegen. GHSA-w5hq (uuid buffer bounds) is in dev tooling. Review when Expo ships @expo/config-plugins >=56.0.9 stable: drop these GHSAs and verify `npm audit --audit-level moderate` passes. --- MAINT-281 (2026-06-15) added the last two (both quadratic-complexity DoS, no fix in range, require attacker-controlled input which Being has no path for): GHSA-h67p (js-yaml) is dev/build-time ONLY β transitive via ts-jest, @expo/cli/@expo/xcpretty, and babel-jest; fix is a breaking ts-jest major bump. GHSA-6v5v (markdown-it) is runtime via react-native-markdown-display@7, but its ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) β no remote/user markdown, so the DoS is unreachable; no patched markdown-it in react-native-markdown-display@7's range. Drop GHSA-6v5v if react-native-markdown-display upgrades markdown-it; drop GHSA-h67p on the next ts-jest major. The high-severity form-data CVE (GHSA-hmw2-7cc7-3qxx) disclosed the same day was FIXED via lockfile (npm audit fix), not allowlisted. --- MAINT-294 (2026-07-08) added GHSA-22p9-wv53-3rq4 (linkify-it 'LinkifyIt#match' scan loop, quadratic-complexity ReDoS, high; range <=5.0.0, fixAvailable:false). Direct twin of GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it@2.2.0, and its ONLY call site is LegalDocumentScreen rendering bundled first-party legal docs (legalContent.generated.ts <- docs/legal/*.md) β no remote/user markdown, so the quadratic blow-up needs attacker-controlled input Being has no path to. No fix in range (would need breaking markdown-it/react-native-markdown-display majors, risking the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-22p9 together with GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- INFRA-302 (2026-07-21) three new HIGH DoS advisories published to the GitHub DB. Two were FIXED via same-major overrides (not allowlisted): GHSA-395f-4hp3-45gv (shell-quote quadratic parse() DoS) -> shell-quote ^1.10.0 (was 1.8.4, single copy); GHSA-3jxr-9vmj-r5cp (brace-expansion exponential expansion DoS) -> version-keyed overrides pinning the 1.x eslint/jest tooling copies to 1.1.16 and the 5.x copy to 5.0.7. The THIRD, GHSA-52cp-r559-cp3m (js-yaml quadratic merge-key DoS; range <=3.14.2 || 4.0.0-4.2.0, fixAvailable but not in-range for the 3.x path), is allowlisted here as a direct sibling of the already-allowlisted GHSA-h67p (js-yaml, MAINT-281): the top-level js-yaml@3.14.2 consumer has NO same-major fix (all 3.x is vulnerable) and the single GHSA covers both the 3.x and 4.x paths, so a clean override is impossible without a breaking 3->4 major bump of a dev/build-time tooling dep (ts-jest, @expo/cli/@expo/xcpretty, eslintrc, babel-jest). Dev/build-time only, operates on trusted first-party source, no attacker-controlled-input path -> unreachable DoS. Drop GHSA-52cp together with GHSA-h67p on the next ts-jest major (both js-yaml). Also allowlisted GHSA-v245-v573-v5vm (linkify-it, published same window) as a direct sibling of GHSA-22p9/GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it, whose ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) β no remote/user markdown, so unreachable; no fix in range without breaking markdown-it/react-native-markdown-display majors (risks the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-v245 with GHSA-22p9/GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- FEAT-301 (2026-07-25) a NEW brace-expansion advisory GHSA-mh99-v99m-4gvg (unbounded-expansion OOM DoS; range <=5.0.7, HIGH) landed on the exact versions INFRA-302 pinned. The 5.x line (minimatch@10) is bumped to 5.0.8 via overrides (same-major fix). The 1.x line (minimatch@3 under eslint/jest tooling) has NO same-major fix β 1.1.16 is the newest 1.x and is still <=5.0.7, and npm's only offered 'fix' is a breaking jest major downgrade β and the single GHSA covers both the 1.x and 5.x paths, so it is allowlisted here. brace-expansion is transitive via minimatch/glob (Node filesystem tooling), NEVER bundled into the RN runtime, and operates on trusted first-party glob patterns during dev/build/lint/test β no attacker-controlled-input path, so the DoS is unreachable. Drop GHSA-mh99 when the eslint/jest tooling upgrades minimatch@3 -> a brace-expansion line with a >5.0.7 backport (or the 1.x consumers are gone). --- INFRA-312 (2026-07-26) TRIAGE NOTE, no allowlist change. If this gate fails with the bare message `code undefined:` and nothing else, that is audit-ci failing to CLASSIFY an error from npm β it is NOT a CVE finding. A real finding always names its GHSA and prints the dependency path. Do not theorise from `code undefined:`; run the underlying command directly and read what npm actually says: `cd app && npm audit --audit-level moderate`. FIRST OCCURRENCE was a TRANSIENT npm REGISTRY INCIDENT, not a repo problem. For roughly an hour npm's legacy `/-/npm/v1/security/audits/quick` endpoint answered `400 Bad Request β Invalid package tree` for this ~1,185-package tree while happily auditing a 20-package control tree, and the newer `security/advisories/bulk` endpoint returned an undecompressed (gzip) body. It blocked every PR in the repo with zero repo changes, then recovered on its own: an untouched `development` worktree went from failing to `Passed npm security audit` with no edit of any kind. LESSON β the retry window matters. A `gh run rerun --failed` ~30 min in still failed, which made the outage look permanent and sent the investigation through eight hypotheses (all recorded on INFRA-312). Before changing ANYTHING here, re-run the audit against a clean worktree and wait longer than half an hour. Two specific red herrings to skip: npm's own 'run npm install to rebuild your package-lock.json' hint (the lockfile was byte-identical and structurally clean), and pinning a newer npm for the bulk endpoint (tried in the since-closed PR #222 β npm 11.18.0 was confirmed active in CI and failed identically). --- FEAT-313/close (2026-08-03) a FOURTH brace-expansion advisory, GHSA-rgw5-rvv9-x895 (\"DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation\", HIGH, range <=1.1.17 || 4.0.0 - 5.0.8), landed on BOTH pins simultaneously β the 1.x pin 1.1.16 (INFRA-302) and the 5.x pin 5.0.8 (FEAT-301). It blocked every PR in the repo. Unlike the previous round, a same-major fix exists on BOTH lines this time, so NOTHING was allowlisted: overrides+resolutions bumped to brace-expansion 1.1.18 and 5.0.9, which clear the range. Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 (a blanket `npm audit fix` would have bumped past it and broken `npm ci` β never run one here). REMOVED GHSA-mh99-v99m-4gvg from the allowlist in the same change: it was added by FEAT-301 solely because the 1.x line then had no same-major fix, and 1.1.18 discharges that exact documented drop-condition β audit-ci now reports it under \"Consider not allowlisting\". Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it too: it was already stale BEFORE this change and its drop-condition is the @expo/config-plugins bump, which is unrelated scope. NOTE THE PATTERN β this is the fourth brace-expansion advisory in ~2 weeks (GHSA-3jxr INFRA-302, GHSA-mh99 FEAT-301, GHSA-rgw5 here), each one re-hitting whatever version the previous round pinned. Expect a fifth; check `npm view brace-expansion versions` for a newer same-major patch BEFORE reaching for the allowlist. --- INFRA-350 (2026-08-06) a THIRD js-yaml advisory, GHSA-5p4m-2wfm-xmqj (\"Quadratic CPU consumption in !!omap resolution (3.x and 4.x) - CVE-2026-59870 fix not backported\", HIGH, range >=3.0.0 <3.15.1 || >=4.0.0 <4.3.1), landed on the current tree and blocked every PR in the repo with zero repo changes - found while closing INFRA-337, whose diff was .github/-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree. UNLIKE the two earlier js-yaml rounds this one was FIXED, NOT ALLOWLISTED: same-major patches now exist on BOTH lines (3.15.1 and 4.3.1), and every requested range is satisfied by them - @istanbuljs/load-nyc-config asks ^3.13.1, @expo/xcpretty ^4.1.0, @eslint/eslintrc ^4.1.1 - so version-keyed overrides+resolutions pin js-yaml@^3.13.1 -> 3.15.1 and js-yaml@^4.1.0 / ^4.1.1 -> 4.3.1. REMOVED GHSA-h67p-54hq-rp68 (MAINT-281) and GHSA-52cp-r559-cp3m (INFRA-302) from the allowlist in the same change: both were added solely because no same-major fix existed for the 3.x line, and 3.15.1/4.3.1 clear BOTH of their vulnerable ranges (h67p: <3.15.0 || 4.0.0-4.1.1; 52cp: <3.15.0 || 4.0.0-4.3.0), discharging their documented drop-conditions exactly as FEAT-313 retired GHSA-mh99 once brace-expansion 1.1.18 landed. Note their recorded drop-condition (\"on the next ts-jest major\") was already obsolete: ts-jest no longer appears anywhere in the js-yaml dependency tree. All three js-yaml paths are dev/build-time only (eslint config loading, @expo/xcpretty build-log formatting, babel-plugin-istanbul coverage config) and never bundled into the RN runtime, so the DoS was unreachable regardless - the fix is taken because it is available and clean, not because exposure changed. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change, and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 made. Verified npx patch-package still resolves expo-modules-jsi@56.0.7 after the install; a blanket `npm audit fix` would bump past that pin and break `npm ci` - never run one here. PATTERN: this is the third third-party advisory publication in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, this). Check `npm view versions` for a same-major patch FIRST; the allowlist is the fallback, not the reflex. --- INFRA-359 (2026-08-07) TWO HIGH image-size advisories plus one nanoid advisory became APPLICABLE to this tree and blocked every PR in the repo with zero repo changes - found while closing DEBUG-342 (PR #252), whose diff is UI-token-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 9af868c2. CORRECTION TO THE FIRST WRITE-UP OF THIS ENTRY: it originally said the advisories 'published in that window'. THEY DID NOT, and the distinction matters for how you watch for the next one. published_at is 2026-07-29 (GHSA-2v37, nanoid) and 2026-06-10 (GHSA-5p2g and GHSA-w3rx, image-size) - weeks and months earlier. What changed is updated_at: all three were updated 2026-08-07 between 20:50 and 20:55 UTC. The proof that nothing on our side moved is the scheduled CI run: the daily cron ran at 09:53 UTC on sha 9af868c2 and PASSED, and the gate failed at 22:52 UTC on THAT SAME SHA - identical tree, identical toolchain, 13 hours apart. (9af868c2 is post-INFRA-346, so the Node 20 -> 24 / npm 10 -> 11 move is also exonerated; it had already been green under the new toolchain for hours.) The advisory-side change is therefore established; its exact nature is not - the API exposes no diff, and a widened affected range, a re-review, or an npm-DB ingestion change would all look the same from here. PRACTICAL CONSEQUENCE: any watcher keyed on advisory PUBLICATION date would have missed all three of these. The only thing that reliably catches this class is resolving the ACTUAL tree against the advisory DB on a schedule - i.e. exactly what this gate does. See INFRA-362 for routing the scheduled run's failure somewhere a human sees it. SPLIT DECISION, per the standing rule that the allowlist is the fallback and not the reflex - the GitHub advisory API was queried directly for each one rather than trusting `npm audit`'s fixAvailable field. FIXED, NOT ALLOWLISTED: GHSA-2v37-7h3g-55p8 (nanoid, 'custom generators can loop indefinitely when size is zero', vulnerable <3.3.17, firstPatched 3.3.17). The tree held nanoid@3.3.12 and every requester is on the 3.x line - @react-navigation/core, /native and /routers ask ^3.3.11, postcss asks ^3.3.16 - so version-keyed overrides+resolutions pin both ranges to 3.3.18 (newest 3.x, clears the range). No 4.x/5.x nanoid exists in the tree, so the advisory's second range (>=4.0.0 <5.1.6) is not reachable here; the keys are version-scoped anyway so a future 5.x consumer will not be dragged backwards. ALLOWLISTED (both image-size, no alternative): GHSA-5p2g-fcmc-qvqq (JXL and HEIF parsers, DoS via infinite loop) and GHSA-w3rx-r6r6-pgpr (ICNS parser, DoS via infinite loop). The advisory API reports vulnerable '<= 2.0.2' with firstPatched NONE on BOTH - i.e. EVERY published version of image-size is vulnerable, including the 2.0.2 latest, so there is no same-major fix and no cross-major fix either. npm's proposed remedy is expo@53.0.27, a major DOWNGRADE from the SDK 56 this project pins, which is not a real option. image-size is a BUILD-TIME bundler dependency reached only through metro (metro asks ^1.0.2; ~10 paths via @expo/cli, @expo/metro-config, @react-native/metro-config, react-native, react-native-reanimated, react-native-screens, react-native-worklets) and is NEVER bundled into the RN runtime. Metro reads image dimensions at bundle time from the developer's own asset directory - first-party files in the repo - so the malformed-image infinite loop needs attacker-controlled input Being has no path to; worst case is a hung local or CI build, not a user-facing or data-integrity issue. Drop BOTH when metro ships a bump to an image-size line with a patched release (watch `npm view image-size versions` - as of this change 2.0.2 is latest and still vulnerable). Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 after the install, and `npx expo export --platform ios` still bundles - the nanoid override touches @react-navigation and postcss, and CI cannot catch a Metro break because ci.yml:222 records that CI does not run Metro. A blanket `npm audit fix` would bump past the expo-modules-jsi pin and break `npm ci` - never run one here. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 and INFRA-350 made. PATTERN: fourth third-party ADVISORY-SIDE EVENT in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, INFRA-350 js-yaml, this). Note they are not all the same mechanism - a registry outage, new publications, and now a metadata update to months-old advisories - which is why the detection has to be 'audit the real tree on a schedule' rather than 'watch for new CVEs'. Historical note: the daily cron ALREADY caught one of these ahead of time - the scheduled run on 2026-07-21 failed on Security + compliance with no PR in flight, which is INFRA-302 - but nothing notified anyone, so it was rediscovered the expensive way during a close. --- DEBUG-573 (2026-08-31) GHSA-vcc3-ghjq-m6fr (decode-uri-component, \"Denial of service via exponential decoding of malformed percent-encoded input\", MODERATE; range <=0.4.2, firstPatched 0.5.0) published 2026-08-31T22:10:20Z and blocked every PR in the repo with zero repo changes - found on a branch whose only diff was one word in a postinstall script, and reproduced against the untouched dependency tree. ALLOWLISTED, and for a NEW reason this file has not recorded before: the fix EXISTS and is forceable via overrides, but taking it BREAKS THE CONSUMER. decode-uri-component@0.5.0 is ESM-ONLY - package.json declares \"type\":\"module\", its exports map has NO require condition, and the tarball ships a single ESM index.js with `export default`. Its only consumer here, query-string@7.1.3, is CommonJS and does `const decodeComponent = require('decode-uri-component')` at index.js:3, so an override resolves the call to a module namespace object rather than a function. 0.4.1 is ALSO \"type\":\"module\" and still inside the <=0.4.2 range, so there is NO CJS-compatible fixed version at any point on the line. Upgrading the parent does not help either: @react-navigation/core@7.21.13 (latest at this change) still depends on query-string ^7.1.3. REACHABILITY IS STATED HONESTLY AND DIFFERS FROM EVERY OTHER ENTRY ABOVE - do not read this as another 'no attacker-controlled-input path' case. There IS an input path: query-string parses deep links via @react-navigation linking, and Being handles deep links (app/.maestro/deeplink-consent-gate.yaml, daily-loop-deeplink.yaml), so a crafted link's query string reaches the decoder. The accepted risk is bounded rather than absent: impact is a local DoS (the app hanging on the user's own device, recoverable by force-quit), it requires the user to open a hostile link, and there is no data exposure. That risk was accepted specifically BECAUSE the alternative is worse - forcing the ESM version would have made this gate green while breaking deep-link parsing, which routes through CombinedLegalGateScreen, the screen hosting the pre-consent 988 footer (INFRA-416). A green security gate over a broken crisis path is not a trade this repo takes. Drop GHSA-vcc3 when ANY of: query-string ships a CJS-compatible line off decode-uri-component; @react-navigation moves to query-string >=8; or decode-uri-component backports the fix to a 0.2.x/0.3.x CJS release (watch `npm view decode-uri-component versions` - as of this change 0.5.0 is the only patched version and it is ESM). NOT verified and deliberately not attempted here: Metro's ESM/CJS interop may or may not paper over the require(); proving that needs a Release build plus a deep-link flow run, and an unproven interop assumption on the deep-link path is not a security fix. PATTERN: fifth advisory-side event in ~6 weeks to red-gate every PR on an unchanged tree, and the first where `npm view versions` shows a patch that must NOT be taken - check the shipped tarball's module format before reaching for an override, not just the version number."
+ "$comment": "MAINT-182. First three CVEs are transitive through @expo/config-plugins@56.0.8 (build-time only, no patched stable available as of 2026-05-27). GHSA-ph9p (tmp path traversal) and GHSA-6vfc (markdown-it ReDoS) only run during prebuild/codegen. GHSA-w5hq (uuid buffer bounds) is in dev tooling. Review when Expo ships @expo/config-plugins >=56.0.9 stable: drop these GHSAs and verify `npm audit --audit-level moderate` passes. --- MAINT-281 (2026-06-15) added the last two (both quadratic-complexity DoS, no fix in range, require attacker-controlled input which Being has no path for): GHSA-h67p (js-yaml) is dev/build-time ONLY \u2014 transitive via ts-jest, @expo/cli/@expo/xcpretty, and babel-jest; fix is a breaking ts-jest major bump. GHSA-6v5v (markdown-it) is runtime via react-native-markdown-display@7, but its ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the DoS is unreachable; no patched markdown-it in react-native-markdown-display@7's range. Drop GHSA-6v5v if react-native-markdown-display upgrades markdown-it; drop GHSA-h67p on the next ts-jest major. The high-severity form-data CVE (GHSA-hmw2-7cc7-3qxx) disclosed the same day was FIXED via lockfile (npm audit fix), not allowlisted. --- MAINT-294 (2026-07-08) added GHSA-22p9-wv53-3rq4 (linkify-it 'LinkifyIt#match' scan loop, quadratic-complexity ReDoS, high; range <=5.0.0, fixAvailable:false). Direct twin of GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it@2.2.0, and its ONLY call site is LegalDocumentScreen rendering bundled first-party legal docs (legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so the quadratic blow-up needs attacker-controlled input Being has no path to. No fix in range (would need breaking markdown-it/react-native-markdown-display majors, risking the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-22p9 together with GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- INFRA-302 (2026-07-21) three new HIGH DoS advisories published to the GitHub DB. Two were FIXED via same-major overrides (not allowlisted): GHSA-395f-4hp3-45gv (shell-quote quadratic parse() DoS) -> shell-quote ^1.10.0 (was 1.8.4, single copy); GHSA-3jxr-9vmj-r5cp (brace-expansion exponential expansion DoS) -> version-keyed overrides pinning the 1.x eslint/jest tooling copies to 1.1.16 and the 5.x copy to 5.0.7. The THIRD, GHSA-52cp-r559-cp3m (js-yaml quadratic merge-key DoS; range <=3.14.2 || 4.0.0-4.2.0, fixAvailable but not in-range for the 3.x path), is allowlisted here as a direct sibling of the already-allowlisted GHSA-h67p (js-yaml, MAINT-281): the top-level js-yaml@3.14.2 consumer has NO same-major fix (all 3.x is vulnerable) and the single GHSA covers both the 3.x and 4.x paths, so a clean override is impossible without a breaking 3->4 major bump of a dev/build-time tooling dep (ts-jest, @expo/cli/@expo/xcpretty, eslintrc, babel-jest). Dev/build-time only, operates on trusted first-party source, no attacker-controlled-input path -> unreachable DoS. Drop GHSA-52cp together with GHSA-h67p on the next ts-jest major (both js-yaml). Also allowlisted GHSA-v245-v573-v5vm (linkify-it, published same window) as a direct sibling of GHSA-22p9/GHSA-6v5v: transitive via react-native-markdown-display@7 -> markdown-it@10 -> linkify-it, whose ONLY call site renders bundled first-party legal docs (LegalDocumentScreen <- legalContent.generated.ts <- docs/legal/*.md) \u2014 no remote/user markdown, so unreachable; no fix in range without breaking markdown-it/react-native-markdown-display majors (risks the expo-modules-jsi@56.0.7 patch pin). Drop GHSA-v245 with GHSA-22p9/GHSA-6v5v when react-native-markdown-display upgrades markdown-it/linkify-it past the vulnerable range. --- FEAT-301 (2026-07-25) a NEW brace-expansion advisory GHSA-mh99-v99m-4gvg (unbounded-expansion OOM DoS; range <=5.0.7, HIGH) landed on the exact versions INFRA-302 pinned. The 5.x line (minimatch@10) is bumped to 5.0.8 via overrides (same-major fix). The 1.x line (minimatch@3 under eslint/jest tooling) has NO same-major fix \u2014 1.1.16 is the newest 1.x and is still <=5.0.7, and npm's only offered 'fix' is a breaking jest major downgrade \u2014 and the single GHSA covers both the 1.x and 5.x paths, so it is allowlisted here. brace-expansion is transitive via minimatch/glob (Node filesystem tooling), NEVER bundled into the RN runtime, and operates on trusted first-party glob patterns during dev/build/lint/test \u2014 no attacker-controlled-input path, so the DoS is unreachable. Drop GHSA-mh99 when the eslint/jest tooling upgrades minimatch@3 -> a brace-expansion line with a >5.0.7 backport (or the 1.x consumers are gone). --- INFRA-312 (2026-07-26) TRIAGE NOTE, no allowlist change. If this gate fails with the bare message `code undefined:` and nothing else, that is audit-ci failing to CLASSIFY an error from npm \u2014 it is NOT a CVE finding. A real finding always names its GHSA and prints the dependency path. Do not theorise from `code undefined:`; run the underlying command directly and read what npm actually says: `cd app && npm audit --audit-level moderate`. FIRST OCCURRENCE was a TRANSIENT npm REGISTRY INCIDENT, not a repo problem. For roughly an hour npm's legacy `/-/npm/v1/security/audits/quick` endpoint answered `400 Bad Request \u2014 Invalid package tree` for this ~1,185-package tree while happily auditing a 20-package control tree, and the newer `security/advisories/bulk` endpoint returned an undecompressed (gzip) body. It blocked every PR in the repo with zero repo changes, then recovered on its own: an untouched `development` worktree went from failing to `Passed npm security audit` with no edit of any kind. LESSON \u2014 the retry window matters. A `gh run rerun --failed` ~30 min in still failed, which made the outage look permanent and sent the investigation through eight hypotheses (all recorded on INFRA-312). Before changing ANYTHING here, re-run the audit against a clean worktree and wait longer than half an hour. Two specific red herrings to skip: npm's own 'run npm install to rebuild your package-lock.json' hint (the lockfile was byte-identical and structurally clean), and pinning a newer npm for the bulk endpoint (tried in the since-closed PR #222 \u2014 npm 11.18.0 was confirmed active in CI and failed identically). --- FEAT-313/close (2026-08-03) a FOURTH brace-expansion advisory, GHSA-rgw5-rvv9-x895 (\"DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation\", HIGH, range <=1.1.17 || 4.0.0 - 5.0.8), landed on BOTH pins simultaneously \u2014 the 1.x pin 1.1.16 (INFRA-302) and the 5.x pin 5.0.8 (FEAT-301). It blocked every PR in the repo. Unlike the previous round, a same-major fix exists on BOTH lines this time, so NOTHING was allowlisted: overrides+resolutions bumped to brace-expansion 1.1.18 and 5.0.9, which clear the range. Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 (a blanket `npm audit fix` would have bumped past it and broken `npm ci` \u2014 never run one here). REMOVED GHSA-mh99-v99m-4gvg from the allowlist in the same change: it was added by FEAT-301 solely because the 1.x line then had no same-major fix, and 1.1.18 discharges that exact documented drop-condition \u2014 audit-ci now reports it under \"Consider not allowlisting\". Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it too: it was already stale BEFORE this change and its drop-condition is the @expo/config-plugins bump, which is unrelated scope. NOTE THE PATTERN \u2014 this is the fourth brace-expansion advisory in ~2 weeks (GHSA-3jxr INFRA-302, GHSA-mh99 FEAT-301, GHSA-rgw5 here), each one re-hitting whatever version the previous round pinned. Expect a fifth; check `npm view brace-expansion versions` for a newer same-major patch BEFORE reaching for the allowlist. --- INFRA-350 (2026-08-06) a THIRD js-yaml advisory, GHSA-5p4m-2wfm-xmqj (\"Quadratic CPU consumption in !!omap resolution (3.x and 4.x) - CVE-2026-59870 fix not backported\", HIGH, range >=3.0.0 <3.15.1 || >=4.0.0 <4.3.1), landed on the current tree and blocked every PR in the repo with zero repo changes - found while closing INFRA-337, whose diff was .github/-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree. UNLIKE the two earlier js-yaml rounds this one was FIXED, NOT ALLOWLISTED: same-major patches now exist on BOTH lines (3.15.1 and 4.3.1), and every requested range is satisfied by them - @istanbuljs/load-nyc-config asks ^3.13.1, @expo/xcpretty ^4.1.0, @eslint/eslintrc ^4.1.1 - so version-keyed overrides+resolutions pin js-yaml@^3.13.1 -> 3.15.1 and js-yaml@^4.1.0 / ^4.1.1 -> 4.3.1. REMOVED GHSA-h67p-54hq-rp68 (MAINT-281) and GHSA-52cp-r559-cp3m (INFRA-302) from the allowlist in the same change: both were added solely because no same-major fix existed for the 3.x line, and 3.15.1/4.3.1 clear BOTH of their vulnerable ranges (h67p: <3.15.0 || 4.0.0-4.1.1; 52cp: <3.15.0 || 4.0.0-4.3.0), discharging their documented drop-conditions exactly as FEAT-313 retired GHSA-mh99 once brace-expansion 1.1.18 landed. Note their recorded drop-condition (\"on the next ts-jest major\") was already obsolete: ts-jest no longer appears anywhere in the js-yaml dependency tree. All three js-yaml paths are dev/build-time only (eslint config loading, @expo/xcpretty build-log formatting, babel-plugin-istanbul coverage config) and never bundled into the RN runtime, so the DoS was unreachable regardless - the fix is taken because it is available and clean, not because exposure changed. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change, and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 made. Verified npx patch-package still resolves expo-modules-jsi@56.0.7 after the install; a blanket `npm audit fix` would bump past that pin and break `npm ci` - never run one here. PATTERN: this is the third third-party advisory publication in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, this). Check `npm view versions` for a same-major patch FIRST; the allowlist is the fallback, not the reflex. --- INFRA-359 (2026-08-07) TWO HIGH image-size advisories plus one nanoid advisory became APPLICABLE to this tree and blocked every PR in the repo with zero repo changes - found while closing DEBUG-342 (PR #252), whose diff is UI-token-only with a byte-identical lockfile, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 9af868c2. CORRECTION TO THE FIRST WRITE-UP OF THIS ENTRY: it originally said the advisories 'published in that window'. THEY DID NOT, and the distinction matters for how you watch for the next one. published_at is 2026-07-29 (GHSA-2v37, nanoid) and 2026-06-10 (GHSA-5p2g and GHSA-w3rx, image-size) - weeks and months earlier. What changed is updated_at: all three were updated 2026-08-07 between 20:50 and 20:55 UTC. The proof that nothing on our side moved is the scheduled CI run: the daily cron ran at 09:53 UTC on sha 9af868c2 and PASSED, and the gate failed at 22:52 UTC on THAT SAME SHA - identical tree, identical toolchain, 13 hours apart. (9af868c2 is post-INFRA-346, so the Node 20 -> 24 / npm 10 -> 11 move is also exonerated; it had already been green under the new toolchain for hours.) The advisory-side change is therefore established; its exact nature is not - the API exposes no diff, and a widened affected range, a re-review, or an npm-DB ingestion change would all look the same from here. PRACTICAL CONSEQUENCE: any watcher keyed on advisory PUBLICATION date would have missed all three of these. The only thing that reliably catches this class is resolving the ACTUAL tree against the advisory DB on a schedule - i.e. exactly what this gate does. See INFRA-362 for routing the scheduled run's failure somewhere a human sees it. SPLIT DECISION, per the standing rule that the allowlist is the fallback and not the reflex - the GitHub advisory API was queried directly for each one rather than trusting `npm audit`'s fixAvailable field. FIXED, NOT ALLOWLISTED: GHSA-2v37-7h3g-55p8 (nanoid, 'custom generators can loop indefinitely when size is zero', vulnerable <3.3.17, firstPatched 3.3.17). The tree held nanoid@3.3.12 and every requester is on the 3.x line - @react-navigation/core, /native and /routers ask ^3.3.11, postcss asks ^3.3.16 - so version-keyed overrides+resolutions pin both ranges to 3.3.18 (newest 3.x, clears the range). No 4.x/5.x nanoid exists in the tree, so the advisory's second range (>=4.0.0 <5.1.6) is not reachable here; the keys are version-scoped anyway so a future 5.x consumer will not be dragged backwards. ALLOWLISTED (both image-size, no alternative): GHSA-5p2g-fcmc-qvqq (JXL and HEIF parsers, DoS via infinite loop) and GHSA-w3rx-r6r6-pgpr (ICNS parser, DoS via infinite loop). The advisory API reports vulnerable '<= 2.0.2' with firstPatched NONE on BOTH - i.e. EVERY published version of image-size is vulnerable, including the 2.0.2 latest, so there is no same-major fix and no cross-major fix either. npm's proposed remedy is expo@53.0.27, a major DOWNGRADE from the SDK 56 this project pins, which is not a real option. image-size is a BUILD-TIME bundler dependency reached only through metro (metro asks ^1.0.2; ~10 paths via @expo/cli, @expo/metro-config, @react-native/metro-config, react-native, react-native-reanimated, react-native-screens, react-native-worklets) and is NEVER bundled into the RN runtime. Metro reads image dimensions at bundle time from the developer's own asset directory - first-party files in the repo - so the malformed-image infinite loop needs attacker-controlled input Being has no path to; worst case is a hung local or CI build, not a user-facing or data-integrity issue. Drop BOTH when metro ships a bump to an image-size line with a patched release (watch `npm view image-size versions` - as of this change 2.0.2 is latest and still vulnerable). Verified `npx patch-package` still resolves expo-modules-jsi@56.0.7 after the install, and `npx expo export --platform ios` still bundles - the nanoid override touches @react-navigation and postcss, and CI cannot catch a Metro break because ci.yml:222 records that CI does not run Metro. A blanket `npm audit fix` would bump past the expo-modules-jsi pin and break `npm ci` - never run one here. Left GHSA-ph9p-34f9-6g65 in place although audit-ci flags it: already stale before this change and its drop-condition (@expo/config-plugins bump) is unrelated scope - same call FEAT-313 and INFRA-350 made. PATTERN: fourth third-party ADVISORY-SIDE EVENT in ~3 weeks to red-gate every PR on an unchanged tree (INFRA-312 registry outage, FEAT-313 brace-expansion, INFRA-350 js-yaml, this). Note they are not all the same mechanism - a registry outage, new publications, and now a metadata update to months-old advisories - which is why the detection has to be 'audit the real tree on a schedule' rather than 'watch for new CVEs'. Historical note: the daily cron ALREADY caught one of these ahead of time - the scheduled run on 2026-07-21 failed on Security + compliance with no PR in flight, which is INFRA-302 - but nothing notified anyone, so it was rediscovered the expensive way during a close. --- DEBUG-573 (2026-08-31) GHSA-vcc3-ghjq-m6fr (decode-uri-component, \"Denial of service via exponential decoding of malformed percent-encoded input\", MODERATE; range <=0.4.2, firstPatched 0.5.0) published 2026-08-31T22:10:20Z and blocked every PR in the repo with zero repo changes - found on a branch whose only diff was one word in a postinstall script, and reproduced against the untouched dependency tree. ALLOWLISTED, and for a NEW reason this file has not recorded before: the fix EXISTS and is forceable via overrides, but taking it BREAKS THE CONSUMER. decode-uri-component@0.5.0 is ESM-ONLY - package.json declares \"type\":\"module\", its exports map has NO require condition, and the tarball ships a single ESM index.js with `export default`. Its only consumer here, query-string@7.1.3, is CommonJS and does `const decodeComponent = require('decode-uri-component')` at index.js:3, so an override resolves the call to a module namespace object rather than a function. 0.4.1 is ALSO \"type\":\"module\" and still inside the <=0.4.2 range, so there is NO CJS-compatible fixed version at any point on the line. Upgrading the parent does not help either: @react-navigation/core@7.21.13 (latest at this change) still depends on query-string ^7.1.3. REACHABILITY IS STATED HONESTLY AND DIFFERS FROM EVERY OTHER ENTRY ABOVE - do not read this as another 'no attacker-controlled-input path' case. There IS an input path: query-string parses deep links via @react-navigation linking, and Being handles deep links (app/.maestro/deeplink-consent-gate.yaml, daily-loop-deeplink.yaml), so a crafted link's query string reaches the decoder. The accepted risk is bounded rather than absent: impact is a local DoS (the app hanging on the user's own device, recoverable by force-quit), it requires the user to open a hostile link, and there is no data exposure. That risk was accepted specifically BECAUSE the alternative is worse - forcing the ESM version would have made this gate green while breaking deep-link parsing, which routes through CombinedLegalGateScreen, the screen hosting the pre-consent 988 footer (INFRA-416). A green security gate over a broken crisis path is not a trade this repo takes. Drop GHSA-vcc3 when ANY of: query-string ships a CJS-compatible line off decode-uri-component; @react-navigation moves to query-string >=8; or decode-uri-component backports the fix to a 0.2.x/0.3.x CJS release (watch `npm view decode-uri-component versions` - as of this change 0.5.0 is the only patched version and it is ESM). NOT verified and deliberately not attempted here: Metro's ESM/CJS interop may or may not paper over the require(); proving that needs a Release build plus a deep-link flow run, and an unproven interop assumption on the deep-link path is not a security fix. PATTERN: fifth advisory-side event in ~6 weeks to red-gate every PR on an unchanged tree, and the first where `npm view versions` shows a patch that must NOT be taken - check the shipped tarball's module format before reaching for an override, not just the version number. --- INFRA-576 (2026-09-02) TWO HIGH browserslist advisories plus one MODERATE @xmldom/xmldom advisory published 2026-09-01/09-02 and blocked every PR in the repo with zero repo changes - found by /b-batch --resume when MAINT-566 (a one-line placeholder copy change, PR #458) failed this gate, and confirmed repo-wide by re-running audit-ci against an untouched development worktree at 23be61e8. ALL THREE FIXED, NOTHING ALLOWLISTED - a same-major patch existed for every one, which is the standing rule's first check. GHSA-73wf-gq98-2v4g (uncaught crash / prototype write via untrusted browserslist-stats.json in normalizeStats) and GHSA-c83g-rgw3-j3cx (unbounded memory growth, no cache eviction, eventual OOM) are both browserslist <=4.28.6, firstPatched 4.28.7. The tree held a single deduped browserslist@4.28.2 and every requester is on the 4.x line - @babel/helper-compilation-targets ^4.24.0, @expo/metro-config ^4.25.0, core-js-compat ^4.28.1, update-browserslist-db peer >=4.21.0 - so an unkeyed override to ^4.28.7 satisfies all four and resolves 4.28.8. Unkeyed (the shell-quote/postcss shape) rather than version-keyed because there is one node on one major line; the caret lets the next patch flow rather than freezing at a version the next advisory re-hits, which is the brace-expansion lesson. GHSA-6gmq-8vp8-gcm6 (XML fragment injection via an invalid EntityReference.nodeName during requireWellFormed serialization) reports TWO applicable ranges with SEPARATE first-patched versions - >=0.7.0 <=0.8.14 -> 0.8.15, and >=0.9.0 <=0.9.11 -> 0.9.12 - and the tree held one node on each: @expo/plist@0.7.0 asks ^0.8.8 (0.8.13) and plist@3.1.1 asks ^0.9.10 (0.9.10). Version-keyed overrides+resolutions pin @xmldom/xmldom@^0.8.8 -> 0.8.15 and @xmldom/xmldom@^0.9.10 -> 0.9.12; each requested range admits its own patch, so neither line is dragged across a major. Note the 0.8 line does have a fix - reading only the advisory's headline range would have suggested allowlisting it. REMOVED GHSA-ph9p-34f9-6g65 (tmp path traversal, MAINT-182) from the allowlist. FEAT-313, INFRA-350 and INFRA-359 each declined to, on the recorded ground that its drop-condition was an @expo/config-plugins bump and therefore unrelated scope. That drop-condition was simply wrong: the advisory is against tmp, and the tree's only tmp is 0.2.7 via patch-package@8.0.1 - above the 0.2.6 first-patched, and reached through no @expo/config-plugins path at all. Same shape as INFRA-350 finding 'on the next ts-jest major' obsolete once ts-jest left the js-yaml tree. audit-ci had been printing 'Consider not allowlisting' for it; that hint is now clear. AC-mandated pass over the other eight allowlisted-but-still-vulnerable advisories found NO new same-major fix and changed none of them: markdown-it 10.0.0 -> firstPatched 14.2.0, linkify-it 2.2.0 -> 5.0.2, and uuid 7.0.3 (under xcode) -> 11.1.1 are all breaking major jumps; image-size still reports firstPatched NONE on every published version; and decode-uri-component 0.5.0 remains the ESM-only trap DEBUG-573 recorded. Every documented drop-condition still holds. Verified after the install that npx patch-package still resolves expo-modules-jsi@56.0.12 - a blanket `npm audit fix` would bump past that pin and break `npm ci`, never run one here - and that `npx expo export --platform ios` still bundles, since browserslist sits on the babel/metro path and ci.yml records that CI does not run Metro. PATTERN: sixth advisory-side event in ~7 weeks to red-gate every PR on an unchanged tree, and the first in that run where every finding had a clean same-major fix - the allowlist did not grow, it shrank."
}
diff --git a/app/package-lock.json b/app/package-lock.json
index ae089f32..aa693a25 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -4249,9 +4249,9 @@
"license": "ISC"
},
"node_modules/@xmldom/xmldom": {
- "version": "0.8.13",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
- "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
+ "version": "0.8.15",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
+ "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -4940,9 +4940,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.32",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
- "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
+ "version": "2.11.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -5012,9 +5012,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.2",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
- "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"funding": [
{
"type": "opencollective",
@@ -5031,11 +5031,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.10.12",
- "caniuse-lite": "^1.0.30001782",
- "electron-to-chromium": "^1.5.328",
- "node-releases": "^2.0.36",
- "update-browserslist-db": "^1.2.3"
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -5148,9 +5148,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001793",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
- "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"funding": [
{
"type": "opencollective",
@@ -5896,9 +5896,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.361",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
- "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
+ "version": "1.5.420",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
+ "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==",
"license": "ISC"
},
"node_modules/emittery": {
@@ -10837,9 +10837,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.46",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
- "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
+ "version": "2.0.54",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
+ "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -11599,9 +11599,9 @@
}
},
"node_modules/plist/node_modules/@xmldom/xmldom": {
- "version": "0.9.10",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
- "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
+ "version": "0.9.12",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz",
+ "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==",
"license": "MIT",
"engines": {
"node": ">=14.6"
@@ -13818,9 +13818,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+ "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"funding": [
{
"type": "opencollective",
diff --git a/app/package.json b/app/package.json
index 2d89aa6e..909f141b 100644
--- a/app/package.json
+++ b/app/package.json
@@ -131,7 +131,10 @@
"js-yaml@^4.1.0": "4.3.1",
"js-yaml@^4.1.1": "4.3.1",
"nanoid@^3.3.11": "3.3.18",
- "nanoid@^3.3.16": "3.3.18"
+ "nanoid@^3.3.16": "3.3.18",
+ "browserslist": "^4.28.7",
+ "@xmldom/xmldom@^0.8.8": "0.8.15",
+ "@xmldom/xmldom@^0.9.10": "0.9.12"
},
"resolutions": {
"react": "19.2.3",
@@ -145,7 +148,10 @@
"js-yaml@^4.1.0": "4.3.1",
"js-yaml@^4.1.1": "4.3.1",
"nanoid@^3.3.11": "3.3.18",
- "nanoid@^3.3.16": "3.3.18"
+ "nanoid@^3.3.16": "3.3.18",
+ "browserslist": "^4.28.7",
+ "@xmldom/xmldom@^0.8.8": "0.8.15",
+ "@xmldom/xmldom@^0.9.10": "0.9.12"
},
"dependencies": {
"@mp2ez/being-design-system": "^1.10.0",
From 9b4f3eb332f4310f98c46994045541eba9ee3b73 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Wed, 2 Sep 2026 22:17:02 -0700
Subject: [PATCH 57/90] chore: INFRA-576 correct two stale npm-audit comments
in ci.yml
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Comment-only; no step, threshold or condition changes.
The job header claimed the npm audit threshold is `high` (not `moderate`).
It has been `moderate` since MAINT-182 β .audit-ci.json carries
`"moderate": true` and the step comment 150 lines below already said so.
That stale header is what INFRA-576's own AC #5 quoted, so it had already
propagated one wrong premise into a work item.
The step comment listed GHSA-ph9p-34f9-6g65 as one of three CVEs
allowlisted "in @expo/config-plugins@56.0.8", with a drop-condition of
@expo/config-plugins >=56.0.9. The preceding commit retired that entry:
the tree's only tmp is 0.2.7 via patch-package, already above the 0.2.6
first-patched and on no @expo/config-plugins path. Rather than re-listing
the allowlist here β it has now rotted twice β point at the per-GHSA review
notes in .audit-ci.json's `$comment`, which is where they are maintained.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_015v7y8ybJj9JtcvbCLuAxuN
---
.github/workflows/ci.yml | 23 ++++++++++++-----------
1 file changed, 12 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9c9c4c53..534e26b8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -386,9 +386,11 @@ jobs:
retention-days: 14
# Security + compliance β encryption, HIPAA-adjacent checks, dependency
- # audit. npm audit threshold is `high` (not `moderate`) until the
- # 5-month-old lockfile is refreshed β see audit-roadmap.md Phase 0/3 for
- # the planned dependency refresh.
+ # audit. npm audit threshold is `moderate`, tightened from `high` by
+ # MAINT-182 once the explicit allowlist landed (this header claimed `high`
+ # until INFRA-576; .audit-ci.json has said `"moderate": true` since
+ # MAINT-182). The 5-month-old lockfile refresh is still outstanding β see
+ # audit-roadmap.md Phase 0/3.
security:
name: Security + compliance
runs-on: ubuntu-latest
@@ -544,14 +546,13 @@ jobs:
- name: npm audit
# MAINT-182: switched from raw `npm audit --audit-level=high` to
# `audit-ci` with an explicit allowlist at app/.audit-ci.json.
- # Three transitive CVEs in @expo/config-plugins@56.0.8 are
- # allowlisted (GHSA-ph9p-34f9-6g65 tmp path traversal,
- # GHSA-6vfc-qv3f-vr6c markdown-it ReDoS, GHSA-w5hq-g745-h8pq
- # uuid bounds check) β all build-time only, no patched stable
- # version available. Threshold is `moderate` (tightened from the
- # prior `high`) since the allowlist gives precise control.
- # Drop the GHSAs from .audit-ci.json when @expo/config-plugins
- # >=56.0.9 ships as stable.
+ # Threshold is `moderate` (tightened from the prior `high`) since
+ # the allowlist gives precise control. Every allowlisted GHSA
+ # carries its own review note and drop-condition in that file's
+ # `$comment` β read there, not here; this list rotted twice.
+ # INFRA-576 retired GHSA-ph9p-34f9-6g65 (tmp), which this comment
+ # had attributed to @expo/config-plugins: the tree's only tmp
+ # arrives via patch-package and is already patched.
#
# Historical context (kept for archaeology): the W3 paydown's
# `npm audit fix` cleared 11 high+critical vulns (xmldom,
From 922c8d4181d650e018e9efd58e61c47f65c28748 Mon Sep 17 00:00:00 2001
From: MP2EZ <182439403+MP2EZ@users.noreply.github.com>
Date: Fri, 4 Sep 2026 14:33:42 -0700
Subject: [PATCH 58/90] chore: INFRA-571 add a call-site rule for third-party
full-screen presenters
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rule 4 of check-modal-occlusion-guard.js. Rules 1-3 match a component we
render; this matches a CALL that hands presentation to a third party whose
component never appears in our tree. DEBUG-533 found the first instance and
no detector could see it: the guard scans app/src for JSX , so
Sentry's in node_modules is invisible, and INFRA-531's
crisis-constant-import rule matches nothing because nothing on that path
imports from features/crisis/.
DENYLIST IS FIVE NAMES, NOT THE AC'S THREE. The crisis planning pass found
two live instances of the same shape and both were verified:
- Sharing.shareAsync at ExportDataScreen.tsx:208 β always on, never
flag-gated per that file's own header, and ExportData is not in
RootCrisisButton.SUPPRESSED_ROUTES. Strictly MORE reachable than the
Sentry widget that motivated the item, whose exposure is bounded by
bug_reporting being off in the public build.
- RNIap.requestPurchase at IAPService.ts:292/307 β StoreKit and Play
Billing sheets, PurchaseOptions also unsuppressed.
Shipping the three named ones while the most reachable instance stayed
undetected would have had the guard claim a class it does not cover.
Note showScreenshotButton is NOT a root export of @sentry/react-native
7.11.0 (index.d.ts:16 has only showFeedbackWidget/showFeedbackButton/
hideFeedbackButton), so a third of the AC's denylist could never have fired
against the import shape this repo uses. Kept forward-looking, and the
header says so rather than presenting the three as that SDK's presenters.
The matcher is CALL-SHAPED, not a bare identifier. stripComments blanks
comments but NOT string literals, and ExternalErrorReporter.ts carries
showFeedbackWidget three times in five lines in three different syntactic
roles β a typeof capability probe at :619, the real call at :620, and a
logger.warn message string at :623. A bare-identifier rule would report a
log message as an occlusion site.
PRESENTER_ALLOWLIST is separate from ALLOWLIST and keyed ::.
Separate because runGuard derives staleness from a per-rule seen set, so a
merged map cross-fires. Per-symbol because a ruling that examined one call
must go stale when THAT call is removed even if another denylisted call
survives in the same file β a file-level key silently transfers a ruling
onto a call it never examined, which is DEBUG-403's failure mode reproduced
inside the fix for it. Mutation-proved: a file already allowlisted for
RNIap.requestPurchase still reds on a second denylisted symbol.
The three day-one entries do not share an evidentiary standard and say so.
DEBUG-533's is MEASURED ON DEVICE; the two new ones are REASONED FROM THE
PRESENTATION MECHANISM, NOT MEASURED. Recording an unmeasured ruling as
though measured is what DEBUG-533's own "MEASURED, NOT INFERRED" section
exists to stop. Measuring them is DEBUG-577.
Comment-only corrections, no behaviour change: ExternalErrorReporter.ts's
"a call-site rule is tracked separately" became false on merge;
ExportDataScreen.tsx asserted it "inherits the sibling CollapsibleCrisisButton
overlay" without qualification, true of the screen and false for the share
sheet's duration; ci.yml's step comment described the guard as -only.
No new npm script, no new CI step, no ci-pass gate-list sync β the call
sites are already inside SRC_ROOT and check:modal-occlusion already runs in
the Security + compliance job.
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/workflows/ci.yml | 9 +
.../safety/modalOcclusionGuard.test.ts | 180 ++++++++++++++
app/scripts/check-modal-occlusion-guard.js | 234 +++++++++++++++++-
.../services/logging/ExternalErrorReporter.ts | 8 +-
.../profile/screens/ExportDataScreen.tsx | 12 +-
5 files changed, 435 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 534e26b8..b66bbc4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -493,6 +493,15 @@ jobs:
# JS view hierarchy, so while one is open the root crisis button is not
# on screen at all β a zero-988-affordance render state.
#
+ # INFRA-571 added rule 4, which is a different SHAPE: a CALL SITE in
+ # app/src handing presentation to a third-party component that never
+ # appears in our tree (Sentry's feedback widget, expo-sharing's share
+ # sheet, the StoreKit purchase sheet). The lives in node_modules,
+ # so rules 1-3 cannot see it, and INFRA-531's crisis-constant-import rule
+ # matches nothing because nothing on those paths imports from
+ # features/crisis/. Its allowlist is keyed :: and fails on a
+ # stale entry the same way rule 2 does.
+ #
# This exists because a Protected Paths row could not catch it. The four
# sites DEBUG-406 audited live in src/core/components/ and
# src/features/insights/, neither of which is on that list and neither of
diff --git a/app/__tests__/safety/modalOcclusionGuard.test.ts b/app/__tests__/safety/modalOcclusionGuard.test.ts
index 6dba7a6b..0f2f8a94 100644
--- a/app/__tests__/safety/modalOcclusionGuard.test.ts
+++ b/app/__tests__/safety/modalOcclusionGuard.test.ts
@@ -35,8 +35,11 @@
const {
ALLOWLIST,
+ PRESENTER_ALLOWLIST,
+ THIRD_PARTY_PRESENTERS,
findAliasedModalImports,
findModalJsx,
+ findPresenterCalls,
runGuard,
stripComments,
// eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -145,3 +148,180 @@ describe('DEBUG-406 Β· RN occlusion guard', () => {
});
});
});
+
+describe('INFRA-571 Β· third-party full-screen presenter call sites', () => {
+ /**
+ * Rule 4 is a different SHAPE from rules 1-3. Those match a component we
+ * render; this matches a CALL that hands presentation to a third party whose
+ * component never appears in our tree. DEBUG-533 found the first instance and
+ * no detector could see it: the guard scans `app/src` for JSX ``, so
+ * Sentry's `` in node_modules is invisible, and INFRA-531's
+ * crisis-constant-import rule matches nothing because nothing on the path
+ * imports from `features/crisis/`. The call site is the right anchor because
+ * it is the only part of the mechanism that is ours.
+ */
+
+ describe('the matcher fires for every denylisted name', () => {
+ it.each(THIRD_PARTY_PRESENTERS)('matches a literal known-bad call to %s', (name) => {
+ const found = findPresenterCalls(`const go = () => ${name}();`);
+ expect(found).toEqual([{ name, line: 1 }]);
+ });
+
+ it('matches the member form the real defect takes', () => {
+ // ExternalErrorReporter reaches Sentry through a dynamically-assigned
+ // module handle, so the live call is a member expression on an instance
+ // field β not the `Sentry.showFeedbackWidget()` an import-aware matcher
+ // would look for.
+ expect(findPresenterCalls('this.sentryModule.showFeedbackWidget();')).toEqual([
+ { name: 'showFeedbackWidget', line: 1 },
+ ]);
+ });
+
+ it('reports the real line number of the match', () => {
+ expect(findPresenterCalls('a\nb\nawait Sharing.shareAsync(uri);')).toEqual([
+ { name: 'Sharing.shareAsync', line: 3 },
+ ]);
+ });
+
+ it('tolerates whitespace around the member access and the call parens', () => {
+ expect(findPresenterCalls('RNIap . requestPurchase ({});')).toEqual([
+ { name: 'RNIap.requestPurchase', line: 1 },
+ ]);
+ });
+ });
+
+ describe('the matcher stays silent on everything that is not a call', () => {
+ it('ignores a comment naming a presenter', () => {
+ const src = '/**\n * Never call showFeedbackWidget() here.\n */\nconst a = 1;';
+ expect(findPresenterCalls(src)).toHaveLength(0);
+ });
+
+ it('ignores a line comment naming a presenter', () => {
+ expect(findPresenterCalls('// do not use Sharing.shareAsync(...)\nconst a = 1;')).toHaveLength(0);
+ });
+
+ it('ignores the capability probe, which is not a call', () => {
+ // ExternalErrorReporter.ts:619 β `typeof x.showFeedbackWidget === 'function'`.
+ const src = "if (typeof this.sentryModule.showFeedbackWidget === 'function') { noop(); }";
+ expect(findPresenterCalls(src)).toHaveLength(0);
+ });
+
+ it('ignores the identifier inside a log-message string literal', () => {
+ // ExternalErrorReporter.ts:623 sits three lines from the real call.
+ // stripComments() blanks comments but NOT string literals, so a
+ // bare-identifier matcher would report a log message as a defect site.
+ const src = "logger.warn(LogCategory.SYSTEM, 'showFeedbackWidget failed');";
+ expect(findPresenterCalls(src)).toHaveLength(0);
+ });
+
+ it('ignores a lookalike identifier that merely starts with a denylisted name', () => {
+ expect(findPresenterCalls('showFeedbackWidgetLater();')).toHaveLength(0);
+ });
+
+ it('still finds a real call sitting beneath a warning comment', () => {
+ const src = '// never call showFeedbackWidget()\nshowFeedbackWidget();';
+ expect(findPresenterCalls(src)).toEqual([{ name: 'showFeedbackWidget', line: 2 }]);
+ });
+ });
+
+ describe('comment-stripping cannot silently reduce this to a matcher that matches nothing', () => {
+ it('leaves a real source file substantially intact after stripping', () => {
+ // The DEBUG-390 failure mode is comment-stripping plus a narrow regex
+ // producing a guard that can never fire. Assert the input the matcher
+ // actually sees is still real code, not blanks.
+ const fs = require('fs');
+ const path = require('path');
+ const abs = path.join(
+ __dirname,
+ '../../src/core/services/logging/ExternalErrorReporter.ts',
+ );
+ const stripped = stripComments(fs.readFileSync(abs, 'utf8'));
+ expect(stripped.replace(/\s/g, '').length).toBeGreaterThan(5000);
+ expect(stripped).toMatch(/showFeedbackWidget\s*\(/);
+ });
+ });
+
+ describe('the real tree', () => {
+ const result = runGuard();
+
+ it('has no denylisted presenter call outside PRESENTER_ALLOWLIST', () => {
+ expect(result.unallowedPresenters).toEqual([]);
+ });
+
+ it('has no presenter ruling that outlived the call it examined', () => {
+ // Keyed per FILE+SYMBOL, not per file: a ruling that examined one call
+ // must go stale when THAT call is removed, even if a different
+ // denylisted call survives in the same file. A file-level key would
+ // silently transfer a recorded ruling onto a call it never examined,
+ // which is DEBUG-403's failure mode reproduced inside the fix for it.
+ expect(result.stalePresenters).toEqual([]);
+ });
+
+ it('does not disturb the rules it shares a script with', () => {
+ // A merged allowlist would cross-fire: a -allowlisted file would
+ // report as a stale presenter and vice versa.
+ expect(result.unallowed).toEqual([]);
+ expect(result.stale).toEqual([]);
+ expect(result.aliased).toEqual([]);
+ });
+
+ it('allowlists ONLY the presenter call sites with a recorded ruling', () => {
+ expect(Object.keys(PRESENTER_ALLOWLIST).sort()).toEqual([
+ 'src/core/services/logging/ExternalErrorReporter.ts::showFeedbackWidget',
+ 'src/core/services/subscription/IAPService.ts::RNIap.requestPurchase',
+ 'src/features/profile/screens/ExportDataScreen.tsx::Sharing.shareAsync',
+ ]);
+ });
+
+ it('records a substantive reason for every allowlisted presenter call', () => {
+ for (const [key, reason] of Object.entries(PRESENTER_ALLOWLIST)) {
+ expect(typeof reason).toBe('string');
+ expect(reason.length).toBeGreaterThan(120);
+ expect(reason).toMatch(/DEBUG-533|INFRA-571/);
+ expect(key).toMatch(/^src\/.+::.+$/);
+ }
+ });
+
+ it('carries its own removal instruction in every ruling', () => {
+ // Whoever deletes a presenter call owns the allowlist edit. Putting the
+ // instruction in the ruling itself is what makes that mechanical rather
+ // than remembered β the CLI failure message repeats it.
+ for (const reason of Object.values(PRESENTER_ALLOWLIST)) {
+ expect(reason).toMatch(/DELETE this entry in the same commit/);
+ }
+ });
+
+ it('marks the two inferred rulings as NOT MEASURED', () => {
+ // DEBUG-533's Sentry finding was measured on device (zero
+ // `crisis-button-root` nodes in the hierarchy). These two are reasoned
+ // from the presentation mechanism only. Recording an unmeasured ruling
+ // as though measured is the failure DEBUG-533's own "MEASURED, NOT
+ // INFERRED" section was written to stop.
+ const inferred = [
+ 'src/features/profile/screens/ExportDataScreen.tsx::Sharing.shareAsync',
+ 'src/core/services/subscription/IAPService.ts::RNIap.requestPurchase',
+ ];
+ for (const key of inferred) {
+ expect(PRESENTER_ALLOWLIST[key]).toMatch(/NOT MEASURED/);
+ }
+ expect(
+ PRESENTER_ALLOWLIST['src/core/services/logging/ExternalErrorReporter.ts::showFeedbackWidget'],
+ ).toMatch(/MEASURED/);
+ });
+
+ it('documents the denylist as non-exhaustive rather than as the set', () => {
+ const fs = require('fs');
+ const path = require('path');
+ const guard = fs.readFileSync(
+ path.join(__dirname, '../../scripts/check-modal-occlusion-guard.js'),
+ 'utf8',
+ );
+ expect(guard).toMatch(/NON-EXHAUSTIVE/);
+ // `showScreenshotButton` is not a root export of @sentry/react-native
+ // 7.11.0, so it can never fire against the import shape this repo uses.
+ // It is forward-looking, and the header must not present the three
+ // Sentry names as a complete account of that SDK's presenters.
+ expect(THIRD_PARTY_PRESENTERS).toContain('showScreenshotButton');
+ });
+ });
+});
diff --git a/app/scripts/check-modal-occlusion-guard.js b/app/scripts/check-modal-occlusion-guard.js
index 13d771e8..36ba8e4a 100644
--- a/app/scripts/check-modal-occlusion-guard.js
+++ b/app/scripts/check-modal-occlusion-guard.js
@@ -55,12 +55,56 @@
* known-bad string, so comment-stripping cannot silently reduce this to a guard
* that matches nothing.
*
+ * RULE 4 β THIRD-PARTY PRESENTER CALL SITES (INFRA-571)
+ * =====================================================
+ * Rules 1-3 match a component WE render. Rule 4 matches a CALL that hands
+ * presentation to a third party whose component never appears in our tree at
+ * all. DEBUG-533 found the first instance and no detector could see it: this
+ * guard scans `app/src` for JSX ``, so Sentry's `` inside
+ * node_modules is invisible to it, and INFRA-531's crisis-constant-import rule
+ * matches nothing because nothing on that path imports from `features/crisis/`.
+ * Not "consumes a crisis constant while matching no path pattern", but "mounts a
+ * third-party component that occludes the affordance while importing nothing of
+ * ours at all."
+ *
+ * The CALL SITE is the correct anchor precisely because it is the only part of
+ * the mechanism that is ours. Extending the scan into `node_modules` is the
+ * wrong inversion: it would fire on every RN library that happens to render a
+ * `` β unbounded, and mostly on code we never mount β while still telling
+ * us nothing about whether WE reach it. `collectSourceFiles` already skips
+ * node_modules; do not widen it.
+ *
+ * The matcher is CALL-SHAPED (`name` followed by `(`), not a bare identifier.
+ * `stripComments` blanks comments but NOT string literals, and
+ * `ExternalErrorReporter.ts` carries `showFeedbackWidget` three times in three
+ * different syntactic roles within five lines β a `typeof` capability probe, the
+ * real call, and a `logger.warn` message string. A bare-identifier rule would
+ * report a log message as an occlusion site.
+ *
+ * PRESENTER_ALLOWLIST is keyed per FILE **and SYMBOL**, not per file. A ruling
+ * that examined one call must go stale when THAT call is removed, even if a
+ * different denylisted call survives in the same file β a file-level key would
+ * silently transfer a recorded ruling onto a call it never examined, which is
+ * DEBUG-403's failure mode reproduced inside the fix for it.
+ *
* WHAT THIS DOES NOT COVER
* ========================
* β’ `Alert.alert` β also native, also above the JS hierarchy, and also capable of
* occluding the button. `ReConsentScreen` forbids it for that reason. It is out
* of scope here only because it has no JSX shape to match; a separate pin would
- * need call-site analysis.
+ * need call-site analysis. It remains, by count, the LARGEST uncovered occluder
+ * surface in the tree. Rule 4 builds machinery that could eventually reach it;
+ * it does not reach it today, and this guard's green must not be read as
+ * covering it.
+ * β’ Presenters reached through one of OUR OWN wrappers. Rule 4 matches the SDK
+ * call, so a third file calling `showFeedbackForm()` β which wraps
+ * `showFeedbackWidget()` β is not a hit. The unit of audit is the third-party
+ * call, not reachability.
+ * β’ Presenters reached by bracket access (`mod['showFeedbackWidget']()`) or a
+ * deep import that renames them. Rule 3 catches the aliasing shape for
+ * ``; there is no equivalent arm for rule 4 yet.
+ * β’ Anything not on THIRD_PARTY_PRESENTERS. That list is NON-EXHAUSTIVE by
+ * design β see the note above it.
* β’ Navigation `presentation: 'modal'` / `'transparentModal'` routes. These are
* NOT an occlusion class: the root stack is a JS stack and `RootCrisisButton`
* is a later sibling of the whole navigator, so a modally-presented screen
@@ -107,12 +151,112 @@ const ALLOWLIST = {
'acceptable if it were ever mounted.',
};
+/**
+ * Third-party calls that present a full-screen surface above our JS hierarchy.
+ *
+ * THIS LIST IS **NON-EXHAUSTIVE** AND ALWAYS WILL BE. It names the presenters we
+ * have actually seen in this tree; it is not a survey of every SDK we depend on.
+ *
+ * TO ADD THE NEXT PRESENTER: append its call name here, then either fix the call
+ * site or record a ruling in PRESENTER_ALLOWLIST keyed `::`. Dotted
+ * names are matched with flexible whitespace, so `Sharing.shareAsync` catches
+ * `Sharing . shareAsync(`.
+ *
+ * A NOTE ON THE SENTRY THREE: `showScreenshotButton` is NOT a top-level export of
+ * @sentry/react-native 7.11.0 β `index.d.ts` re-exports only `showFeedbackWidget`,
+ * `showFeedbackButton` and `hideFeedbackButton`, and `showScreenshotButton` is
+ * reachable solely by a deep import. It is listed here forward-looking, so do not
+ * read these three as a complete account of that SDK's presenters.
+ *
+ * `showFeedbackWidget` occludes by full-screen sheet; `showFeedbackButton` mounts
+ * a PERSISTENT FLOATING button, whose harm is DEBUG-547-style overlap with the
+ * crisis FAB's `zIndex: 9999` β a wrong-destination tap, a crisis FALSE POSITIVE
+ * β rather than full occlusion. Both belong here; the rulings should say which.
+ */
+const THIRD_PARTY_PRESENTERS = [
+ 'showFeedbackWidget',
+ 'showFeedbackButton',
+ 'showScreenshotButton',
+ 'Sharing.shareAsync',
+ 'RNIap.requestPurchase',
+];
+
+/**
+ * Presenter call sites permitted to survive, each with the ruling that permits
+ * it. Keyed `::` β see the header on why the
+ * symbol is part of the key.
+ */
+const PRESENTER_ALLOWLIST = {
+ 'src/core/services/logging/ExternalErrorReporter.ts::showFeedbackWidget':
+ 'DEBUG-533 β DEBT REGISTER ENTRY, NOT AN EXCEPTION GRANTED. The `crisis` pass ' +
+ 'ruled this a DEBUG-406 conversion site that fails all three legs of the ' +
+ 'NotificationTimePicker exception. MEASURED ON DEVICE, not inferred: with the ' +
+ 'widget open the hierarchy carried zero `crisis-button-root` nodes. The occluder ' +
+ 'is not the RN β `Sentry.wrap(App)` mounts `FeedbackWidgetProvider` above ' +
+ '`GestureHandlerRootView`, which emits our whole app as children and THEN, as a ' +
+ 'later sibling, an inset-0 `Animated.View` animating to rgba(0,0,0,0.9). ' +
+ "`RootCrisisButton`'s zIndex 9999 cannot reach past it, because zIndex orders " +
+ "siblings and that backdrop is a later sibling of the button's ANCESTOR. So no " +
+ 'RN-level or z-order change recovers this surface; only not rendering Sentryβs ' +
+ 'component can. The remedy is tracked separately (a first-party form in ' +
+ '`rootOverlaySlot`, submitting via `Sentry.captureFeedback()`), and note that ' +
+ 'dropping `feedbackIntegration` alone does NOT disarm this path: `Sentry.wrap` ' +
+ 'mounts the provider unconditionally and `showFeedbackWidget()` re-adds the ' +
+ 'integration at call time, so removal without deleting the call merely strips ' +
+ "our showName/showEmail:false. Full ruling in prose at `showFeedbackForm()`. " +
+ 'WHEN THAT CALL IS REMOVED, DELETE this entry in the same commit.',
+
+ 'src/features/profile/screens/ExportDataScreen.tsx::Sharing.shareAsync':
+ 'INFRA-571 β REASONED FROM THE PRESENTATION MECHANISM, NOT MEASURED. Unlike the ' +
+ "DEBUG-533 entry above, no device capture backs this: expo-sharing presents a " +
+ 'native UIActivityViewController above the RN root view by the same mechanism ' +
+ 'the header documents for RN , so the 988 affordance is off screen for ' +
+ 'the sheetβs duration. Measuring it is tracked as follow-up work and this ' +
+ 'entry must be revised, not merely re-approved, once that lands (DEBUG-577). '+
+ 'Reachability is ' +
+ 'the reason it is registered rather than deferred: Profile β Privacy & Data ' +
+ 'β Export, `ExportData` is NOT in `RootCrisisButton.SUPPRESSED_ROUTES`, and this ' +
+ "file's own header records the JSON export path as ALWAYS ON, never flag-gated " +
+ 'β strictly more reachable than the Sentry widget, whose exposure is bounded by ' +
+ '`bug_reporting` being off in the public build. WHEN THAT CALL IS REMOVED, ' +
+ 'DELETE this entry in the same commit.',
+
+ 'src/core/services/subscription/IAPService.ts::RNIap.requestPurchase':
+ 'INFRA-571 β REASONED FROM THE PRESENTATION MECHANISM, NOT MEASURED, on the same ' +
+ 'basis as the expo-sharing entry above; measuring it is tracked as DEBUG-577. ' +
+ 'StoreKit (iOS) and Play Billing (Android) present the purchase sheet as a ' +
+ 'system surface above the JS hierarchy, with unbounded dwell while the user ' +
+ 'reads terms or authenticates, and the module imports nothing of ours. Reached ' +
+ 'from `PurchaseOptionsScreen` via `subscriptionStore.purchaseSubscription` on ' +
+ 'root-stack route `PurchaseOptions`, which is NOT in ' +
+ '`RootCrisisButton.SUPPRESSED_ROUTES`. Registered rather than converted because ' +
+ 'no app-side change can put anything above an OS-owned payment sheet β the same ' +
+ 'reasoning that earned NotificationTimePickerβs Android dialog its carve-out. ' +
+ 'WHEN THAT CALL IS REMOVED, DELETE this entry in the same commit.',
+};
+
/** JSX usage of the component, e.g. ``, `