archive-from-any-folder.test.tsfix(review): archive from any tab, rename an agent in place, and eight bugs a review found Three features that were sitting uncommitted, and the findings from reviewing the whole branch against them. The features. Archive from an open thread was a no-op on every tab whose slug is not literally `inbox` — the Important and Other tabs, a custom inbox, All Mail: `moveThreadsTo` resolved to no labels, logged "No labels to modify" and never made the request. Both the toolbar button and `e` now go through one `useArchiveThread`, which closes the thread on the same frame, puts up an Undo toast, and commits. An agent's title is editable where you are looking at it, through the same `EditableText` a conversation's name uses — displayed as the pretty form, saved as the slug the harness registers it under, and refused when the slug is already taken, because the loser of a duplicate simply stops being reachable from chat. The review's findings, in rough order of what they cost: `chat_messages.citations` was still typed event-only while the frontend had already widened to an event|web union, and four `as Citation[]` casts were the only thing hiding it — a server reader of a persisted web citation got `undefined` for `quote` with no type error. The column now carries the real union and the casts are a zod parse at the boundary. Web citations never rendered while the answer streamed: `toolResultResponseProcessor` read `citations` off the SSE frame and dropped them on the way into the message, so every `[N]` fell through as grey text until the canonical reload swapped it — and never at all if the user cancelled. And the per-turn carry never reset at a turn boundary, so turn five inherited turn one's sources and any later "option [2]" became a live pill pointing at an unrelated page. `openWrittenDocumentIfPanelIdle` promised thread isolation and only checked whether the ACTIVE thread's panel was empty. A background run's document landed in front of whoever was reading a different chat, because `setSelectedArtifact` writes the active thread's slot. It takes the writing thread now, and the test that was named for this case actually exercises it. `mutateThreadContext` is a non-atomic read-modify-write of one JSON column, which was harmless while attach-on-write was gated behind a flag no call site set. It is now unconditional for every chat-turn write, and agents emit parallel tool_use blocks — so two reads saw the same context and the second UPDATE dropped the first one's chip. Now one transaction with the row locked. Dropping the `is_list_field = false` filter from the CRM field read turned it into "every value ever extracted for every deal on the page", on every table read, with eight of them able to reach a cell. Bounded in SQL with `row_number()` per (conversation, field) rather than a global LIMIT, which one talkative deal would have eaten. `readCustomFrontmatterEntries` skipped block-list continuations by their missing colon, but a grant item has one — `- roadmap: rw`, `- Bash(git status:*)` — so a hand-authored agent showed its grants twice, once correctly and once as junk labelled "not acted on", which they are. `getAgentOutputs` built its LIKE prefix from an unescaped `agentId`, and the new `classifyAgentPath` THROWS on an over-matched row rather than misfiling it, so one `_` in an id would 500 the whole Output tab. Escaped, and mapped with a guard. `createBoard` scanned org-wide for a name collision, but board uniqueness is (orgId, userId, path) — so one teammate's "New board" numbered itself off a set of boards they cannot see. `agent.create` wrote `defaultFile` unvalidated while `setDefaultFile` validated it, so a create-time `../other` was silently discarded on read. A run's notification reported the Slack channel it ASKED for rather than the one it reached, which is exactly the customer-visible-vs-private distinction the field exists to draw. Plus: `AgentDocumentView` was imported eagerly by the panel that lazy-loads TipTap to keep it out of the home bundle, and that same edge closed a real import cycle back through `AgentOutputTab → FileBrowser`; the "Default file" radio could never match its own value, so it rendered with nothing selected; two `as` casts where a `find` narrows; a settled mutation that wiped the other textarea's in-progress draft; and `mergeCanonicalPage` threw away the whole scrollback when one boundary row had no timestamp. pnpm --filter @zero/mail: 2949 jest tests, tsc -b --force clean. pnpm --filter @zero/server: 8999 vitest tests, tsc -b --force clean. pnpm deps:check: no violations. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 3, 2026, 10:49 AMdelete-draft-flow.helpers.tsfeat: draft & slack v0May 22, 2026, 1:09 AMdelete-draft-flow.test.tsxfix(inbox): make Important mean Important on every channel The channel badge and the inbox tab are orthogonal filters over one feed, and on the unified feed each half was dropping the other. Reported by <email> on 2026-08-26: 32 ARCHIVED Rippling receipts under Important, while the email-only list — which always sends the tab's real query — showed none of them. Email half: until settings/inboxes resolve, `inboxLayout` reads as the default `inbox`, no stub owns the `important` slug, `compiledQuery` goes out undefined, and the server fell back to `in:important` — a bare `label:IMPORTANT`, which is not "the Important tab" but every thread Gmail ever flagged, archive included. The feed now waits for the tab instead of asking the wrong question, and the server fallbacks may only ever narrow the inbox. Chat half: Important/Other are client-side stubs, so the record lookup always missed them and answered "this tab has no rule", letting every LinkedIn/Slack conversation contribute its whole unfiltered stream. They are mail-only by construction. Also, from the same report: - A draft row backed only by list-preview placeholders resolved no draft, so the server guard was all-falsy and `drafts.delete` never fired at all — the row vanished locally and returned on the next refetch, opening to a blank page. - Opening an email from a chat forked a fresh thread every time: `openThread` deliberately does not commit its artifact to `chatContext.items`, so the "is this the chat's own context" test was false 100% of the time. - A failed or skipped `mail.get` left the reading pane skeletoned forever with no error and no way back. And the reason none of this was visible: `cedar-prod` is at Axiom's 1025-column ceiling, so every API log line carrying an unregistered field was rejected whole while the worker's older field names kept landing. Logs get their own dataset via AXIOM_LOGS_DATASET, and ingest failures — which reject asynchronously, past the try/catch — are now reported instead of swallowed. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 26, 2026, 5:26 PMemail-tracking-indicator.test.tsxfix(mail): stop a tracked-email open from white-screening the whole app EmailTrackingIndicator called useState + useTRPC, returned null early when openCount/clickCount were 0, and only then called useQuery and three useMemos. Those counts come off the thread query, so the moment a recipient opened a tracked email the same mounted instance re-rendered past the guards and ran six hooks where React had recorded two — React #310, caught by the route error boundary, which replaced the app with the error screen. Hit <email> on staging (2026-08-17/18) and, with the same fingerprint, <email> and isabelle on production (2026-08-11); one 2026-08-05 session looped the crash 59 times. All six hooks move above the guards; the two early returns collapse into one `if (!isVisible) return null` after the last hook. Behaviour is unchanged — the query was already gated by `enabled: isOpen`, and a disabled TanStack v5 query still reports isLoading === false. ESLint had flagged all four lines the whole time; CI runs oxlint, where rules-of-hooks was off. Turned it on, scoped off for apps/server and packages (server helpers named use* are not hooks), and renamed the CompanyGTMPanel click handler `useTemplate` to `applyTemplate` so the repo lints clean. Verified against a copy of the pre-fix file: the gate reproduces the four errors and would have blocked this. Report: apps/mail/docs/bug-email-tracking-indicator-conditional-hooks.md Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 11:46 PMemptyPageKeepsPaging.test.tsxfix(mail): a search that leaves the screen stops filtering the list Isabelle's Email tab showed a full page of older mail with almost nothing from the past week, while the same screen's All-channels badge showed her whole inbox. It was not missing mail: the list was still filtered by a `thiago` search she had run three hours earlier, and mail.listThreads re-ran it 52 times on the 5-minute refetch interval with no search box on screen to say so. SearchInput held the only writer that resets searchState.value, and mail.tsx mounts it only while `isSearchOpen || !!searchQuery`. isSearchOpen resets to false on remount and ?search= is dropped by navigation, so the input unmounts in the same commit as the param change and its effect never runs for the new value — while useCedarStore, a module singleton, keeps the compiled query. The mirror moves into useSearchQuerySync(), called unconditionally next to the list it filters: the URL owns the filter, the input only edits the URL. Second defect from the same logs: her Starred split returned 0 rows on every read (over-fetch-cap truncation) and stayed there. The server hands back a short page plus a usable cursor for exactly that case, but MailList renders the inbox-zero state when the list is empty, so the VList never mounts and the auto-load effect bailed on its null-ref guard before it could page. An empty list with hasNextPage now pages ahead of that guard, and spins rather than announcing an inbox zero it cannot yet claim. Report: apps/mail/docs/bug-email-tab-shows-few-threads.md Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 12:47 PMinboxCreateRace.test.tsxfeat(onboarding): a setup flow that shows you the product while you configure it Adds `/onboarding-setup` — a sidebar-driven flow over the four surfaces a new user actually has to configure (Inbox, sub-inboxes, Tasks, Pipeline), each step paired with a live preview of the real product rather than a screenshot of it. Reachable from the profile menu as "Set up Cedar". Two inbox-creation races surfaced by clicking through the template gallery are fixed on the way, since the flow leans on that path hard: - Per-mutation `invalidateQueries` lost rows. Create #1's refetch was issued after #2 had written its optimistic row, so a server list predating #2 landed last and overwrote it — the row vanished and the template card flipped back to unselected. Now the last create to settle does the single refetch. - Two adds fired back to back both read the cache before either had written to it and claimed the same position, so a run of template clicks piled up at one index and the tab strip reordered itself on refetch. Positions are now issued from a carried-forward high-water mark. A failed create drops only its own row instead of restoring a whole snapshot, and `removeInbox` no longer swallows the rejection — callers holding their own selected-state need to hear about it, or the row returns while the card stays deselected. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 10:48 PMinboxScopeReady.test.tsxfix(drafts): stop the second normalize pass from eating the signature's blank line prepareDraftBody runs twice over the same body — once at the tool boundary, then again inside createDraftInProvider as a backstop — and the trailing-spacer strip only ever saw the region BEFORE the gmail_signature marker. So the second pass deleted the <div><br></div> applySignature had just inserted, and every agent-created provider draft shipped with its signature butted against the last line of the message. The strip now collapses that run to exactly one when a signature or quote follows, and clears it only when nothing does — which is what the doc comment already claimed the function did. Head elements go with their CONTENTS too: stripping the <style>/<title> tags alone left the stylesheet text and the document title opening the message as ordinary prose. From the same review pass over the Important-tab work: - useInboxes gains `scopeReady`. The feed gate read `isLoading`, but both of its queries are gated on the session and a DISABLED react-query reports `isLoading: false` — pending, but not fetching — so the gate stood open for precisely the window it existed to close. `isPending` is the honest question, and an error still settles it rather than spinning the feed forever. - folderToCompiledQuery reads SYSTEM_SEGMENT_BASE_LABEL instead of a second copy of the same rule. The bug being fixed WAS those two paths disagreeing, so there is now only one place to change. - getInboxFeedScope's doc block goes back onto getInboxFeedScope. - "Try again" is hidden for a client-only compose key, where mail.get 400s by construction and there is nothing to retry. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 26, 2026, 8:13 PMlabel-change-flow.test.tsxfix(types): close 205 typecheck errors by repairing real client/server drift apps/mail typechecked against a stale apps/server/dist d.ts (the server's package.json points "types" at dist), so a chunk of the reported drift was phantom. Rebuilt it and fixed the four script errors that blocked `tsc -b` in server, which is what regenerates it. The rest was genuine contract drift, fixed at the source rather than cast away — routes that dropped fields their callers still read (agentExecutions, getUpcomingCalendarEvents' conversationId, createCanvas' description), schema splits the UI never followed (conversationUsers vs conversationContacts), and enums that grew server-side but not client-side (TASK_TYPES, ActionStatus, DateFilterOperator). Also deletes code that was already dead: an unreferenced sort popover whose store API is gone, a panel importing a deleted module, the system-skill metadata UI whose mutation was removed deliberately, and a test asserting a store method that no longer exists. apps/server: 4 -> 0. apps/mail: 228 -> 23, all remaining errors being dependency resolution (zod v3/v4, react-router dev/runtime skew, tiptap v2 via novel) rather than code. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 2, 2026, 7:45 PMlist-tracking-metadata.test.tsfix(mail): unstick orphaned task labels on send, and load open tracking with the list Two defects found in the same send flow. The Cedar/Task/* chip survived a send. applyCedarMailDraftLabel puts the agent-draft marker and the task-type label on a thread together, but they do not come off together: cleanupDraftForTask strips only the marker when a task's draft is discarded or regenerated. removeCedarDraftLabels gated its whole sweep behind hasAgentDraft, so a thread that reached send in that state kept its task label forever. Prod telemetry confirms it — the sweep did its threads.get and labels.list, then issued no threads.modify at all. The two label families are now stripped independently; hasAgentDraft only gates the INBOX removal, which is the part that actually needs the proof. Task cleanup now removes the task-type label alongside the marker so the orphan state stops being created in the first place. The "opened" badge only rendered after a thread was selected, because trackingData rode on mail.get alone. listThreads now carries the same records in its preview payload (batched per page, best-effort so a lookup failure degrades to the old behaviour), and the row resolves them against a preview `latest`, whose id is the thread id rather than a message id. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 4, 2026, 5:29 PMmark-done-flow.test.tsxfix(threads): a thread marked done stays gone, whatever writes the list Round 1 treated the optimistic removal as an edit and repaired the one window it could name -- a listThreads response issued inside the archive round-trip -- by re-applying the removal once, the instant the mutation settled. That assumed a 227-380ms round-trip. Staging measures mail.markDone at up to 7381ms, and all three resurrections prod ever recorded landed 15.7-18.2s after the click, every one a fetch-response: seconds after the one-shot repair had already run, with nothing left to oppose them. So the removal becomes an invariant instead of an edit. While a thread is guarded, any write to a mail.listThreads query that reintroduces it is repaired immediately -- late fetch response, IndexedDB restore, side-inbox prefetch and fetchNextPage alike -- so we no longer have to name the guilty write. The guard is time-boxed at 60s; markActive, undo and mutation rollback all release first, so a legitimate return still lands. Suppressions now report to the server, not PostHog. The previous round's diagnostic was PostHog-only and the browser reporting the bug sends PostHog nothing -- 66 archives on staging produced zero client events -- so the fix could never be verified. mail.reportListSuppression writes a structured log readable in CloudWatch. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 24, 2026, 3:51 PMparseRecipientString.test.tsfix: single recipient boundary — validate + block with clear error, drop heuristic repair Tests-first (per /fix-bug) for every recipient edge case from this investigation, then the implementation that makes them green. Design decision (supersedes the earlier auto-repair net): parse legitimate mailbox forms to a bare addr-spec, and BLOCK genuinely-invalid recipients with a clear, specific error naming the offending value — never silently ship an undeliverable header, never silently guess which token is the address. - extractAddrSpec: drop the whitespace "@-token" repair heuristic (guessing risks misdelivery). It now only unwraps `<addr>` and trims. - InvalidRecipientError (carries the offending value) + toMailbox(sender): the single recipient boundary. Bare addr in, validated via isValidAddrSpec, {name, addr} out. MimeText alone re-adds the angle brackets / quoting / encoding, so the address reaching Gmail's header (and the SMTP envelope) is always the bare addr-spec. - google.ts normalizeRecipients (send) and parseRecipientField (createDraft) both route through this boundary and throw the clear error. - tRPC send blocks up front with TRPCError(BAD_REQUEST) naming the invalid recipient(s), surfaced to the frontend. Tests: - apps/server/tests/mail/recipient-format.test.ts — isValidAddrSpec, extractAddrSpec (incl. the jammed shape is NOT repaired), toMailbox throws on invalid, and the serialized-header bare-addr delivery guarantee. - apps/mail/.../parseRecipientString.test.ts — regression: the display name is never jammed onto the address; recipientEmails returns bare addrs only. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 7, 2026, 10:33 PMrouteInboxParity.test.tsxfeat(inbox): retire the server-merged feed, and make one email read serve both lists (design: inbox-triage phases 8 + 0) Finishes Phase 8 and lands Phase 0. RETIRED — `inbox.listItems` and `assembleInboxFeed`, with `fetchEmailItems`, `inboxCompiledQuery`, the folder→query fallbacks and `resolveEmailLabelTerms`. feed.ts drops 875→500 lines and no longer reads mail at all. Its 4 test files are RETARGETED, not deleted: scope onto `participatingChannels` and the three sources' SQL, and the real-DB files onto the live `inbox.listChannelItems` route. TWO PAGING BUGS the server-merged feed was hiding, both found by retargeting those tests onto a single source — three others had been covering for each one: * `mergeAndPage` truncated a single-source feed at page two: the sources resume at `<= cursorSortedAt`, so every page re-read its boundary row and spent the `limit + 1` that proves "more remains". It now takes a REQUIRED `sourcesSaturated` — required so a new caller cannot silently reintroduce it. * The sources ordered by `last_message_at` alone while the pager tiebreaks by id, so a LIMIT landing mid-tie-band took an arbitrary subset and the skipped rows sorted ABOVE the minted cursor — gone for good. All six source queries now order `(last_message_at DESC, <itemId> COLLATE "C" DESC)`; the collation is load-bearing, since the default one folds case and ignores punctuation and would disagree with `compareItemsDesc`. Measured: three rows vanished from a paged walk of a real feed. PHASE 0 — one email read, one route resolver: * The `headChanged` latch is per-surface. Phase 8 gave `mail.listThreads` a second caller, so a single per-connection flag meant whichever surface read first consumed the signal. `markPendingHeadChange` fans out to every surface, because the reconcile dedup key carries none and the second reader routinely joins the first's in-flight run. * `use-route-inbox.ts` resolves the active inbox from the URL for BOTH lists; the persisted `settings.activeInboxId` fallback is gone. Closes the important/other and system-folder divergences as well. * `drafts.create` awaits `surfaceDraftInInbox`, and the label join row is born with its ordering key instead of being backfilled by a second statement. compose-feed becomes self-verifying now that its diff target is gone: ordered, unique, stable-across-paging, and watermark-respecting, checked over real paging rounds and each probed against a deliberately broken history. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 9:56 PMscheduled-send-layout.test.tsrefactor(mail): stop announcing success, and fix the qualified-date parser Toasts now only fire when something went wrong. 416 success confirmations across 151 files told the user that the thing they had just watched happen had happened — the row was already gone, the draft already sent, the text already in the clipboard. Removing them took the variables and callbacks that existed only to build those messages with them. Thirteen survive, minus the success styling: the ones carrying an Undo button, where the toast is the affordance rather than a notification. Those now use the neutral toast() so they read as "here is your undo", not congratulation. toast.info / toast.warning are untouched — those report neutral or unwanted outcomes, which is not the same thing as celebrating one. Also fixes "mid august" resolving to August 1st in every date picker. chrono has no notion of early/mid/late, so it finds the month, discards the qualifier and answers the 1st — and near a month boundary forwardDate then pushed that past date into NEXT YEAR, so scheduling a send three days out landed eleven months away. resolveQualifiedPeriod runs ahead of chrono and takes the words it cannot express: named months, relative months, weeks and years, each with early / mid / late, rolling forward only when the resolved date has actually passed. And the scheduled email in a thread now renders as an ordinary message rather than a bespoke card: same MailDisplay, a Scheduled badge in the tracking-badge slot, and Edit / Delete replacing reply / reply-all / forward — there is nothing to reply to on mail that has not gone out yet. Edit unschedules before reopening, because the send payload is a frozen snapshot and would otherwise fire the old text alongside the edited one. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 12, 2026, 10:13 PMsearchFilterLifetime.test.tsxfix(mail): a search that leaves the screen stops filtering the list Isabelle's Email tab showed a full page of older mail with almost nothing from the past week, while the same screen's All-channels badge showed her whole inbox. It was not missing mail: the list was still filtered by a `thiago` search she had run three hours earlier, and mail.listThreads re-ran it 52 times on the 5-minute refetch interval with no search box on screen to say so. SearchInput held the only writer that resets searchState.value, and mail.tsx mounts it only while `isSearchOpen || !!searchQuery`. isSearchOpen resets to false on remount and ?search= is dropped by navigation, so the input unmounts in the same commit as the param change and its effect never runs for the new value — while useCedarStore, a module singleton, keeps the compiled query. The mirror moves into useSearchQuerySync(), called unconditionally next to the list it filters: the URL owns the filter, the input only edits the URL. Second defect from the same logs: her Starred split returned 0 rows on every read (over-fetch-cap truncation) and stayed there. The server hands back a short page plus a usable cursor for exactly that case, but MailList renders the inbox-zero state when the list is empty, so the VList never mounts and the auto-load effect bailed on its null-ref guard before it could page. An empty list with hasNextPage now pages ahead of that guard, and spins rather than announcing an inbox zero it cannot yet claim. Report: apps/mail/docs/bug-email-tab-shows-few-threads.md Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 12:47 PMSplitSettingsCreator.test.tsxfix(outbound/mail-ui): paint per-node validation ring + drop unsafe cast + dead code + tests - setOutboundValidation folds errors onto node.data.validation by stage id, so offending nodes light up on the canvas (the read in StageNodeShell was previously always undefined). - AddToConversationPopover drops the 'as unknown as SearchResult' double-cast (tRPC infers the type). - delete dead handleForRelation; wire clearRunOverlay into run/upload so stale per-node counts clear. - add tests for the validation-ring fold and the SplitSettingsCreator query helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 25, 2026, 3:43 PMthread-telemetry.test.tsfeat(observability): join a browser action to its server trace Diagnosing a lost send took a full day because the frontend emitted nothing the server logs could be joined to. The client already minted an X-Client-Request-Id per tRPC batch; the server read it only inside the malformed-body warning branch and dropped it on every normal request. Server: clientCorrelation() middleware puts the id on a request-scoped AsyncLocalStorage context, so every createStructuredLog during that request carries client_request_id, and both the request span and the auto-instrumented HTTP span carry cedar.client_request_id. Ids are validated rather than mangled, so nothing can inject a newline into a header or a log line. Adds POST /api/client-telemetry, authenticated with the same session as tRPC, so the browser can ship events without an Axiom token. Identity is stamped from the resolved session — a client-supplied userId survives only as inert metadata. Relays to its own dataset via AXIOM_CLIENT_DATASET, falling back to AXIOM_LOGS_DATASET then AXIOM_DATASET, following the split axiom-logger.ts already documents: a dataset holds 1025 columns and a rejected batch is rejected whole, and client telemetry adds columns fast. Client: every tRPC call is recorded against the same batch id. Aborted and network-failed requests are the point — they leave no server row at all, which is how a send that never left the browser stayed invisible. Those flush immediately with keepalive, since an abort usually means teardown. Store capture is scoped to threads.list / thread.get / thread.send only; anything else returns on one lookup. Two redaction layers: meta is derived from counts and ids only, then sanitized to scalars with a content-key denylist, so a draft body or recipient list cannot be serialized even by a careless caller. Routine 2xx batches sample at 25%; failures, non-2xx and any send are never sampled away. AXIOM_CLIENT_DATASET is deliberately NOT added to the aws runtime contract — that would make it a required Secrets Manager key and block ECS startup. Prod falls back to AXIOM_LOGS_DATASET until the key is seeded. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 12, 2026, 12:05 PMunread-filter.test.tsfeat(inbox): retire the server-merged feed, and make one email read serve both lists (design: inbox-triage phases 8 + 0) Finishes Phase 8 and lands Phase 0. RETIRED — `inbox.listItems` and `assembleInboxFeed`, with `fetchEmailItems`, `inboxCompiledQuery`, the folder→query fallbacks and `resolveEmailLabelTerms`. feed.ts drops 875→500 lines and no longer reads mail at all. Its 4 test files are RETARGETED, not deleted: scope onto `participatingChannels` and the three sources' SQL, and the real-DB files onto the live `inbox.listChannelItems` route. TWO PAGING BUGS the server-merged feed was hiding, both found by retargeting those tests onto a single source — three others had been covering for each one: * `mergeAndPage` truncated a single-source feed at page two: the sources resume at `<= cursorSortedAt`, so every page re-read its boundary row and spent the `limit + 1` that proves "more remains". It now takes a REQUIRED `sourcesSaturated` — required so a new caller cannot silently reintroduce it. * The sources ordered by `last_message_at` alone while the pager tiebreaks by id, so a LIMIT landing mid-tie-band took an arbitrary subset and the skipped rows sorted ABOVE the minted cursor — gone for good. All six source queries now order `(last_message_at DESC, <itemId> COLLATE "C" DESC)`; the collation is load-bearing, since the default one folds case and ignores punctuation and would disagree with `compareItemsDesc`. Measured: three rows vanished from a paged walk of a real feed. PHASE 0 — one email read, one route resolver: * The `headChanged` latch is per-surface. Phase 8 gave `mail.listThreads` a second caller, so a single per-connection flag meant whichever surface read first consumed the signal. `markPendingHeadChange` fans out to every surface, because the reconcile dedup key carries none and the second reader routinely joins the first's in-flight run. * `use-route-inbox.ts` resolves the active inbox from the URL for BOTH lists; the persisted `settings.activeInboxId` fallback is gone. Closes the important/other and system-folder divergences as well. * `drafts.create` awaits `surfaceDraftInInbox`, and the label join row is born with its ordering key instead of being backfilled by a second statement. compose-feed becomes self-verifying now that its diff target is gone: ordered, unique, stable-across-paging, and watermark-respecting, checked over real paging rounds and each probed against a deliberately broken history. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 9:56 PMuse-inboxes.test.tsfeat: migrate mail to query-first inboxes and drop categoriesMay 19, 2026, 9:37 AM