linkedin-counterpart-profile.md57.8 KBView on GitHub
# LinkedIn counterpart profile in the empty chat panel

## 1) Introduction — goal, present state, future state

When a rep is talking to someone on LinkedIn, the chat column beside the conversation should tell them who that person is — the role and company they hold now, where they worked before, and what they have been posting — so the reply can be written without leaving the app. Today that column resolves to the `channelChat` empty-chat surface, whose only body section is `conversationCards`, and those cards self-hide whenever the chat is not linked to a deal; for a LinkedIn DM that is the common case, so the panel renders empty. Nothing about the counterpart is stored either: every LinkedIn person in `crm_person` carries a name, a headline and an obfuscated `ACoAA…` profile URL, while `current_title`, `current_company_id` and `enrichment` are empty, because the seat's `profile_view` budget is capped at 50/day and there has never been a table to cache a fetched profile into. This design adds that cache, a cache-first read that costs nothing, a governed refresh that spends at most one profile view per person and only while the day's budget is comfortably under cap, and a `linkedinProfile` section in the empty-chat registry that renders current role, past history and recent posts whenever the open artifact is a `linkedin_chat`.

## 2) Present state

### 2.1 Architecture diagram

```text
  user opens a LinkedIn chat
            │
            ▼
  ┌───────────────────────────┐        ┌──────────────────────────────┐
  │ selectedArtifact          │        │  AppShell (2 columns)        │
  │  kind: 'linkedin_chat'    │───────►│  [ context ] [ ChatColumn ]  │
  │  id:   <unipile chatId>   │        └──────────────┬───────────────┘
  └───────────┬───────────────┘                       │
              │ artifactToContext()                   ▼
              ▼                            ┌────────────────────────┐
     { kind: 'channelChat',                │ EmbeddedCedarChat      │
       id: <chatId> }                      │  emptyChatSurface      │
              │                            └──────────┬─────────────┘
              │ computeEmptyChatSurface()              │ resolveEmptyChatLayout()
              ▼                                        ▼
        'channelChat'  ────────────────►  EMPTY_CHAT_LAYOUTS.channelChat
                                                       │
                                          sections: [conversationCards,
                                                     tabbedPrompts]
                                                       │
                                                       ▼
                                       ┌────────────────────────────────┐
                                       │ EmptyStateSuggestions          │
                                       │  conversationCardsNode         │
                                       │   → <ThreadContextCards/>      │
                                       │   → null when no deal link  ◄──┼── the empty panel
                                       └────────────────────────────────┘

  data available, none of it reaching the panel:
  ┌─────────────────────────────┐   ┌──────────────────────────┐   ┌───────────────────────┐
  │ linkedin_chats              │   │ linkedin_chat_           │   │ crm_person            │
  │  chat_id (PK)               │──►│   participants           │──►│  display_name         │
  │  person_id ──────────────────────  participant_urn         │   │  headline             │
  │  unipile_account_id         │   │  public_identifier       │   │  linkedin_provider_id │
  └─────────────────────────────┘   │  name / headline / photo │   │  current_title = NULL │
                                    └──────────────────────────┘   │  enrichment   = {}    │
                                                                   └───────────────────────┘
  never called for display:
     UnipileClient.getUserProfile(sections:'*')  → work_experience[]   (governed: 50/day)
     UnipileClient.listUserPosts(providerId)     → recent posts        (ungoverned)
```

### 2.2 Step-by-step walkthrough

1. **Artifact resolution** — `getDisplayArtifact` at [messagesSlice.ts:344](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts) returns the active thread's open artifact.
   - Receives: nothing (store read)
   - Data after this step:
     ```json
     { "kind": "linkedin_chat", "id": "ghtYXSs8XKqonGeH1aO9gQ" }
     ```

2. **Context projection** — `artifactToContext` at [resolveContext.ts:13](apps/mail/modules/ux/layout/resolveContext.ts) collapses `slack_thread`, `linkedin_chat` and `whatsapp_chat` into one context kind, deliberately: all three open the same `ChannelThreadView`.
   - Calls: nothing further
   - Data after this step — **the provider is now lost**:
     ```json
     { "kind": "channelChat", "id": "ghtYXSs8XKqonGeH1aO9gQ" }
     ```

3. **Surface projection** — `computeEmptyChatSurface` at [selectEmptyChatSurface.ts:15](apps/mail/modules/ux/layout/selectEmptyChatSurface.ts) maps the context kind to the empty-chat surface. Its `selectEmptyChatSurface` wrapper must keep returning a primitive — consumers subscribe with no equality function, and `selectContext` allocates a fresh object per call.
   - Data after this step:
     ```ts
     'channelChat'
     ```

4. **Layout resolution** — `resolveEmptyChatLayout` at [emptyChatLayout.ts:952](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts), called from [EmbeddedCedarChat.tsx:641](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx). The `channelChat` builder at [emptyChatLayout.ts:893](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts) is a thread surface, so it passes through untouched.
   - Data after this step:
     ```json
     {
       "surface": "channelChat",
       "heading": "",
       "sections": [
         { "id": "conversationCards", "kind": "conversationCards" },
         { "id": "suggestions", "kind": "tabbedPrompts", "title": "Suggestions" }
       ]
     }
     ```

5. **Body render** — `EmptyStateSuggestions` at [EmptyStateSuggestions.tsx:58](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmptyStateSuggestions.tsx). `tabbedPrompts` is filtered out (it is rendered by `EmptyChatSuggestionsBar` above the composer), leaving one body section.
   - `conversationCards` branch returns `<React.Fragment>{conversationCardsNode}</React.Fragment>`, or `null` when the node is absent.

6. **Cards** — `conversationCardsNode` is supplied at [EmbeddedCedarChat.tsx:1268](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx) as `<ThreadContextCards/>`, which renders the deal's Overview / Next steps. For a LinkedIn DM with `conversation_id IS NULL` it renders nothing.
   - **Result: the body is empty.** This is the bug.

7. **What the seat could answer, but is never asked** — `UnipileClient.getUserProfile` at [unipile-client.ts:591](apps/server/src/services/integrations/linkedin/unipile-client.ts), with `sections: '*'`, returns the full profile.
   - Data:
     ```json
     {
       "provider_id": "ACoAACDNrG0B9JTDvxf_lfJiVpc08IW8yLpwB2o",
       "public_identifier": "molly-pilch",
       "headline": "Senior Account Executive @ ClassPass",
       "occupation": "Senior Account Executive",
       "location": "New York, NY",
       "work_experience": [
         { "company": "ClassPass", "company_id": "1866484", "position": "Senior Account Executive", "current": true,  "start": "2022-03" },
         { "company": "Yelp",      "company_id": "17876",   "position": "Account Executive",        "current": false, "start": "2019-01", "end": "2022-02" }
       ]
     }
     ```

