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