storefix(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 PMutilsfix(crm): "3h ago", not "Today", for everyone west of UTC `formatRelativeDate` read two different calendars. The day branch built its comparison from UTC components — deliberately, so a date-only value stored at midnight UTC does not slide back a day for US viewers — and said so in a comment ending "BOTH sides must read the same clock". The sub-24h branch three lines above it asked date-fns `isToday`, which reads the LOCAL calendar. The one branch nobody re-checked was the one the comment was about. West of UTC the two disagree for most of the evening. At 04:37Z on the 31st — 00:37 in New York — a touch from 3h41m earlier is 20:55 on the 30th locally but still the 31st in UTC. So `isToday` was false, the hour branch was skipped, and the day math, which had both on the 31st, answered "Today". A rep opening a deal after 8pm saw a meeting from that afternoon labelled as though the hour did not matter. The UTC day values are now computed once, above both branches, and the hour branch tests those instead of the ambient zone. `isToday` is gone with it. ── Why the suite could not catch this ── Every CI runner is UTC, and in UTC the two calendars cannot disagree, so all five existing tests passed on the broken code. The suite even documented the hole — "keys off the viewer's LOCAL calendar day by design, so it is not timezone-stable" — right under a test asserting "3h ago", which is exactly the assertion that is not stable. UTC is not a neutral default for date code; it is the blind spot. So there is now a second pass, west of UTC: `tests/timezone/`, run by `pnpm --filter @zero/mail test:tz` and by its own CI step, excluded from the main run so it cannot pass vacuously there. TZ has to be set before the process starts — V8 caches the zone on first use, so neither assigning `process.env.TZ` in a test nor a custom testEnvironment can move it (I tried both; a guard test in the file now fails if the zone ever stops applying). Verified the way a regression test has to be: the new suite fails on the old implementation with exactly "Expected 3h ago, Received Today", and passes on the fix. Co-Authored-By: Claude Opus 5 <<email>>Sep 8, 2026, 1:51 PMaop-refresh-indicator.test.tsfix(crm): stop AOP changes dying on CloudFront's 60s origin timeout Changing a conversation's AOP awaited the full agent refresh (Gmail re-fetch + conversation-updating LLM skill) inside the tRPC mutation. Measured in prod over 14 days that refresh runs p50 28.4s / p90 89.6s / max 176.4s, against a 60s CloudFront origin readTimeout — so 29% of AOP changes had their connection severed and surfaced in the browser as a raw "api.mail.cedarcopilot.com failed", even though the aopId write had already committed and the server-side span finished OK. The slow half now lives in services/aop/aop-change-refresh.ts and runs detached: the mutation returns once aopId and the audit row are durable. Its two steps are independently guarded, so a failing refresh no longer skips the Gmail label sync, and failures go to logError instead of vanishing into a detached promise. Backgrounding it hides ~30s of field churn from the user, so the refresh's execution row is opened in the request and its runId returned as aopRefresh.runId (handleConversationRefresh takes it via existingRunId rather than opening a rival row). The client tracks it as aopRefreshRunId to show "Updating fields…" and pull the refreshed values in when the run lands. This polls rather than listens: sseEventBus's subscription-manager has no importers, so there is no live push channel to the client for executions today. Also: the update-failure toast no longer pipes the raw fetch error into the UI — it names the field, strips hostnames, and for a dropped connection says the change may have saved rather than claiming it failed. And drops a premature conn.end() that ran mid-procedure while db was still in use (harmless only because the shared-pool handle's end() is a no-op counter decrement). Report: apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md, which also lists the other buffered routes on the same cliff — integrations.connect (18% over 60s) and crm.loadConversation (p95 60.5s) are the notable ones, both left untouched here. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 7, 2026, 2:51 PMaopChangeConsistency.test.tsxperf(crm): give conversation rows their own event projection A row draws a dot timeline and three "last <type> at" columns, and nothing else — but crm.listConversations was building up to seven nested sub-objects per event through eight LEFT JOINs to feed it. Rows now get five scalars and a flat threadId, ~3x smaller per event, so the cap can move 15 -> 60 and draw three times the history for no more bytes: measured on a 50-row page, 547KB over 641 events before, 538KB over 1,919 now. That cap was also quietly wrong. "Last meeting" was derived by scanning whatever events the list happened to ship, so a deal whose last meeting fell outside its 15 most recent rendered blank — 53 of the 304 conversations with a meeting, in the org this was measured on — while SORTING by the same field used an unbounded MAX(occurred_at) and placed it somewhere the column contradicted. The three columns now read scalars computed over the whole history, from the same expressions the ORDER BY builder already used. Splitting the projections needed guards the single shared type had been hiding: - setConversations takes an explicit `projection`. Both queries write one store key and replace wholesale, so a list refetch would otherwise empty the timeline of whatever deal was open — the same bug that previously just shrank it to 15 (client-data-architecture.md §1.2). - getEventType/getEventTitle read `eventType` rather than sniffing which sub-object came back from a join, which equated "this join returned nothing" with "this is a note" and would have greyed out every dot. - a dot click opens EventDetailDialog on an id and fetches the full event, since the row no longer carries one. Also carries the in-flight pending-conversation-field-writes mask and its tests: it edits the same setConversations body, so it cannot be split out. Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:01 PMconversation-update-error.test.tsfix: close the stale-state holes in the AOP-async and mail perf changes Review follow-ups on the detached AOP refresh and the three caches it shipped alongside. Each one leaves state that outlives the thing it describes. Orphaned execution rows. The refresh row is the only signal telling the client whether the agent is still working, and three paths could strand it in `executing` forever: handleConversationRefresh adopted `existingRunId` after its first await, so an early throw closed nothing; handleChangeAopForConversation discarded the handler's return value, and that handler reports failure by returning rather than throwing; and a deploy mid-refresh killed the work with no one to close the row. Fixed by adopting the runId up front, adding a `refreshError` channel that does not fold into `success` (the AOP change itself did commit), and draining in-flight refreshes from gracefulShutdown. Connection-record cache. Its own header said every writer of the token columns must invalidate; `clearInvalidConnectionTokens` and `resetConnection` did not, and no delete path did at all — so "Disconnect mailbox" kept serving that row's OAuth tokens for the rest of the TTL. Covered those, made the generic `updateConnection` setter invalidate unconditionally, froze the cached row (every hit shares one reference), and rewrote the header to state exactly what is and is not covered, and that it is per-process rather than fleet-wide. Skipped authorization guard. Passing `ctx.activeConnection` as `record` bypassed getConnectionRecord, and with it the only place ConnectionNotAuthorizedError is raised — which queue.ts classifies as fatal. That context is not guaranteed to carry tokens: getActiveConnection validates them on the default-connection branch but its findFirstConnection fallback does not. Take the shortcut only for a record that would have passed anyway. Gmail freshness probe. Its catch sat inside withGmailCall, eating errors before the rate-limit bookkeeping ran, so a 429 on the probe never tripped the per-mailbox breaker and the caller then spent a second request on the full get. Moved the swallow outside, and added the probe to IDEMPOTENT_GMAIL_OPERATIONS — it was the only pure read in the driver getting maxAttempts=1. Also: the update-error toast classified any message containing 502/503/504 as a dropped connection, telling users a real server rejection "may have saved". Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 10, 2026, 12:39 PMcrm-sync-href.test.tsfix: address Greptile review findings on Attio workspace-slug resolution Three issues flagged on PR #2817: 1. Wrong Attio workspace owner (P1): the self-heal fallback resolved the Attio workspace slug from conversation.userId (the conversation owner), not the user whose action actually produced the link — a shared conversation's owner may have no Attio connection, or a different one than whoever linked the deal. Added ExternalCrmLinkDetails.actingUserId, threaded through from the two Mastra tool call sites (their agent-run's own userId), and prefer it over conversation.userId when resolving the slug. 2. Hydration can stall indefinitely (P1): neither the Klavis credential fetch nor the Attio /v2/self fetch inside the conversation-hydration read path had a timeout. Bounded the whole self-heal attempt at 5s via Promise.race, plus an explicit AbortSignal on the /v2/self fetch itself (both the hydration-path and connect-time call sites). 3. Unchecked assertions (P2): replaced `as string | undefined` / `as {...}` casts on the /v2/self response and Klavis access_token with runtime narrowing (typeof checks / a shared extractWorkspaceField helper), and removed three `as ... as HydratedConversation` / `as any[]` casts that turned out to be structurally unnecessary once checked against the real target types. New tests cover the actingUserId resolution order (external-crm-events.test.ts); existing conversationTool.test.ts assertions updated for the new argument. Co-Authored-By: Claude Sonnet 5 <<email>>Sep 17, 2026, 10:40 PMfilter-sort-display.test.tsfeat(pipeline): filter the table down to deals that actually need an action The Suggested Action column could only be sorted, so a rep scanning for work still had to read past every row with nothing due. Its popover now offers the same Has/No binary the Tasks column has, mapped to the currentActionHasTasks filter the backend already supported. Two fixes fall out of wiring it: - The built-in "Deal Actions" tab stored its filter as a `before` operator with a runtime date, a shape the compute path never read — so the tab that says "show me what I have to do" only sorted. It filters now. - The due cutoff was end of tomorrow while the cell shows tasks due through end of today, so a task-type filter could surface rows whose Suggested Action cell showed no action. Both sides now share currentActionDueCutoff(). Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 16, 2026, 11:11 PMoverviewEventProjection.test.tsfix(crm): a row's dot timeline froze at the first list page it was born with The guard that stops a list refetch from emptying an open conversation's timeline asked only "does this entry already have events?". A conversation acquires events on its very first list page, so from then on every later list payload was discarded too — including the ones carrying a meeting or an email the deal had since gained. The row kept the timeline it was born with until a detail load or a full rehydration replaced it. Both sides of a list-over-list write are the overview shape, so there was never anything richer to protect there. The guard now compares the incoming projection against the STORED one and holds only in the case it was written for: overview landing on top of detail. That comparison needs the store to remember which projection wrote the events it is holding, which it did not — `metadata.projection` was passed per write and never kept, and the two payloads are not distinguishable by inspection (the overview shape is the detail shape minus its sub-objects, which is also what an empty timeline looks like). Hence `eventsProjection` on the entry. It follows the events actually stored rather than the payload that arrived, so a write the guard held still reports 'detail' and the next refetch is held too — covered by a test that fires three list refetches in a row. `conversations` is not in the store's `partialize`, so there are no persisted entries to migrate; an absent marker means a test fixture and reads as "unknown", which lets fresh data win. Reported by Greptile on #2669. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 3, 2026, 10:49 AMpendingConversationFieldWrites.test.tsperf(crm): give conversation rows their own event projection A row draws a dot timeline and three "last <type> at" columns, and nothing else — but crm.listConversations was building up to seven nested sub-objects per event through eight LEFT JOINs to feed it. Rows now get five scalars and a flat threadId, ~3x smaller per event, so the cap can move 15 -> 60 and draw three times the history for no more bytes: measured on a 50-row page, 547KB over 641 events before, 538KB over 1,919 now. That cap was also quietly wrong. "Last meeting" was derived by scanning whatever events the list happened to ship, so a deal whose last meeting fell outside its 15 most recent rendered blank — 53 of the 304 conversations with a meeting, in the org this was measured on — while SORTING by the same field used an unbounded MAX(occurred_at) and placed it somewhere the column contradicted. The three columns now read scalars computed over the whole history, from the same expressions the ORDER BY builder already used. Splitting the projections needed guards the single shared type had been hiding: - setConversations takes an explicit `projection`. Both queries write one store key and replace wholesale, so a list refetch would otherwise empty the timeline of whatever deal was open — the same bug that previously just shrank it to 15 (client-data-architecture.md §1.2). - getEventType/getEventTitle read `eventType` rather than sniffing which sub-object came back from a join, which equated "this join returned nothing" with "this is a note" and would have greyed out every dot. - a dot click opens EventDetailDialog on an id and fetches the full event, since the row no longer carries one. Also carries the in-flight pending-conversation-field-writes mask and its tests: it edits the same setConversations body, so it cannot be split out. Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:01 PMpick-active-deal.test.tsfeat(crm): make active-deal rule universal + fix lint/test regressions The "conversation follows its open deal" behavior is correct for every org — if an open deal belongs to another rep, they should own it in Cedar too — so it is no longer a per-org rollout gate. Removed CRM_ACTIVE_DEAL_RECONCILE_ENABLED (default-off) and CRM_ACTIVE_DEAL_RECONCILE_ORG_IDS (per-org allowlist); the rule now runs for all orgs by default. Retained ONE global emergency kill-switch, CRM_ACTIVE_DEAL_RECONCILE_DISABLED=true, which restores the pre-rule behavior everywhere (orphan pruning + field mappings only) without a code revert. Reverted the Tier 3 org-threading that only existed to feed the per-org gate. CI fixes: - oxlint: removed two needless object spreads (attio/copper normalizers) and wired the previously-unused applyAttioDealState into Attio getAllDeals/getDealsByIds, so Attio owner extraction is applied like Copper/Salesforce. - frontend jest: pick-active-deal.test.ts imported from 'vitest' but apps/mail runs tests under Jest — dropped the import so it uses Jest globals like its siblings. 471 server CRM tests + frontend jest green, tsc clean, oxlint --deny-warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Aug 4, 2026, 9:43 PMstrategic-overview-layout.test.tsfix(overview): the layout editor names the field the deal view actually shows The editor and the Overview tab each kept their own copy of the top-row field list, and only the tab rewrote the legacy `next_step_quality` cell to the native `status` field. So the editor labelled a cell "Next Step Quality" that every deal renders as "Stage". Both now read one shared definition, which also carries the rewrite, the `risk` label the tab was missing, and the five-slot cap. The default row is Stage, Next step, Last touch, Forecast, ACV. The seed's sixth cell overflowed a five-slot grid; `on_track` and the native `risk` stay extracted and stay selectable, they are just no longer in the default row. migrate-strategic-top-row.ts brings the 130 stored rows onto that shape. It resolves the Forecast cell per-AOP: three AOPs never had the seeded `forecast` field, only their own CRM's `forecast_category` with real options and ~200 real values, so they point the cell at the field they actually have rather than at a blank one. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 11:02 PM