8. **Posts, likewise unasked** — `UnipileClient.listUserPosts` at [unipile-client.ts:357](apps/server/src/services/integrations/linkedin/unipile-client.ts), reachable through `fetchRecentPosts` at [messaging.ts:1835](apps/server/src/services/integrations/linkedin/messaging.ts) and exposed as `outbound.linkedin.posts` at [linkedin.ts:71](apps/server/src/trpc/routes/linkedin.ts). Ungoverned — no rate window applies.
   - Data:
     ```json
     { "posts": [ { "id": "urn:li:activity:73…", "text": "We just shipped…", "date": "2026-08-29T14:02:00Z", "reaction_counter": 41, "comment_counter": 6 } ] }
     ```

9. **Why nothing is cached** — `resolveCounterpartCompany` at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts) spends the one governed `profile_view` this codebase allows per person, banks only `provider_id` plus three scalars, discards `work_experience`, and then refuses to ever look again (`if (person?.linkedinProviderId) return null`) — stated in its own comment as "one paid view per person, ever; otherwise a profile that simply lacks a hard company id drains the 50/day cap forever."
   - Live data confirming the outcome, across every LinkedIn chat counterpart sampled:
     ```json
     { "display_name": "Molly Pilch", "headline": "Senior Account Executive @ ClassPass",
       "linkedin_url": "https://www.linkedin.com/in/ACoAACDNrG0B9JTDvxf_lfJiVpc08IW8yLpwB2o",
       "current_title": null, "current_company_id": null, "enrichment": {}, "enriched_at": null }
     ```

10. **The gate the refresh must pass** — `governedSend` at [messaging.ts:1581](apps/server/src/services/integrations/linkedin/messaging.ts) applies `assertSeatHealthy` then `rateCheckAndIncrement` at [governor.ts:76](apps/server/src/services/integrations/linkedin/governor.ts), against `DEFAULT_CAPS.profile_view` at [governor.ts:34](apps/server/src/services/integrations/linkedin/governor.ts).
    - Data:
      ```ts
      DEFAULT_CAPS.profile_view // [{ windowKind: 'day', cap: 50 }]
      rateCheckAndIncrement(...) // → { allowed: true, remaining: 37, cap: 50 }  — but it INCREMENTS
      ```

## 3) Designed state

### 3.1 Architecture diagram

```text
  user opens a LinkedIn chat
            │
            ▼
  selectLinkedinChatId(store) ──► chatId | null      (primitive — safe bare subscribe)
            │                            │
            │ null → card renders nothing (Slack / WhatsApp / no artifact)
            ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │ EMPTY_CHAT_LAYOUTS.channelChat                                   │
  │   sections: [ linkedinProfile,  ◄── NEW, first                   │
  │               conversationCards,                                 │
  │               tabbedPrompts ]                                    │
  └──────────────────────────┬───────────────────────────────────────┘
                             ▼
            EmptyStateSuggestions( linkedinProfileNode )
                             ▼
            <LinkedInCounterpartCard chatId />
                             │
        ┌────────────────────┴─────────────────────┐
        │ query                                    │ mutation (auto or button)
        ▼                                          ▼
 messaging.counterpartProfile          messaging.refreshCounterpartProfile
   (FREE — cache only)                    (governed — at most 1 profile_view)
        │                                          │
        ▼                                          ▼
 readCounterpartProfile()                 refreshCounterpartProfile()
        │                                          │
        │                          ┌───────────────┴──────────────┐
        │                          │ no reserve — a rep is waiting │
        │                          │ (the 64/day cap is the limit) │
        │                          └───────┬──────────────┬───────┘
        │                          allowed │              │ cap reached
        │                                  ▼              ▼
        │                    governedSend(profile_view)  skip profile,
        │                    → getUserProfile('*')       posts only,
        │                                  │             reason:'budget'
        │                                  ▼
        │                    listUserPosts (ungoverned, always)
        │                                  │
        │                                  ▼
        │                    splitWorkExperience() ── current + history
        │                                  │
        ▼                                  ▼
  ┌───────────────────────────────────────────────────────────┐
  │ linkedin_person_profiles          (NEW cache table)       │
  │   (organization_id, provider_urn) PK · person_id · work_… │
  │   posts jsonb · profile_fetched_at · posts_fetched_at     │
  └───────────────────────┬───────────────────────────────────┘
                          │ banks the paid view, same as resolveCounterpartCompany
                          ▼
              crm_person.current_company_id / current_title  (coalesce, never clobber)
```

### 3.2 Step-by-step walkthrough

1. **Provider-aware selector** — `selectLinkedinChatId` in [selectChannelChat.ts](apps/mail/modules/ux/layout/selectChannelChat.ts) (new). Reads `getDisplayArtifact()` and returns the id only for `kind === 'linkedin_chat'`. Returns a **primitive**, honouring the gotcha documented on `selectEmptyChatSurface` — `getDisplayArtifact()` allocates a fresh object per call, so returning anything derived from it would re-render the chat on every store change.
   - Receives: `CedarStore`
   - Data after this step:
     ```ts
     'ghtYXSs8XKqonGeH1aO9gQ' | null
     ```

2. **Registry section** — `EMPTY_CHAT_LAYOUTS.channelChat` at [emptyChatLayout.ts:893](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts) gains a `linkedinProfile` section ahead of `conversationCards`. The person is the specific thing about this panel; the deal cards already self-hide.
   - Data after this step:
     ```json
     { "surface": "channelChat", "heading": "",
       "sections": [
         { "id": "linkedinProfile",  "kind": "linkedinProfile" },
         { "id": "conversationCards","kind": "conversationCards" },
         { "id": "suggestions",      "kind": "tabbedPrompts", "title": "Suggestions" }
       ] }
     ```

3. **Body render** — `EmptyStateSuggestions` at [EmptyStateSuggestions.tsx:58](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmptyStateSuggestions.tsx) gains a `linkedinProfileNode` prop and a branch that mirrors `conversationCards` exactly: absent node ⇒ `null`. `topAlign` also takes it, so the card starts at the top rather than floating mid-panel.

