commonfix: review pass — keep the ping out of the timeline, stop a rename escaping to root Three behavioural fixes found reviewing this branch, plus the deduplication the review turned up. The ambiguity ping's audit row is a dedupe key and a measurement surface, but both conversation reads returned it unfiltered and the timeline renders any action_type it does not recognise by de-underscoring it — so every pinged conversation would have shown the user a pill reading "Agent aop ambiguity ping" the moment the flag was turned on. Excluded from both reads; the ping's own dedupe query hits the table directly and is untouched. moveNode's new pure-rename path fell through to `null` when the parent row could not be loaded, and `null` there means "move to the scope root" — the same teleport the commit above it fixes, reached by a different door. It refuses now. The select-aop tool call is deliberately deferred so `conversationId` is the post-merge surviving id, which meant a throw out of setupConversationForAop lost the record of what the classifier decided — exactly the run you would open the log to diagnose. It flushes on the way out. Deduplication, all of it drift that had already caused a bug once: - resolveOrgId/getSessionOrgId now live in trpc/session-org.ts. user-tasks.ts had copied one and not the other, which is why it scoped conversation lookups to the owner while crm.ts scoped the same rows to the org — the "Untitled" task header. - The backfill script had its own copy of the LinkedIn cold-reach classifier. A backfill that classifies differently from ingest is how the folder=INMAIL check survived unnoticed; it imports the real one now. - One MAX_AOP_AMBIGUITY_ALTERNATIVES, owned by the Slack module where the four-buttons-per-row constraint actually comes from. Also: dropped the ping button's `aopId` arg, which agent_prompt's zod schema strips before the agent ever sees it (the id travels in the prompt, which is what is read); lazy-loaded TranscriptViewerModal off the root render — 10 fewer preloaded chunks and 85 KB off the critical path, though react-markdown stays on it via a second, pre-existing path through messagesSlice's eager renderer registry; and cleaned up two comments orphaned by the extractions, a sideEffects note the same PR falsified, a runbook query naming a table that does not exist, and three no-op eslint-disables. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 17, 2026, 11:58 PMcomponentsfeat(calendar,tasks): mint real Zoom meetings, and two fixes for surfaces that lied Zoom reaches a Google Calendar event through the vendor's Workspace Add-on running INSIDE Google, never through an external API client. Probed live against two accounts: all 17 calendars report `allowedConferenceSolutionTypes: ["hangoutsMeet"]`, so the `addOn` option added in ead6b26a4 was unreachable code — and had it rendered, Google would have answered "Invalid conference type value". Cedar now holds a Zoom OAuth token per user, mints the meeting through Zoom's own API, and writes the join details onto the event. That is what Superhuman does, and there is no Calendar-API shortcut they know that we do not. The driver implements the existing OAuth interface, which buys the sealed AES-GCM state, `initiateOAuth`'s URL builder, and `refreshMcpConnection`. The last is load-bearing rather than convenient: Zoom rotates the refresh token on every refresh and kills the old one, so two concurrent refreshes presenting the same stored token leave it dead — exactly the failure the single-flight coalescing there already exists for. The row is written `direct_oauth`, never `external_mcp`, because every MCP surface selects on that column and an agent enumerating tools from api.zoom.us should not be possible. Attachment goes to `location` (the only field that renders on every Google client), `description` (a fenced, idempotent dial-in block — recreating the meeting REPLACES it rather than appending beside it, which is what a "does it already contain this URL?" check gets wrong), and `conferenceData` best-effort. `addOn` is gone from both `createRequest` enums, the agent tool, the chat route, and `CalendarEvent.conferenceType`; describing an existing conference is still allowed, because that is what a Zoom meeting is by the time it reaches the event body. Inert until ZOOM_CLIENT_ID is set. Two unrelated fixes, both reported by the same customer: - "Search a deal…" searched every conversation the user owned. Cedar mints one per unrecognised correspondent, so on the reporting account 117 conversations held 6 deals and typing "gmail" returned a column of recruiters' addresses. `dealsOnly` narrows to the AOPs the user actually works — `isNoOp = false` being their own declaration that Cedar does work there — and falls back to today's behaviour for the 18 of 109 active accounts that have no workable AOP. - Every task surface splits due-now from upcoming at `endOfToday()`, read inside memos whose dependencies are all data. Nothing in them moves when the day does, so a tab left open past midnight files everything due TODAY under Upcoming, out of its group column, which then collapses into the hidden-columns rail for being empty. `useDayKey` is the missing dependency. Staged whole-tree, not session-scoped: this branch is shared with a concurrent session whose in-flight work (the board's Done lane, the calendar drag-settle animation, the onboarding setup flow, the task-groups CLI) is entangled with these changes both within files and across them — CalendarView passes props DayColumn only accepts in its uncommitted form — so a scoped commit would not have compiled. That session likewise swept this change's crm.ts and NewTaskDialog edits into a5fc7b295. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 10:35 PMconversation-inboxfix(tasks): a tick on an already-resolved task stops looping (design: zach-sept10-bugs phase 1) Zach, 2026-09-10: "i keep checking this off and it comes back." It was a closed loop. The Overview renders Due Tasks off the crm.getConversation cache and filters status === 'todo', so a row the server has already resolved can still be on screen. Ticking it reached completeTask, which refused anything not `todo`; the tRPC route THREW that refusal; the client's onError rolled the row back — restoring the very row the user was clearing. Every attempt re-rendered it, so the tick could never converge. Measured: tasks ad0fd8a4 and 5fbee194 went status='deleted' at 17:07:32-34 UTC, and the screenshot 8 seconds later still lists both as open. Two halves, because they fail independently: - completeTask treats `deleted`/`agent_deleted` as a terminal SUCCESS, the way `done` always was, and reports which state via `alreadyResolved`. The status is never rewritten — a deleted task must not come back as done — and the draft cleanup does not re-run, since deleteTask already tidied it. - The client refetches before it restores, so a row that is genuinely gone stays gone. `staleTime: 0` on that refetch is load-bearing: this same function has already optimistically written `done` into that cache, so a fetch allowed to serve cached data would read back its own write and drop tasks that are still open. A test caught exactly that. ConversationTaskRow refreshed only conversationInbox.list, leaving the other three userTasks-carrying reads stale; it now invalidates all four like the main path, and refetches on error. Headless proof, all five statuses against the real service: pnpm --filter @zero/server task-complete-probe PASS todo → completed | done → done | deleted → deleted | agent_deleted → agent_deleted | recommended → refused Two pre-existing tests asserted the old contract; the deleted one was pinning the bug itself, and is rewritten to keep the half that still holds (no draft cleanup). Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:18 PMhooksfix(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 PMlibfeat(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 PMrenderingMerge origin/staging into fix/dm-container-kind Both branches built org-shared MCP connections independently, so the merge picks ONE mechanism and layers the other work on top of it. KEPT (staging) `metadata.scope` / `organizationId` + callability.ts as the single visibility rule for every MCP surface. It resolves name collisions deterministically, narrows in SQL and enforces in JS, and it is what the discovery-based OAuth callback and the credential vault already write. DROPPED (ours) `metadata.orgShared` and the peer-scan in visible-connections.ts. That module survives as the agent-facing ADAPTER over findMcpConnections — `ownedByMe` and the explicit-grant name set — so nothing re-implements a cross-tenant WHERE. KEPT (ours) `toolPolicy` (the per-tool ceiling) and `requiresAgentGrant` (which of a person's agents reach a connection). Staging has no equivalent; they are a different axis from scope, and the types now say so. Settings loses mcp-integration-card.tsx to staging's Systems and credentials section, and the tool-permissions checklist is ported onto system-row.tsx so the ceiling is still editable. The agent's own picker keeps its shape and now reads staging's KNOWN_MCP_PROVIDERS, resolving a provider id through discovery at click time instead of from a second hard-coded catalogue. `probeMcpServer` became discovery, so the pre-save tools/list probe is `probeMcpServerTools`. Also reconciled outside MCP: the Slack Connect webhook scoping (staging's, which handles externally shared channels) keeping this branch's stronger workspace resolver; the agent-archive and subagent-write metadata stamps, chained rather than chosen between; buildAgentDocs' generic folder model with this branch's `listed` on top; and the conferencing-aware event popover with the extracted RSVP hook. Fixes carried by the merge: coach-meeting and coach-weekly had no folder and would have landed in Active; a duplicate source_workspace_id column; a duplicate import; the dead org-admin catalogue bypass. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 30, 2026, 4:15 PM1 defectsmart-inboxrefactor(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 PMthreadfix(unibox): a chat row that answers the keyboard, and an R that actually drafts R reported "No emails to select" on a LinkedIn or Slack row, and on an email row it opened the thread and left no composer. Two bugs behind one key. The list handlers resolved their target from a ref that only ever holds an EMAIL id, so a hovered chat row was invisible to every one of them — and anything reached through the bulk selection instead handed `li:<chatId>` to the Gmail driver, which issued modifies for thread ids Gmail has never heard of (x, u, i, h, #). So the handlers now resolve ONE target set and split it: Gmail ids down the optimistic email path, chat rows to the channel path, both halves every time. That deletes actOnSingleInboxId / handleHoveredInboxItem / bulkActOnSelectedInbox along with their dropFromFeed calls for email, which stopped matching anything when the feed moved to the client. r/⇧R open the chat, which is where its composer takes focus. The opener moved to module state because MailListHotkeys is mounted at the root and can never be handed openChannel as a prop; the row click goes through the same function. Spam/trash/archive collapse into Mark done, i stars (a chat row has one flag), h snoozes, and f says it cannot rather than doing something adjacent. The email half: createDraftInThread needs the thread's messages, and a row you press R on has not fetched its body yet — so the first pass fails by design and the whole thing rested on a retry that did not exist. The effect depended on threadId and a callback built from stable store actions, so nothing changed when mail.get landed. It now retries on the messages arriving, attempts once, and clears either way so a stuck action cannot fire in the next thread opened. Also: markChannelRead clears the `u` override as well as marking the source read, or a row marked unread by hand re-bolds on the refetch that call itself schedules. And the seven `as 'linkedin' | 'whatsapp' | 'slack'` casts became a real narrowing. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 2:12 PMthreadListfix(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 PM2 defectsdata.tsxrefactor: mail & drafting final movesOct 12, 2025, 6:03 PMmail.tsxfeat(tables): sort by a column, and LinkedIn/WhatsApp output cells Two additions to table documents. SORTING. `schema.sort` orders the rows the way the column's TYPE orders — currency numerically, a date by the instant (in the column's own `format`), a select by its DECLARED option order, `_status` by the fan-out lifecycle. One comparator in `table-sort.ts`, shared by the grid and by `read`, so a sorted read and the sorted table cannot disagree. The sort is a VIEW: `tableRows` keeps document order, so `_id` addressing, the ordinal forms and every row's CRDT identity are untouched. Two consequences fall out of that — the row drag handle is withdrawn while sorted, and `read` reports rather than silently adopts a declared sort, because an ordinal resolves against document order on every backend. `table-sort.ts` also absorbs the tolerant cell readers, which existed twice (the Excel writer's and the grid's) and had already drifted on the checkbox vocabulary. LINKEDIN / WHATSAPP OUTPUT CELLS. Both kinds were declared but their payload fell into `GenericCellOutput`, which holds the words and nothing that says who they are for. Real payloads now, addressable by an existing chat OR by a person with no chat yet — which is the lead-list case the column is for. `canSendCellOutput` becomes `sendableCellOutput`, which RESOLVES a cell into a payload whose fields are required, removing three non-null assertions from the send site. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:17 PM1 defectnavbar.tsxrefactor: mail & drafting final movesOct 12, 2025, 6:03 PM