componentsfeat(linkedin): the profile mark wears LinkedIn blue at rest It was muted until hovered. A glyph that only colours under the cursor reads as decoration until you happen to sweep over it, and the whole job of this one is to advertise at a glance that there is a profile behind the name. The colour comes from CONTEXT_KIND_COLORS.linkedin_chat — the same constant the context chips use — so the mark means the same thing here as everywhere else, and there is no second copy of the hex to drift. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 9, 2026, 12:36 PMhooksfix: six review findings — tenant leaks, wrong seat, lost writes The LinkedIn counterpart cache was GLOBAL. Keyed on `provider_urn` alone, one row served every customer who had met that member: the second org's refresh overwrote the first org's row and its `person_id` (a pointer into that org's own crm_person), and either org's read could hand back the other's cache. The key is now (organization_id, provider_urn), and the read and the upsert both say so. DDL applied as a targeted, idempotent ALTER — the old single-column PK is dropped only where it is still the one in place, and the org index goes with it, being redundant the moment the key leads with the org. The refresh no longer takes a seat from its caller. A profile view is billed to one seat's 50/day and reaches LinkedIn as that seat's identity, and the client was naming the org's first connected account — so in an org with several seats the panel spent the wrong rep's budget under the wrong rep's name, with nothing checking the seat had any relationship to the chat. `linkedin_chats` already records which seat holds a chat, so that is where the answer comes from. A remembered `budget` skip now expires. The client suppresses its auto-refresh entirely while one is reported, so an unexpiring reason was a permanently cold panel: a chat opened once while the day's views were down to the send reserve never refreshed on its own again, however many days later it was reopened. `last_skip_at` stamps the reason and the read honours it only inside the window it was measured in — windows read off the governor's own caps, not a number repeated here. A range or paste over two linked cells of ONE deal now writes them in order. `crm.updateConversation` holds an optimistic lock on the conversation's `updated_at`; fired together, both calls read the same timestamp, the first commits and the second matches no row and comes back CONFLICT — so half the gesture reverted with "please retry" on an edit nobody was competing for. Same deal, one at a time; different deals still in parallel. "Shared with me" is gone. It listed every row in the org owned by another PERSON, which is not a sharing relationship — there is none between users in this product; `share.ts` is public token links. `user/` is the one genuinely private scope and `documents.get` refuses to open another person's row at all, so the view could only ever show teammates' private files (10,037 rows live, titles, paths and a 300-character content preview included) and then fail to open them. A menu row that cannot be implemented honestly is worse than no menu row. The Deleted view now lists exactly what Restore accepts. It listed every org-scoped tombstone, which put 52 live rows (`#agendas/…`, `organisation/wiki/…`, `organisation/tables/…`) on screen wearing a button that `callerOwnsDocument` always refuses. The set predicate lives beside that row check so the two cannot drift apart. One deliberate gap: a `thread/` row with no owner is left out rather than listed, because its ownership lives on the thread and cannot be asked in the query. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 12:03 PMindex.tsfeat(linkedin): the counterpart card — current role, past history, recent posts (design: linkedin-counterpart-profile phase 6) The card reads free from cache on open and auto-refreshes once per chat, but only while the seat's daily budget is above the reserve; past that it offers a button instead, the same deliberate-action framing as "Find company". Unipile frequently flags no role as current, so the role line resolves current → the most recent role with no end date → the headline. That fallback is display-only: splitWorkExperience stays strict on the server, because a wrong current_company_id caches permanently while a wrong LINE just redraws. Also stops persisting a 'fresh' skip. last_skip_reason exists so a turned-away client can avoid retrying, and "everything was already cached" is not that — storing it left a merely-refreshed chat reporting a skip forever. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 8, 2026, 3:50 PMtypes.tsfeat(linkedin): LinkedIn inbox UI — top-level nav + two-pane chat view A first-class "LinkedIn" app (left-nav, /linkedin/inbox) mirroring the Mail inbox: - ChatList (left): T1 chat index with counterpart, relevance badge (✓ In CRM / New), unread counts, snippet, filters (All / In CRM / Unread / New), and a Sync button (pulls live chats via Unipile → drains → promotes). - ChatDetail (right): message history (T3 if promoted, else live Unipile), a composer that sends DMs through the native pipeline, "Save to CRM" (promote) on new chats, and a profile link. Server prep: listChats now returns the resolved counterpart (name/headline/publicId); new outbound.linkedin.messaging.sync tRPC route (sync → drain). All wired to the already-live tRPC routes. Mail-app typecheck clean for the new module. Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 14, 2026, 5:23 PM