4. **Node supplied by the host** — [EmbeddedCedarChat.tsx:1268](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx) passes `<LinkedInCounterpartCard/>`, matching the pattern already used for `agendaNode`, `calendarRailNode` and `conversationCardsNode`. The registry stays a plain data module with no React and no import into `modules/linkedin`.

5. **Cache read** — `readCounterpartProfile` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) (new). Resolves the chat's non-self participant from `linkedin_chat_participants` (free — `participant_urn`, `public_identifier`, `name`, `headline`, `profile_picture_url` are already mirrored), then left-joins the cache. **Never calls Unipile.**
   - Receives: `{ organizationId, chatId }`
   - Data after this step (cold cache — what every counterpart looks like today):
     ```json
     { "urn": "ACoAACDNrG0B9JTDvxf_lfJiVpc08IW8yLpwB2o", "personId": "…", "name": "Molly Pilch",
       "headline": "Senior Account Executive @ ClassPass", "publicIdentifier": null,
       "current": null, "history": [], "posts": [],
       "profileFetchedAt": null, "postsFetchedAt": null, "stale": true }
     ```

6. **Budget probe** — `rateRemaining` in [governor.ts](apps/server/src/services/integrations/linkedin/governor.ts) (new export). A read-only sibling of `rateCheckAndIncrement` at [governor.ts:76](apps/server/src/services/integrations/linkedin/governor.ts) — same window arithmetic via `windowStartFor` at [governor.ts:49](apps/server/src/services/integrations/linkedin/governor.ts), but it does not increment. Required because the reserve gate must be able to ask "how much is left?" without spending one to find out.
   - Data after this step:
     ```json
     { "allowed": true, "remaining": 37, "cap": 50 }
     ```

7. **Reserve gate, pointing at the background** — `withinBackgroundProfileReserve` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts). Pure, unit-tested: `remaining > cap * PROFILE_VIEW_INTERACTIVE_RESERVE` with `PROFILE_VIEW_INTERACTIVE_RESERVE = 0.25`. **The panel does not call it.** It is consulted by the BACKGROUND consumers of this budget — `resolveCounterpartCompany` on chat sync, `deobfuscateViaSeat`, the sequencer's `linkedin_view` warm-up step, `signalProfileViewers` — which stop at 16 of the 64/day cap so a rep opening cold chats still has views to spend.

   The gate originally pointed the other way, and the justification did not survive contact with the data: sends book `dm` / `connect` / `inmail`, which are different buckets, so the panel was being starved to protect a queue that was not competing with it. What WAS competing was background enrichment, which drained the seat to exactly the panel's stop line (29 of 50) most mornings and left it there.
   - Data after this step:
     ```ts
     withinBackgroundProfileReserve({ remaining: 37, cap: 64 }) // true  — crawler may spend
     withinBackgroundProfileReserve({ remaining: 16, cap: 64 }) // false — the rest is the rep's
     ```

8. **Governed profile fetch** — inside `refreshCounterpartProfile`, via `governedSend` at [messaging.ts:1581](apps/server/src/services/integrations/linkedin/messaging.ts) with `action: 'profile_view'`, calling `getUserProfile(urn, { sections: '*' })` at [unipile-client.ts:591](apps/server/src/services/integrations/linkedin/unipile-client.ts). `notify` stays at its `false` default: reading a chat you are already in must not register as a profile visit.
   - Branches:
     - cache cold ⇒ fetch, with no reserve consulted — the cap is the only ceiling
     - `force` absent and `profile_fetched_at` within `PROFILE_TTL_DAYS` (90) ⇒ skip, `skipped: 'fresh'`
     - the day's 64 are spent ⇒ `governedSend` denies, `skipped: 'budget'`; posts still proceed
     - `assertSeatHealthy` / provider error throws ⇒ caught, `skipped: 'unavailable'`; posts still proceed

9. **Work-experience split** — `splitWorkExperience` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts). Current = the entry flagged `current`, or the sole entry when there is exactly one; history = every other entry, newest start first. This is the identical "hard signal only" rule `resolveCounterpartCompany` states at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts) — so it is **extracted here and imported back** by that function rather than copied, since a second opinion about which row is the current one is precisely what would drift. `company` is nullable: an entry carrying LinkedIn's hard org id but no display name is kept, because that id is what `resolveCounterpartCompany` links on — the card, not the normalizer, is what declines to render a role it cannot name.

    **Corrected against the live API.** `start`/`end` come back as `M/D/YYYY` (`'6/1/2023'`), not the `YYYY-MM` this design assumed, so the original `localeCompare` sort was lexicographic — it placed `4/1/2017` above `3/1/2022` and `12/1/2016` above `10/1/2020`. Ordering now goes through a `Date.parse`-based key, applied on WRITE and again on READ so rows cached by an earlier build still come out newest-first. Unipile also frequently flags NO entry as `current`, so `current: null` is the common case, not the edge case — the card falls back to the most recent role with no end date, and then to the headline.
   - Data after this step:
     ```json
     { "current": { "company": "ClassPass", "companyId": "1866484", "title": "Senior Account Executive", "start": "2022-03" },
       "history": [ { "company": "Yelp", "companyId": "17876", "title": "Account Executive", "start": "2019-01", "end": "2022-02" } ] }
     ```

10. **Ungoverned posts fetch** — `listUserPosts(urn, { limit: 10 })` at [unipile-client.ts:357](apps/server/src/services/integrations/linkedin/unipile-client.ts), filtered to posts this member actually authored (`author.id === urn` or `reposted_by.id === urn`), capped at 5 stored. Runs on its own TTL (`POSTS_TTL_DAYS = 7`) and on its own try/catch, so a profile skipped for budget still gets fresh posts.

    **Corrected against the live API.** The design assumed `date` was a timestamp and that a permalink had to be built from `social_id`. Neither is true: `date` is a RELATIVE string (`'6mo'`, `'2yr'`), the ISO instant lives in `parsed_datetime` (and `repost_parsed_datetime` for a reshare — the instant that matters for "their activity"), and the provider hands back a real `share_url`. Sorting on `date` put an 8-month-old post above a 6-month-old one; `postedAt` now carries the ISO instant and the sort parses it.
    - Data after this step:
      ```json
      [ { "postId": "urn:li:activity:73…", "text": "We just shipped…", "postedAt": "2026-08-29T14:02:00Z",
          "url": "https://www.linkedin.com/feed/update/urn:li:activity:73…",
          "reactions": 41, "comments": 6, "isRepost": false } ]
      ```

