Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ the archive; if it broke production, it belongs in `docs/incidents/`. See

## [Unreleased]

- **QA** — three regressions found in code shipped the day before, and a guard taught to read prose — backend, mobile, shared · [details](docs/changelog-archive/2026-H2.md#2026-09-12-qa-three-regressions-in-code-shipped-the-day-before)
- **Guests** — a sign-in that left your reading behind no longer tells you it was kept — web, mobile, shared · [details](docs/changelog-archive/2026-H2.md#2026-09-11-guests-the-reader-is-told-when-their-work-did-not-come-across)
- **Sentry** — a laptop's stale API key had been filing production-looking incidents for a month — backend, mobile · [details](docs/changelog-archive/2026-H2.md#2026-09-11-sentry-a-developer-machine-is-not-an-incident)
- **Genres** — opening any genre on the phone showed nothing, because the endpoint never sent the authors both clients read — backend, mobile, shared · [details](docs/changelog-archive/2026-H2.md#2026-09-11-genres-a-field-two-clients-used-and-the-server-never-sent)
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app/(auth)/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export default function LoginScreen() {
)

await signInWithTokens(result.accessToken, result.refreshToken, result.user)
warnIfNothingCarried(result.guestMergeSkipped)
if (isFreshAccount(result.user.createdAt)) trackSignUp('apple')
else trackLogin('apple')
landAfterAuth(result.user)
Expand Down
96 changes: 96 additions & 0 deletions apps/mobile/src/lib/guestMergeWarningLiterals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'

/**
* Every sign-in that can drop a guest session must say so.
*
* <p>The server has reported <c>guestMergeSkipped</c> since guest sessions shipped and no client
* read it (ADR-014). #609 finally read it — on web, in <c>authToastFor</c>, and on mobile in
* <c>app/(auth)/login.tsx</c>'s <c>warnIfNothingCarried</c>. The mobile side is a hand-written call
* at each entry point, which means it can be forgotten at one of them, and was.</p>
*
* <p>There are exactly FOUR merge entry points — <c>/auth/register</c>, <c>/auth/login</c>,
* <c>/auth/google</c>, <c>/auth/apple</c> — because those are the four the server consults
* <c>ResolveGuestToken</c> on. All four go through <c>mobilePost</c>, which attaches the guest
* bearer, so all four can produce a skip. A screen test would catch this properly; mobile has none
* and <c>vitest.config.ts</c> is scoped to <c>src/lib/**</c> on purpose, so this greps instead —
* same shape as <c>routeLiterals.test.ts</c> and <c>capabilityLiterals.test.ts</c>.</p>
*
* <p><b>KNOWN_GAPS is the finding.</b> An entry point listed there is one that currently signs the
* reader in without telling them their earlier work stayed behind. Shrinking the list is always the
* fix; growing it needs a reason on the line. It was written with one entry — Apple — found by an
* adversarial QA pass the day after #609 shipped, and emptied the same day.</p>
*/

const LOGIN_SCREEN = join(__dirname, '..', '..', 'app', '(auth)', 'login.tsx')

/** How far after the API call the warning may appear. The handlers are short; 15 is generous. */
const WINDOW = 15

const ENTRY_POINTS = [
'registerWithEmail',
'loginWithEmail',
'loginWithGoogle',
'loginWithApple',
] as const

/**
* Entry points that do NOT warn today. Each is a bug, recorded rather than hidden.
*
* <p>Empty, and it should stay that way. `loginWithApple` sat here for a few hours on 2026-09-12:
* `handleAppleSignIn` called `signInWithTokens` + `landAfterAuth` and never `warnIfNothingCarried`,
* so an iOS reader whose guest row was not merged was landed in a new account with no indication
* that their highlights, vocabulary and progress had stayed behind. The other three paths in the
* same file warned. It was written three-quarters right and shipped that way.</p>
*/
const KNOWN_GAPS: { entry: (typeof ENTRY_POINTS)[number]; why: string }[] = []

function source(): string {
return readFileSync(LOGIN_SCREEN, 'utf8')
}

function warnsWithinWindow(text: string, entry: string): boolean {
const lines = text.split('\n')
const at = lines.findIndex(l => l.includes(`authApi.${entry}(`))
if (at < 0) return false
return lines.slice(at, at + WINDOW).some(l => l.includes('warnIfNothingCarried('))
}

describe('guest-merge warning on every mobile sign-in path', () => {
it('the login screen still calls all four merge entry points', () => {
// A rename would make every assertion below pass vacuously, which is the failure mode of a
// grep-based test.
const text = source()
for (const entry of ENTRY_POINTS) {
expect(text, `authApi.${entry}( not found in login.tsx`).toContain(`authApi.${entry}(`)
}
expect(text).toContain('warnIfNothingCarried')
})

it.each(ENTRY_POINTS.filter(e => !KNOWN_GAPS.some(g => g.entry === e)))(
'%s warns when the server reports a skipped merge',
entry => {
expect(warnsWithinWindow(source(), entry)).toBe(true)
},
)

it.each(KNOWN_GAPS)(
'KNOWN GAP: $entry does not warn — $why',
({ entry }) => {
// Characterization. When the call is added, this expectation flips to `true` and the entry
// moves out of KNOWN_GAPS into the list above.
expect(warnsWithinWindow(source(), entry)).toBe(false)
},
)

it('warnIfNothingCarried is a no-op when nothing was skipped', () => {
// The field is null on the ordinary path and the guard must be the first thing in the function,
// or every successful sign-in shows an error toast.
const text = source()
const at = text.indexOf('const warnIfNothingCarried')
expect(at).toBeGreaterThan(-1)
const body = text.slice(at, at + 400)
expect(body).toMatch(/if \(!skipped\) return/)
})
})
7 changes: 6 additions & 1 deletion backend/src/Application/Agents/TutorAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ private static string BuildGoal(TutorInput input)
sb.Append($"- card {f.WordId} → {verdict} ({f.ResponseTimeMs}ms)\n");
}
sb.Append($"\nFetch the current due / weak cards again, drop cards they already got right, re-surface the ones they MISSED ");
sb.Append("with an easier context exercise (pull a real example sentence for a miss), and order up to ");
// "pull a real example sentence for a miss" lived here until 2026-09-12. It named
// get_example_sentence, which was deleted with the retrieval spine — #605 removed the
// instruction from AllowedTools and from SystemPrompt and missed this copy, which is
// sent on EVERY re-plan turn. The guard could not see it: it reflects over the tool
// list, not over the words.
sb.Append("with an easier context exercise, and order up to ");
sb.Append($"{cap} items.\n");
}
sb.Append("Only plan cards a tool returned — never invent a word or card id.");
Expand Down
35 changes: 35 additions & 0 deletions docs/changelog-archive/2026-H2.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@
Full write-ups, newest first. The one-line index lives in [`../../CHANGELOG.md`](../../CHANGELOG.md);
the incidents worth reading on their own are in [`../incidents/`](../incidents/README.md).

<a id="2026-09-12-qa-three-regressions-in-code-shipped-the-day-before"></a>

## QA — three regressions in code shipped the day before — backend, mobile, shared — 2026-09-12

An adversarial pass over the nine PRs of 2026-09-11. Everything asserted about those screens was an
assertion about a projection function, because no mobile component test exists to run. Three of the
findings were regressions introduced by those PRs — all three mine, all three in the half of the work
that had no human looking at it.

**"Mark as finished" on the phone was a silent no-op on a slow clock.** The helper added to make web
and mobile write the *same* locator also sent `updatedAt: new Date().toISOString()` — a device clock.
The server reads that as a last-write-wins guard and, when it is not newer than the stored value,
answers **200 with the row untouched**. The shelf had already flipped optimistically, so the reader
saw "finished" and the server saw nothing. Web's `markAsRead` has never sent one. The guard is right
for a queued background sync and wrong for a button; the timestamp is gone, and a test asserts the
payload does not carry one while the ordinary progress write still does.

**The Apple sign-in never warned about a dropped guest merge.** #609 wired `warnIfNothingCarried`
into three of the four merge entry points. An iOS reader whose guest row was not merged was landed in
a new account with no indication that their highlights, vocabulary and progress had stayed behind.
A literal-scanning test now covers all four, in the shape `capabilityLiterals` and `routeLiterals`
already use — because mobile has no screen test that could.

**The Tutor kept ordering a tool that does not exist.** #605 removed `get_example_sentence` from the
allowed list and from the system prompt, and missed the copy in `BuildGoal` — sent on **every**
re-plan turn. The guard added with it could not see it: it reflects over a string array, while the
instruction is prose.

So the guard learned to read. It now scans what the agent *says* for anything shaped like a tool name
and requires it to be allowed. Two false starts are worth recording: matching against *registered*
tools left the mutation green (a deleted tool is registered nowhere, which is exactly the case that
hurts), and a naive scan failed on a correct file because these prompts wrap at 110 columns and a
name lands across a concatenation. It now glues adjacent literals, and both mutations — the plain one
and the split one — turn it red.

<a id="2026-09-11-guests-the-reader-is-told-when-their-work-did-not-come-across"></a>

## Guests — the reader is told when their work did not come across — web, mobile, shared — 2026-09-11
Expand Down
70 changes: 70 additions & 0 deletions packages/shared/src/api/markProgressFinished.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { initApi } from './client'
import { markProgressFinished, updateProgress } from './readingProgress'

/**
* What "mark as finished" puts on the wire.
*
* <p>The server treats `updatedAt` as a last-write-wins guard: a timestamp that is not newer than
* the stored one makes the whole write a **no-op, answered 200 with the row untouched**
* (`UserDataEndpoints.UpsertProgress`). That is right for a queued background sync and wrong for a
* button: the reader taps "mark as finished", the shelf flips optimistically, and on a device whose
* clock runs a minute behind the server — ordinary, and invisible — nothing is saved and nothing
* says so.</p>
*
* <p>Found by an adversarial QA pass on 2026-09-12, in code shipped the day before. Web's
* `markAsRead` never sent one; the mobile helper introduced to make the two clients agree quietly
* introduced this asymmetry instead.</p>
*/
describe('markProgressFinished', () => {
const realFetch = globalThis.fetch
let fetchMock: ReturnType<typeof vi.fn>

beforeEach(() => {
fetchMock = vi.fn(async () => ({ ok: true, status: 204, text: async () => '' }) as unknown as Response)
globalThis.fetch = fetchMock as unknown as typeof fetch
initApi({
baseUrl: 'https://api.test',
getAccessToken: async () => 'token',
onUnauthorized: async () => null,
})
})

afterEach(() => {
globalThis.fetch = realFetch
vi.restoreAllMocks()
})

const bodyOf = () => JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)

it('sends no updatedAt — a device clock must not be able to veto a deliberate tap', async () => {
await markProgressFinished('e1', { chapterId: 'c1', finished: true })

expect(bodyOf()).not.toHaveProperty('updatedAt')
})

it('finishes with the end sentinel and a whole-book percent', async () => {
await markProgressFinished('e1', { chapterId: 'c1', finished: true })

expect(bodyOf()).toMatchObject({
chapterId: 'c1',
locator: '{"type":"end"}',
percent: 1,
percentUnit: 'book',
})
})

it('un-finishes with the start sentinel and zero', async () => {
await markProgressFinished('e1', { chapterId: 'c1', finished: false })

expect(bodyOf()).toMatchObject({ locator: '{"type":"start"}', percent: 0 })
})

it('leaves the ordinary progress write alone — it IS a queued sync and keeps its timestamp', async () => {
// The guard exists for this caller: reader progress is flushed from a queue, offline and out of
// order, where an older write must not overwrite a newer one.
await updateProgress('e1', { chapterId: 'c1', chapterSlug: 'ch-1', progress: 0.4 })

expect(bodyOf()).toHaveProperty('updatedAt')
})
})
10 changes: 9 additions & 1 deletion packages/shared/src/api/readingProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export function markProgressFinished(
locator: data.finished ? PROGRESS_LOCATOR_END : PROGRESS_LOCATOR_START,
percent: data.finished ? 1 : 0,
percentUnit: PERCENT_UNIT_BOOK,
updatedAt: new Date().toISOString(),
// NO `updatedAt`, deliberately — and web's markAsRead has never sent one either.
//
// The server treats it as a last-write-wins guard: a timestamp that is not newer than the
// stored one makes the whole write a no-op, answered 200 with the row untouched
// (UserDataEndpoints.UpsertProgress). That guard is for a queued background sync, where an old
// queued write must not overwrite a fresh one. This is neither queued nor background: the
// reader just tapped "mark as finished" and the shelf has already flipped optimistically. On a
// device whose clock runs a minute behind the server — ordinary, and invisible to the reader —
// the tap did nothing and said it worked.
}))
}
Loading
Loading