11. **Cache upsert** — one `INSERT … ON CONFLICT (organization_id, provider_urn) DO UPDATE` into `linkedin_person_profiles`. Each half updates only the columns it fetched, so a posts-only refresh cannot blank a profile that was skipped.

    Only `'budget'` and `'unavailable'` are PERSISTED to `last_skip_reason`. `'fresh'` is reported for the call that
    skipped but never stored — the column 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 `skipped: 'fresh'` forever, which reads like a
    failure and is not one.

12. **Bank the paid view** — the same coalescing write `resolveCounterpartCompany` already performs at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts): `crm_person.current_title` / `headline` / `location` gap-filled, and `current_company_id` resolved through `resolveCompanyByLinkedinHardKey` when the current entry carries a `company_id`. A view spent on the panel therefore also repairs the CRM link that `resolveCounterpartCompany` gave up on — which is the second reason this feature is worth its budget.

13. **tRPC surface** — two procedures on `outbound.linkedin.messaging` in [linkedin.ts](apps/server/src/trpc/routes/linkedin.ts), beside the existing `enrichCounterparty` at [linkedin.ts:178](apps/server/src/trpc/routes/linkedin.ts):
    ```ts
    counterpartProfile:        privateProcedure.input({ chatId }).query(…)          // free
    refreshCounterpartProfile: privateProcedure.input({ chatId, force? }).mutation(…)  // seat comes off the chat
    ```

14. **Auto-fetch, client side** — `useCounterpartProfile` in [use-counterpart-profile.ts](apps/mail/modules/linkedin/hooks/use-counterpart-profile.ts) (new). Runs the free query on open; fires the refresh mutation exactly once per `chatId` per session when the read comes back `stale`, and reconciles the result into the query cache. When the server answers `skipped: 'budget'` the hook stops auto-firing and the card offers a "Load profile" button instead — the manual escape hatch, mirroring the deliberate-button precedent set by "Find company" in [ChatDetail.tsx:95](apps/mail/modules/linkedin/components/ChatDetail.tsx).

15. **Card render** — `LinkedInCounterpartCard` in [LinkedInCounterpartCard.tsx](apps/mail/modules/linkedin/components/LinkedInCounterpartCard.tsx) (new). Header (avatar · name · headline · profile link), then a **Current role** line, a **Past** disclosure, and up to three **Recent posts**. The role line resolves `current` → the most recent role with no `end` date → the headline, and the headline is rendered once: when it stands in as the role line it is dropped from the header subtitle. Per `CLAUDE.md`, the Past disclosure animates its height (`AnimatePresence` + `initial/animate/exit` on `height`, `overflow-hidden`, one shared tween — never a spring, which would bounce every row below it), the toggle and each post row carry `cursor-pointer`, padding lives inside the hit area, and the card is one surface — no bordered box nested inside the panel.

### 3.3 Schema

Full schema:

```sql
-- NEW. Applied as targeted additive DDL (never `pnpm db:push` — see CLAUDE.md §4a),
-- as apps/server/src/db/migrations/linkedin_person_profiles.sql
CREATE TABLE IF NOT EXISTS linkedin_person_profiles (
  provider_urn         text        NOT NULL,                 -- Unipile member urn (ACoAA…); the stable LinkedIn identity
  organization_id      uuid        NOT NULL,                 -- → organizations.id
  person_id            uuid,                                 -- → crm_person.id; NULL until the chat resolves a person
  public_identifier    text,                                 -- real vanity slug once a profile fetch reveals it
  name                 text,
  headline             text,
  occupation           text,
  location             text,
  profile_picture_url  text,
  work_experience      jsonb       NOT NULL DEFAULT '[]'::jsonb,  -- WorkExperienceEntry[], newest start first
  posts                jsonb       NOT NULL DEFAULT '[]'::jsonb,  -- CachedPost[], max 5, newest first
  profile_fetched_at   timestamptz,                          -- NULL = never fetched; drives PROFILE_TTL_DAYS (90)
  posts_fetched_at     timestamptz,                          -- independent TTL; drives POSTS_TTL_DAYS
  last_skip_reason     text,                                 -- budget|unavailable|NULL — why the last refresh did less
  last_skip_at         timestamptz,                          -- WHEN that reason was recorded; it expires with its rate window
  created_at           timestamptz NOT NULL DEFAULT now(),
  updated_at           timestamptz NOT NULL DEFAULT now(),
  -- The ORG leads the key. One LinkedIn member can be in chats with several of our customers,
  -- and the row belongs to the org whose seat paid for the view: on `provider_urn` alone the
  -- second org's refresh overwrote the first org's row — `person_id` included, which points at
  -- that org's own `crm_person` — and either org's read could return the other's cache.
  PRIMARY KEY (organization_id, provider_urn)
);
CREATE INDEX IF NOT EXISTS linkedin_person_profiles_person_idx ON linkedin_person_profiles (person_id);
-- No org index: the primary key already leads with organization_id.
```

```ts
// NEW — apps/server/src/db/outbound-schema.ts (drizzle mirror of the DDL above)
export const linkedinPersonProfiles = pgTable(
  'linkedin_person_profiles',
  {
    providerUrn: text('provider_urn').notNull(),
    organizationId: uuid('organization_id').notNull(),
    personId: uuid('person_id'),
    publicIdentifier: text('public_identifier'),
    name: text('name'),
    headline: text('headline'),
    occupation: text('occupation'),
    location: text('location'),
    profilePictureUrl: text('profile_picture_url'),
    workExperience: jsonb('work_experience').$type<WorkExperienceEntry[]>().notNull().default([]),
    posts: jsonb('posts').$type<CachedPost[]>().notNull().default([]),
    profileFetchedAt: timestamp('profile_fetched_at', { withTimezone: true }),
    postsFetchedAt: timestamp('posts_fetched_at', { withTimezone: true }),
    lastSkipReason: text('last_skip_reason'),
    lastSkipAt: timestamp('last_skip_at', { withTimezone: true }),
    createdAt: now(),
    updatedAt: updated(),
  },
  (t) => [
    primaryKey({ columns: [t.organizationId, t.providerUrn] }),
    index('linkedin_person_profiles_person_idx').on(t.personId),
  ],
);

// NEW — apps/server/src/db/outbound-schema.ts (beside ChannelChatDraft, the existing precedent:
// a jsonb row shape is DEFINED in the schema file and imported FROM it by services — the reverse
// would make the db layer depend on the services layer and cycle once person-profile.ts imports
// the table.)
/** One role, from Unipile `work_experience[]`. `companyId` is LinkedIn's hard org id. */
export type WorkExperienceEntry = {
  company: string | null;       // nullable: an id-only entry still links a company, so it is kept
  companyId: string | null;     // LinkedIn org id; the hard key resolveCompanyByLinkedinHardKey wants
  companyUrl: string | null;
  title: string | null;
  location: string | null;
  start: string | null;         // 'YYYY-MM' as the provider gives it; not parsed
  end: string | null;           // null while current
  isCurrent: boolean;
};

/** One post, normalized from `UnipilePost` (unipile-client.ts:61). Also defined in the schema. */
export type CachedPost = {
  postId: string;
  text: string;
  postedAt: string | null;      // ISO
  url: string | null;
  reactions: number;
  comments: number;
  isRepost: boolean;
};

/** Why a refresh did less than everything. `null` = it did everything. */
export type RefreshSkipReason = 'budget' | 'fresh' | 'unavailable';

/** The panel's whole payload. Returned by BOTH the free read and the refresh. */
export interface CounterpartProfile {
  chatId: string;
  urn: string;
  personId: string | null;
  publicIdentifier: string | null;
  name: string | null;
  headline: string | null;
  location: string | null;
  profilePictureUrl: string | null;
  current: WorkExperienceEntry | null;   // null when no hard `current` signal — never guessed
  history: WorkExperienceEntry[];
  posts: CachedPost[];
  profileFetchedAt: string | null;
  postsFetchedAt: string | null;
  stale: boolean;                        // true ⇒ the client may auto-refresh once
  skipped: RefreshSkipReason | null;     // 'budget' ⇒ the card shows "Load profile" instead
}

// NEW — apps/server/src/services/integrations/linkedin/governor.ts
/** Read-only sibling of rateCheckAndIncrement (governor.ts:76) — does NOT consume a slot. */
export async function rateRemaining(
  db: DB, unipileAccountId: string, action: RateAction,
): Promise<{ allowed: boolean; remaining: number; cap: number }>;

// NEW — apps/mail/modules/ux/layout/selectChannelChat.ts
/** The open artifact's chat id when it is a LinkedIn chat, else null. Primitive by contract. */
export const selectLinkedinChatId: (s: CedarStore) => string | null;

// CHANGED — apps/mail/modules/cedar-os/.../chatComponents/emptyChatLayout.ts
export type EmptyChatSection =
  | { id: string; kind: 'agenda'; title?: string }
  | { id: string; kind: 'welcome'; title?: string }
  | { id: string; kind: 'tabbedPrompts'; title?: string; tabs: EmptyChatTab[]; defaultTabId?: string }
  | { id: string; kind: 'taskShortcuts'; title?: string }
  | { id: string; kind: 'inboxShortcuts'; title?: string }
  | { id: string; kind: 'calendarRail'; title?: string }
  | { id: string; kind: 'conversationShortcuts'; title?: string }
  | { id: string; kind: 'conversationCards'; title?: string }
  | { id: string; kind: 'linkedinProfile'; title?: string };   // NEW

// CHANGED — apps/mail/modules/cedar-os/.../chatComponents/EmptyStateSuggestions.tsx
interface EmptyStateSuggestionsProps {
  layout: EmptyChatLayout;
  agendaNode?: React.ReactNode;
  conversationCardsNode?: React.ReactNode;
  calendarRailNode?: React.ReactNode;
  linkedinProfileNode?: React.ReactNode;                       // NEW — self-hides when absent
}
```

Relationship diagram:

```text
┌──────────────────────────┐
│ linkedin_chats           │
│  chat_id            (PK) │
│  organization_id         │
│  unipile_account_id      │──────────────────┐
│  person_id               │──────┐           │
│  conversation_id         │      │           │
└───────────┬──────────────┘      │           │
            │ 1:N chat_id         │           │
            ▼                     │           │
┌──────────────────────────┐      │           │
│ linkedin_chat_           │      │           │
│   participants           │      │           │
│  id                 (PK) │      │           │
│  chat_id                 │      │           │
│  participant_urn ────────┼──┐   │           │
│  public_identifier       │  │   │           │
│  name / headline / photo │  │   │           │
│  person_id ──────────────┼──┼───┤           │
│  is_self                 │  │   │           │
└──────────────────────────┘  │   │           │
                              │   │           │
   participant_urn ──1:1──────┘   │           │
                              │   │           │
                              ▼   │           │
┌───────────────────────────────┐ │           │
│ linkedin_person_profiles NEW  │ │           │
│  provider_urn            (PK) │ │           │
│  organization_id ─────────────┼─┼───FK──►  organizations.id
│  person_id ───────────────────┼─┤           │
│  public_identifier            │ │           │
│  name / headline / occupation │ │           │
│  location / profile_picture   │ │           │
│  work_experience  jsonb       │ │           │
│    ▼ contains WorkExperienceEntry[]         │
│        { company, companyId, companyUrl,    │
│          title, location, start, end,       │
│          isCurrent }                        │
│  posts            jsonb       │ │           │
│    ▼ contains CachedPost[]    │ │           │
│        { postId, text, postedAt, url,       │
│          reactions, comments, isRepost }    │
│  profile_fetched_at           │ │           │
│  posts_fetched_at             │ │           │
│  last_skip_reason             │ │           │
└───────────────────────────────┘ │           │
                                  │           │
                    person_id ──N:1──FK──►    │
                                  ▼           │
┌──────────────────────────────────────────┐  │
│ crm_person                               │  │
│  id                                 (PK) │  │
│  display_name                            │  │
│  headline            ◄── gap-filled      │  │
│  current_title       ◄── gap-filled      │  │
│  location            ◄── gap-filled      │  │
│  linkedin_provider_id  = provider_urn    │  │
│  current_company_id ──N:1──FK──► crm_company.id
└──────────────────────────────────────────┘  │
                                              │
                        unipile_account_id ──N:1──FK──► linkedin_accounts.unipile_account_id
                                              │              │
                                              │              │ 1:N (unipile_account_id, action, window)
                                              │              ▼
                                              │   ┌────────────────────────────┐
                                              └──►│ linkedin_rate_limits       │
                                                  │  action = 'profile_view'   │
                                                  │  window_kind = 'day'       │
                                                  │  cap 50 · reserve gate 40% │
                                                  └────────────────────────────┘
```

## 4) Implementation phases

### Phase 1 — Cache table and the pure rules

**Goal:** Land the storage and the two pure functions the rest of the design depends on, with no behaviour change anywhere.

- [x] Add `apps/server/src/db/migrations/linkedin_person_profiles.sql` with the `CREATE TABLE IF NOT EXISTS` + two `CREATE INDEX IF NOT EXISTS` statements from §3.3, and apply it as targeted additive DDL
- [x] Add the `linkedinPersonProfiles` drizzle table to [outbound-schema.ts](apps/server/src/db/outbound-schema.ts), in the LinkedIn messaging block beside `linkedinChatParticipants`
- [x] Create [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) exporting `WorkExperienceEntry`, `CachedPost`, `RefreshSkipReason`, `CounterpartProfile`, and the constants `PROFILE_TTL_DAYS = 90`, `POSTS_TTL_DAYS = 1`, `PROFILE_VIEW_INTERACTIVE_RESERVE = 0.25`
- [x] Implement `splitWorkExperience(work)` in that file — current = `find(e => e.current)` ?? the sole entry; history = the rest, newest `start` first; drop only entries with neither a company name nor a company id
- [x] Implement `withinBackgroundProfileReserve({ remaining, cap })` in that file — `remaining > cap * PROFILE_VIEW_INTERACTIVE_RESERVE`, asked by background callers only
- [x] Implement `normalizePosts(items, urn)` in that file — filter to posts authored or reposted by `urn`, map `UnipilePost` → `CachedPost`, newest first, slice to 5
- [x] Replace the inline current-employer pick inside `resolveCounterpartCompany` at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts) with a call to `splitWorkExperience`, so the rule has exactly one definition

**Tests:**

- [x] `apps/server/src/services/integrations/linkedin/__test__/person-profile.rules.test.ts` — `splitWorkExperience` returns `current: null` when several entries exist and none is flagged (the no-guessing rule), picks the sole entry when there is exactly one, and orders history newest-first
- [x] Same file — `withinBackgroundProfileReserve` is true at `{remaining:17,cap:64}` and false at `{remaining:16,cap:64}`
- [x] Same file — `normalizePosts` drops a post authored by someone else, keeps a repost by the urn, and caps the list at 5
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/person-profile.rules.test.ts`

### Phase 2 — Read-only budget probe

**Goal:** Let a caller ask how much `profile_view` budget is left without consuming a slot.

- [x] Export `rateRemaining(db, unipileAccountId, action)` from [governor.ts](apps/server/src/services/integrations/linkedin/governor.ts), reusing `windowStartFor` at [governor.ts:49](apps/server/src/services/integrations/linkedin/governor.ts) and the same multi-window `every window under cap` rule as `rateCheckAndIncrement` at [governor.ts:76](apps/server/src/services/integrations/linkedin/governor.ts), with no write
- [x] Factor the shared window/cap resolution out of `rateCheckAndIncrement` so the two cannot disagree about which buckets an action maps to

**Tests:**

- [x] Extend `apps/server/src/services/integrations/linkedin/__test__/send-governor.test.ts` — `rateRemaining` reports the same `{remaining, cap}` as `rateCheckAndIncrement` would, and calling it twice does not change the count
- [x] Same file — a multi-window action (`dm`) reports the tightest of its windows
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/send-governor.test.ts`

### Phase 3 — Cache-first read

**Goal:** A free server read that returns everything the panel needs, correct on a cold cache.

- [x] Implement `readCounterpartProfile(db, { organizationId, chatId })` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) — resolve the non-self participant from `linkedin_chat_participants`, left-join `linkedin_person_profiles` on `provider_urn`, and never call Unipile
- [x] Fall back to the participant row's `name` / `headline` / `profile_picture_url` for the header fields when the cache row is absent, so a cold panel still identifies the person
- [x] Compute `stale` — true when `profile_fetched_at` is null or older than `PROFILE_TTL_DAYS`, or `posts_fetched_at` is null or older than `POSTS_TTL_DAYS`
- [x] Carry `last_skip_reason` through as `skipped`, so a client that was budget-blocked earlier does not immediately retry
- [x] Add `counterpartProfile` as a `privateProcedure.query` on `messaging` in [linkedin.ts](apps/server/src/trpc/routes/linkedin.ts)
- [x] Add `linkedin messaging profile --chatId <id>` to the CLI switch in [cli.ts:308](apps/server/src/outbound/cli.ts) and its help line at [cli.ts:134](apps/server/src/outbound/cli.ts), so the read is drivable headlessly

**Tests:**

- [x] `apps/server/src/services/integrations/linkedin/__test__/person-profile.read.test.ts` — a chat with no cache row returns the participant's name/headline, `current: null`, `posts: []`, `stale: true`
- [x] Same file — a chat with a fresh cache row returns `current`, `history`, `posts` and `stale: false`
- [x] Same file — a cache row whose `posts_fetched_at` is 8 days old returns `stale: true` while `profile_fetched_at` is fresh
- [x] Same file — a group chat with several non-self participants resolves deterministically (first by `participant_urn`) rather than at random
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/person-profile.read.test.ts`

### Phase 4 — Governed refresh

**Goal:** Spend at most one profile view per person, only under the reserve, and bank it into the CRM.

- [x] Implement `refreshCounterpartProfile(db, { organizationId, userId, chatId, force })` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) — the seat is read off `linkedin_chats`, never taken from the caller
- [x] Let the profile half run against the daily cap alone; on a denial set `skipped: 'budget'` and continue to posts rather than returning early
- [x] Fetch the profile through `governedSend` at [messaging.ts:1581](apps/server/src/services/integrations/linkedin/messaging.ts) with `action: 'profile_view'` and `getUserProfile(urn, { sections: '*' })`, leaving `notify` at its `false` default
- [x] Fetch posts through `listUserPosts(urn, { limit: 10 })` at [unipile-client.ts:357](apps/server/src/services/integrations/linkedin/unipile-client.ts) in its own try/catch, on its own `POSTS_TTL_DAYS` check
- [x] Upsert `linkedin_person_profiles` with `ON CONFLICT (organization_id, provider_urn) DO UPDATE`, writing only the columns the half that ran actually fetched
- [x] Bank the paid view into `crm_person` exactly as `resolveCounterpartCompany` does — `coalesce` gap-fills for `headline` / `current_title` / `location`, and `current_company_id` via `resolveCompanyByLinkedinHardKey` when the current entry carries a `company_id`
- [x] Emit a `createStructuredLog('info', 'linkedin.counterpartProfile.refresh', …)` carrying `skipped`, `remaining` and `cap`, so the reserve can be tuned from Axiom rather than guessed
- [x] Add `refreshCounterpartProfile` as a `privateProcedure.mutation` on `messaging` in [linkedin.ts](apps/server/src/trpc/routes/linkedin.ts)
- [x] Extend the CLI command from Phase 3 with `--refresh [--force]`

**Tests:**

- [x] `apps/server/src/services/integrations/linkedin/__test__/person-profile.refresh.test.ts` — with a stubbed client, a cold cache under reserve fetches both halves and writes one cache row
- [x] Same file — at `{remaining:18,cap:50}` the profile half is skipped with `skipped: 'budget'` while posts are still fetched and stored
- [x] Same file — a `getUserProfile` throw leaves the existing cached profile columns intact and returns `skipped: 'unavailable'`
- [x] Same file — a second refresh inside `PROFILE_TTL_DAYS` without `force` spends no profile view (`skipped: 'fresh'`)
- [x] Same file — a refresh that resolves a `company_id` sets `crm_person.current_company_id` and does not clobber a non-null `current_title`
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/person-profile.refresh.test.ts`

### Phase 5 — Empty-chat registry slot

**Goal:** Give the `channelChat` surface a `linkedinProfile` section that self-hides, with no card built yet.

- [x] Add `{ id: string; kind: 'linkedinProfile'; title?: string }` to `EmptyChatSection` in [emptyChatLayout.ts:86](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts)
- [x] Add the `linkedinProfile` section ahead of `conversationCards` in the `channelChat` builder at [emptyChatLayout.ts:893](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts), and update its header comment to say why the person now leads
- [x] Add the `linkedinProfileNode` prop and its render branch to [EmptyStateSuggestions.tsx:58](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmptyStateSuggestions.tsx), mirroring `conversationCards` — absent node returns `null`
- [x] Include `linkedinProfileNode` in the `topAlign` calculation so the card starts at the top of the body
- [x] Add `selectLinkedinChatId` to [selectChannelChat.ts](apps/mail/modules/ux/layout/selectChannelChat.ts), returning a primitive and documenting the `getDisplayArtifact()` allocation gotcha

**Tests:**

- [x] Update `apps/mail/modules/cedar-os/__tests__/emptyChatLayout.surfaces.test.ts:122` — the `channelChat` layout's sections are `['linkedinProfile', 'conversationCards', 'tabbedPrompts']` in that order
- [x] `apps/mail/tests/modules/ux/layout/selectChannelChat.test.ts` — returns the id for a `linkedin_chat` artifact and `null` for `slack_thread`, `whatsapp_chat` and no artifact
- [x] Same file — the selector returns a primitive (two calls against an unchanged store are `===`), matching the pin on `emptyChatSurface.store.test.ts`
- [x] `npx jest tests/modules/ux/layout/selectChannelChat.test.ts` and `npx jest modules/cedar-os/__tests__/emptyChatLayout.surfaces.test.ts` from `apps/mail`

### Phase 6 — The card

**Goal:** Render current role, past history and recent posts in the panel, with the auto-fetch-under-reserve behaviour.

- [x] Add `useCounterpartProfile(chatId)` in [use-counterpart-profile.ts](apps/mail/modules/linkedin/hooks/use-counterpart-profile.ts) — the free query, plus a once-per-`chatId`-per-session refresh fired only when the read returns `stale` and `skipped !== 'budget'`
- [x] Reconcile the refresh result into the query cache via `setQueryData` rather than an invalidate, so the card does not flash back to its cold state
- [x] Build `LinkedInCounterpartCard` in [LinkedInCounterpartCard.tsx](apps/mail/modules/linkedin/components/LinkedInCounterpartCard.tsx) — renders nothing when `selectLinkedinChatId` is null
- [x] Header row: avatar, name, headline, and an external link to `https://www.linkedin.com/in/<publicIdentifier ?? urn>` with `cursor-pointer`
- [x] Current role: title and company, with tenure since `start`. When `current` is null — the COMMON case on live data — fall back to the most recent history entry with no `end` date, then to the headline. This fallback is display-only and is deliberately not fed back into `splitWorkExperience`, which stays strict because a wrong `current_company_id` caches permanently
- [x] Past history: a height-animated disclosure (`AnimatePresence`, `initial/animate/exit` on `height`, `overflow-hidden`, one shared tween duration, never a spring), collapsed by default, listing company · title · dates
- [x] Recent posts: up to three rows — relative date, two-line clamped text, reaction and comment counts — each a link to the post, with padding inside the hit area and `cursor-pointer`
- [x] Budget state: when `skipped === 'budget'`, render a "Load profile" button that calls the refresh with `force`, with the same deliberate-action framing as "Find company" at [ChatDetail.tsx:95](apps/mail/modules/linkedin/components/ChatDetail.tsx)
- [x] Loading and empty states: skeleton rows while the first read is pending; when the cache is warm but genuinely empty, show the header alone rather than an error
- [x] Supply `linkedinProfileNode={<LinkedInCounterpartCard/>}` at [EmbeddedCedarChat.tsx:1268](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx), wrapped in `<Suspense fallback={null}>` like `ThreadContextCards`
- [x] Export the card and hook from [index.ts](apps/mail/modules/linkedin/index.ts)

**Tests:**

- [x] `apps/mail/tests/modules/linkedin/LinkedInCounterpartCard.test.tsx` — renders current role, past roles and posts from a stubbed query
- [x] Same file — renders nothing when the open artifact is a `slack_thread`
- [x] Same file — shows "Load profile" and fires no auto-refresh when the read returns `skipped: 'budget'`
- [x] Same file — auto-fires the refresh exactly once for a `stale` read, and not again on re-render
- [x] Same file — with `current: null` it falls back to the most recent history entry whose `end` is null (an unended role is itself a hard signal), and to the headline when none is ongoing — never to `history[0]`
- [x] `npx jest tests/modules/linkedin/LinkedInCounterpartCard.test.tsx` from `apps/mail`
- [x] `timeout 300 pnpm --filter @zero/mail run types` and `timeout 300 pnpm --filter @zero/server run types`

## 5) Follow-up — the artifact every channel was missing

Shipped after the first live test, which found the panel never rendered at all.

**The bug.** `openChannelChat` ([use-open-channel-item.ts](apps/mail/modules/inbox/hooks/use-open-channel-item.ts))
set a display artifact only for Slack; LinkedIn and WhatsApp lived in a module store alone. §3.2
step 1 assumed the artifact was there, and it never is when a chat is opened from the unibox — so
`selectLinkedinChatId` returned null, the surface resolved to `mail` rather than `channelChat`, and
the card had no slot to render into. The same gap is why a LinkedIn row left the URL untouched.

**The fix.** All three channels now set `{kind, id}` via `CHANNEL_CONTEXT_KINDS`, and
`LayoutUrlSync`'s Slack block was extracted into `useChannelArtifactParam(param, kind)` and called
three times — `?slack=`, `?linkedin=`, `?whatsapp=`. One copy of the ref handshake instead of three.

**The behaviour this changes**, deliberately: a LinkedIn/WhatsApp chat now takes the single display
slot, so opening one displaces a conversation, and the back button closes it — exactly as Slack has
always behaved. Three tests in `openChannelItem.test.tsx` pinned the old asymmetry and were rewritten.

**Presentation**, from looking at real profiles in the browser:

- The unlinked `conversationCards` empty state is suppressed on `channelChat` (`hideWhenUnlinked`).
  For an email thread "No conversation linked" is a useful offer; for a DM that will never be a deal
  it is a permanent "no" above the only thing the surface has to say.
- The role shown as current is filtered out of Past (`pastRoles`) — `resolveDisplayRole` usually
  picks it FROM history, so the same job rendered twice.
- Past is a timeline: a rail with a dot per employer, and consecutive roles at one company grouped
  under it (`groupByCompany`), so a promotion reads as one job rather than three. Only consecutive
  runs merge — someone who left and came back keeps two stints.
- Recent posts are a horizontal carousel (`HorizontalScrollContainer`), which fits all five cached
  posts in one row's height instead of three stacked rows.

### 5.1 Company marks, and what we actually have

Checked before designing around them:

| source | covers |
| --- | --- |
| `crm_company.logo_url`, joined on `linkedin_id` = `work_experience[].company_id` | 16,461 companies — the ones we already track |
| `crm_company.primary_domain` → favicon (the `WebCitationPill` pattern) | fills in where a tracked company has no stored logo |
| the provider | **nothing** — the profile payload carries no logo, and `company_url` is null on every cached entry |

So a counterpart's PAST employers are usually not companies we hold, and there is no free way to
fetch their marks. The initials monogram is therefore the design, not a degraded state — and the
logo appears exactly where it matters most for a sales panel: when the employer is already a
prospect or customer of ours. Rendering goes through the app's own `CompanyAvatar`, so a company
here looks like the same company on a deal card.

Logos resolve at READ time and are never stored (`ResolvedWorkEntry`), so a company enriched next
week starts showing a mark on a profile cached today.

Name matching was considered and rejected: none of the sample's employers were in `crm_company` by
name either, and a same-named company would put the wrong logo on someone's history.

### 5.2 Layout

- **Posts above experience.** The career is background you skim once; what they posted last week is
  what gives you an opening line today.
- **The timeline never collapses.** It was a disclosure; the collapse only put a click between the
  reader and the shape of someone's career (how long, how many moves, which companies).
- **The avatar column is the timeline** — one mark per employer with a hairline behind them, so the
  eye tracks one spine rather than a stack of rows.

## 6) Follow-up — the budget, measured

Shipped after the panel was found empty in daily use: header filled in, timeline and posts blank on
almost every chat. The panel was not broken. It was starving, and it was being starved by the part
of the system that had already bought what it needed.

**What the data said.** `linkedin_person_profiles` held **4 rows against 1,146 distinct chat
counterparts**, none written in ten days. Meanwhile **716 people carried a banked
`linkedin_provider_id`** — one `profile_view` spent on each, **701 of them chat counterparts** — of
which **8** yielded a `current_company_id` and **10** a `current_title`. `resolveCounterpartCompany`
makes the identical `getUserProfile(sections:'*')` call the panel makes, reads one field, and used
to drop the rest. Roughly seven hundred complete work histories, bought and binned.

Live confirmation on the newest chat in the org: the refresh returned `skipped: "budget"` with
posts fetched and `work_experience` empty — the exact shape of the empty panel.

**Three changes.**

1. **The payload is kept.** `resolveCounterpartCompany` writes the whole profile through
   [person-profile-cache.ts](apps/server/src/services/integrations/linkedin/person-profile-cache.ts),
   which both it and the refresh now share as the single payload→columns mapping. A chat that has
   ever synced is warm for free; the panel stops being the thing that pays.
2. **The reserve is inverted.** It protected the sequencer, which does not spend from this bucket —
   sends book `dm` / `connect` / `inmail`. It now protects the interactive read: background callers
   (`resolveCounterpartCompany`, `deobfuscateViaSeat`, the sequencer's `linkedin_view` step,
   `signalProfileViewers`) stop at 25% remaining; the panel consults no reserve and spends to the
   cap. Two callers stay ungated on purpose — `resolveProviderId`, because a send must never be
   blocked by a panel's reserve, and `viewProfile` itself, which also serves the CLI, the tRPC route
   and the agent tool; the warm-up gate therefore sits on the sequencer's call, not inside it.
3. **The cap is 64, not 50.** 50 was never sourced: `sequencing.md` §4 cites limits for `connect`,
   `dm` and `inmail` and does not list this action at all. The seat hit 50 outright on 2026-09-15
   and came within a handful of it on three other days that week.

**Measured spend, one seat, the week before the change:** `4 · 33 · 50 · 12 · 22 · 29`. Every other
seat in the database ran 2–4/day. The stop line under the old reserve was 29.

**Also paused, separately from the code:** the two subscription-triggered Cedar Inputs on that seat
(`My LinkedIn Engagers FULL`, `My LinkedIn Post Engagers (live)` — `my_post_engagement`, 10 post
pages × 30 reaction pages), which fired 12–30 runs a day in bursts of seven and eight identical
runs within the same second. That burst pattern is its own bug and is not addressed here.