conversation-index-and-card-lists.md40.3 KBView on GitHub
# Conversation Index & Card Lists

> Historical design record. Only the card-list half of this design is live — the conversation
> attention index (phases 2–5) was removed because it ranked on inputs we do not trust, and
> Top Deals now sorts by next-step date. See [wiki/card-lists.md](wiki/card-lists.md).

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

We want deal conversations to carry a single sortable "how much does this deserve my attention right now" number (the **conversation index**), plus two new display primitives: a **card-column renderer** and a **card-list** canvas that renders a set of conversations as stacked cards instead of table rows — so an AI-curated or index-sorted "Top Deals" list can become a one-click portal into the conversations a user is most likely to open. Today conversations are ranked only by whatever column a user manually sorts on ([`listConversations` sortBy](apps/server/src/trpc/routes/crm.ts) offers `lastContactedAt`, `dealValue`, `priority`, …), there is no composite attention score, the only conversation-set renderers are the CRM table / kanban / conversation-canvas ([`CanvasRenderer`](apps/mail/modules/canvas/components/CanvasRenderer.tsx)), and the base-agent home falls back to the live calendar agenda ([`getDisplayArtifact`](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts) returns `{ kind: 'agenda' }`). We add a native `risk` field, a `conversationIndex` column maintained inside the crm-updater's event flow from one pure `computeConversationIndex()` function, a per-pipeline `medianDealValue` cache, a `cardList` canvas type with a card renderer, and (long-term) a flip of the base-agent default to a Top Deals card list.

## 2) Present state

### 2.1 Architecture diagram

```text
  EVENT INGEST                         READ / RENDER
  ┌───────────────────────────┐        ┌─────────────────────────────────────┐
  │ on-event orchestrator      │        │ crmFilters → listConversations      │
  │  └ updateConversationFields│        │   sortBy: builtin | customField     │
  │     writes status/priority/│        │   (no composite score)              │
  │     nextSteps to           │        │        │                            │
  │     crm_conversations       │───────▶│  HydratedConversation[]            │
  └───────────────────────────┘         │        │                            │
                                        │  CanvasRenderer switch(canvas.type) │
  crm_conversations                     │   table | conversationCanvas |      │
   priority, dealValue, status,         │   kanban | agenda | …  (rows only)  │
   nextStepDate, lastContactedAt        └─────────────────────────────────────┘
   (NO risk, NO conversationIndex)
                                        HOME BASE AGENT
                                        getDisplayArtifact() → {kind:'agenda'}
                                        → <AgendaMeetings/> (live calendar)
```

### 2.2 Step-by-step walkthrough

1. **Event → field write** — the crm-updater updates native conversation fields via `updateConversationFieldsTool` at [updateConversationFieldsTool.ts:281](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts). Its allow-list `CONVERSATION_METADATA_FIELD_NAMES` includes `priority` ([:283](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts)) and the `columns` select includes `priority: true` ([:345](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts)). No index is computed here.
   - Row after write:
     ```json
     { "id": "conv_1", "priority": "high", "dealValue": 40000, "nextStepDate": "2026-07-10", "lastContactedAt": "2026-07-01" }
     ```
2. **Native field storage** — `crm_conversations` has native `priority text` at [crm-schema.ts:437](apps/server/src/db/crm-schema.ts) and `dealValue`, `nextStepDate`, `lastContactedAt`, `aopId` ([:430](apps/server/src/db/crm-schema.ts)) in the same block. There is no `risk` and no `conversationIndex`.
3. **Default field registry** — `priority` is registered as a default enum field (`DEFAULT_PRIORITY_OPTIONS` + `DEFAULT_CONVERSATION_FIELD_DEFINITIONS.priority`) server-side at [conversation-field-definitions.ts:32](apps/server/src/services/crm/conversation-field-definitions.ts) / [:119](apps/server/src/services/crm/conversation-field-definitions.ts) and mirrored frontend at [crm/types/index.ts:25](apps/mail/modules/crm/types/index.ts) / [:120](apps/mail/modules/crm/types/index.ts). Unlike `status`, `priority` is available on all AOPs (no `AopName.Deals` gate).
4. **List + sort** — `listConversations` accepts `sortBy` (discriminated `builtin` / `customField`) at [crm.ts:625](apps/server/src/trpc/routes/crm.ts); the builtin field enum is at [crm.ts:636](apps/server/src/trpc/routes/crm.ts). Sort columns resolve in `getColumnForField` at [conversation-lookup.ts:470](apps/server/src/services/crm/conversation-lookup.ts); enum filters apply via `buildEnumFilter` at [conversations.ts:594](apps/server/src/services/crm/conversations.ts). Returns `HydratedConversation[]` (carrying `userTasks`, `openTaskCount`).
5. **AOP scope** — a conversation's pipeline is `crm_conversations.aopId` → `agent_operating_procedures` ([aop-schema.ts:689](apps/server/src/db/aop-schema.ts)); a "deal" pipeline is the AOP whose `name === AopName.Deals` ([types.ts:42](apps/server/src/services/aop/types.ts)). The AOP row has no general-purpose stats column.
6. **Render dispatch** — `CanvasRenderer` switches on `canvas.type` (union at [canvas-types.ts:31](apps/mail/modules/canvas/types/canvas-types.ts)); `use-canvas-conversations` already supports a pinned set via `viewConfig.pinnedConversationIds` → `listConversations({ conversationIds })` at [use-canvas-conversations.ts:276](apps/mail/modules/crm/hooks/use-canvas-conversations.ts). All conversation renderers are row/table/kanban — none renders cards in a column.
7. **Base-agent default** — home resolves the open artifact via `getDisplayArtifact()` at [messagesSlice.ts:231](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts) (`?? { kind: 'agenda' }`); [DisplayArtifactPanel](apps/mail/modules/home/components/DisplayArtifactPanel.tsx) renders `<AgendaMeetings/>` when nothing is selected.

## 3) Designed state

### 3.1 Architecture diagram

```text
  EVENT INGEST                                READ / RENDER
  ┌────────────────────────────┐             ┌──────────────────────────────────────┐
  │ updateConversationFields    │             │ crmFilters → listConversations         │
  │  writes status/priority/    │             │  sortBy builtin += 'conversationIndex' │
  │  RISK/nextSteps ───────────┐│             │  (deal AOP scope)                       │
  │                            ▼│             │        │                               │
  │  reindexConversation(id) ───┼──┐          │  HydratedConversation[] (has index)    │
  └────────────────────────────┘  │          │        │                               │
  task create/complete ───────────┤          │  CanvasRenderer switch(canvas.type)    │
  star (important) toggle ─────────┤          │   ... | 'cardList' → CardListCanvasView │
                                   ▼          │           └ ConversationCard column     │
        computeConversationIndex(input)       └──────────────────────────────────────┘
          reads medianDealValue (AOP cache)
                                   │          HOME BASE AGENT (final phase)
                                   ▼          getDisplayArtifact() → {kind:'canvas', id:topDeals}
   crm_conversations.conversationIndex         → CardListCanvasView (Top Deals)
   crm_conversations.risk
   agent_operating_procedures.computedStats { medianDealValue }
```

### 3.2 Step-by-step walkthrough

1. **`risk` write** — `updateConversationFieldsTool` allow-list + `columns` select gain `risk` (parallel to `priority`) at [updateConversationFieldsTool.ts:281](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts) / [:345](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts). The crm-updater can now set `risk: 'low'|'medium'|'high'` from event content.
2. **Pipeline stat read** — before scoring, read `medianDealValue` from the conversation's AOP `computedStats` jsonb ([aop-schema.ts:689](apps/server/src/db/aop-schema.ts)); if absent/stale, `refreshAopComputedStats(aopId)` recomputes `percentile_cont(0.5)` over non-null `dealValue` for that pipeline and writes it back.
3. **Single calculation** — `computeConversationIndex()` (new, in [conversation-index.ts](apps/server/src/services/crm/conversation-index.ts)) is the one home of the formula. Pure, fully commented, no IO:
   ```ts
   // apps/server/src/services/crm/conversation-index.ts
   //
   // The conversation index is a single attention score used to sort deal
   // conversations. It is intentionally coarse — its only job is a relative
   // ranking. Range ≈ [0, 5.4]. Ties are broken downstream by
   // (dealValue DESC, lastContactedAt DESC), so the score need not be precise.
   //
   //   index = 2·A(action) + 2·I(importance) + 1·S(starred)
   //
   export const INDEX_CONSTANTS = {
     ACV_CAP: 3,              // a deal ≥ 3× the pipeline median maxes the size term
     RISK_MULT: { low: 1.0, medium: 1.1, high: 1.2 } as const, // risk only amplifies action
     PRIORITY_SCORE: { low: 0.25, medium: 0.5, high: 0.75, urgent: 1.0 } as const,
     NULL_PRIORITY_FLOOR: 0.375, // unset priority ≈ between low and medium; ACV lifts from here
     NULL_PRIORITY_ACV_WEIGHT: 0.45,
   };

   export interface ConversationIndexInput {
     important: boolean;                 // starred
     priority: 'low' | 'medium' | 'high' | 'urgent' | null;
     risk: 'low' | 'medium' | 'high' | null;
     dealValue: number | null;
     linkedinHeadcount: number | null;
     employeeCountRange: string | null;
     hasOpenTask: boolean;               // any non-completed/-deleted user task
     daysSinceContact: number | null;    // from lastContactedAt; null = never
     nextStepOverdue: boolean;           // nextStepDate != null && nextStepDate < now
     medianDealValue: number | null;     // pipeline cache; null → fall back to size buckets
   }

   export function computeConversationIndex(c: ConversationIndexInput): number {
     const K = INDEX_CONSTANTS;

     // ── deal size, normalized to [0,1] ────────────────────────────────────
     // ACV is user-relative ($40k is big for one pipeline, tiny for another),
     // so normalize against the pipeline median. When there is no dealValue
     // (or no median yet), fall back to company headcount buckets, which have
     // universal meaning and need no pipeline stat.
     const dealSize =
       c.dealValue != null && c.medianDealValue
         ? Math.min(c.dealValue / c.medianDealValue, K.ACV_CAP) / K.ACV_CAP
         : companySizeBucket(c.linkedinHeadcount, c.employeeCountRange);

     // ── I: importance ∈ [0,1] ─────────────────────────────────────────────
     // Priority is the source of truth when set: it anchors magnitude and ACV
     // only modifies ±25% (dealBlend). When priority is unset (not yet judged
     // by Cedar) ACV drives, lifting from a low-medium floor so a big unassessed
     // deal still surfaces.
     let I: number;
     if (c.priority) {
       const dealBlend = 0.75 + 0.5 * dealSize; // [0.75, 1.25]
       I = clamp01(K.PRIORITY_SCORE[c.priority] * dealBlend);
     } else {
       I = clamp01(K.NULL_PRIORITY_FLOOR + K.NULL_PRIORITY_ACV_WEIGHT * dealSize);
     }

     // ── A: action ∈ [0, ~1.2] ─────────────────────────────────────────────
     // Presence of an obligation, amplified (never suppressed) by risk — a
     // risky deal with something owed on it deserves more attention.
     const actionBase =
       0.5 * (c.hasOpenTask ? 1 : 0) +
       0.25 * (c.daysSinceContact != null && c.daysSinceContact > 7 ? 1 : 0) +
       0.25 * (c.nextStepOverdue ? 1 : 0);
     const riskMult = c.risk ? K.RISK_MULT[c.risk] : 1.0; // unset risk = neutral
     const A = actionBase * riskMult;

     // ── S: starred ────────────────────────────────────────────────────────
     const S = c.important ? 1 : 0;

     return 2 * A + 2 * I + 1 * S;
   }
   ```
   - Example input → output:
     ```json
     { "important": false, "priority": "high", "risk": "high", "dealValue": 120000,
       "hasOpenTask": true, "daysSinceContact": 12, "nextStepOverdue": true,
       "medianDealValue": 40000 }
     → dealSize=1.0, I=clamp(0.75·1.25)=0.9375, actionBase=1.0, A=1.2, S=0
     → index = 2·1.2 + 2·0.9375 + 0 = 4.275
     ```
4. **Reindex hook** — new `reindexConversation(db, conversationId)` in [conversation-index.ts](apps/server/src/services/crm/conversation-index.ts) hydrates the inputs (conversation row + open-task existence + AOP median), calls `computeConversationIndex`, and writes `crm_conversations.conversationIndex`. Called from: the crm-updater after `updateConversationFieldsTool` writes; task create/complete/delete mutations; the star (`important`) toggle. All share this one path.
5. **Deal-scoped sort** — `listConversations` sortBy builtin enum gains `'conversationIndex'` at [crm.ts:636](apps/server/src/trpc/routes/crm.ts); `getColumnForField` maps it to `crmConversations.conversationIndex` at [conversation-lookup.ts:470](apps/server/src/services/crm/conversation-lookup.ts). Sort applies with the deterministic tiebreaker `conversationIndex DESC, dealValue DESC NULLS LAST, lastContactedAt DESC`.
   - Prompt/query shape:
     ```json
     { "sortBy": [{ "kind": "builtin", "field": "conversationIndex", "direction": "desc" }] }
     ```
6. **Card render** — `CanvasType` gains `'cardList'`; `CanvasRenderer` switch gains `case 'cardList': return <CardListCanvasView canvas={canvas} />` at [CanvasRenderer.tsx](apps/mail/modules/canvas/components/CanvasRenderer.tsx). `CardListCanvasView` (new) hydrates conversations via `use-canvas-conversations` (pinned or filtered+sorted), preserves list order, and renders each as a `ConversationCard` in a single column. The whole card is a click target → `openConversation({ conversationId })` at [conversationsSlice.ts:887](apps/mail/modules/conversations/slice/conversationsSlice.ts) (sets `selectedArtifact { kind:'conversation' }`).
   - `ConversationCard` anatomy (all fields off `HydratedConversation`; the labeled-row + stat patterns reuse `renderFieldLabel` from [ConversationOverviewCard.tsx:157](apps/mail/modules/conversations/components/ConversationOverviewCard.tsx) and `renderStat` from [StrategicOverviewTab.tsx:344](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)):
     ```text
     ┌──────────────────────────────────────────────────────────┐
     │ ● TensorWave                                        $82k  │  header: risk-color dot + conversation.name  ←→  formatDealValue(dealValue)
     │ Solutioning · closes … · Scott Sowers · [Out]            │  subtitle: status · nextStepDate · ownerUser.name · status badge
     │                                                          │
     │ MOVE  <the_play text>                                    │  customFields['the_play'].value   (Strategist-owned)
     │ RISK  <risks text>                                       │  customFields['risks'].value
     │ [risk: high]                                             │  native risk field (Phase 1) as a colored badge
     │ [3 tasks due]  <first open task.description>             │  openTaskCount/userTasks[0].description
     │ Last touch 17d ago · next step · 7 stakeholders  →       │  lastContactedAt · nextSteps · people.length
     └──────────────────────────────────────────────────────────┘
     ```
   - v1 uses only fields that exist today. Probability (`55%`), weighted value (`$45k`), and close date (`Jul 31`) from the reference screenshot are **not** on the model — deferred (each is net-new schema; see §4 notes), and omitted from v1 rather than faked.
7. **Card-list instances** — a manual card list is a `cardList` canvas with `viewConfig.pinnedConversationIds` (curated set, ordered). "Top Deals" is a `cardList` canvas with no pins whose `viewConfig` filters `aop = Deals` and sorts `conversationIndex desc` — seeded once per user as a system canvas.
8. **Base-agent default (final phase)** — `DisplayArtifact` gains `{ kind: 'canvas', id }`; `getDisplayArtifact()` at [messagesSlice.ts:231](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts) resolves the user's default Top Deals canvas when present, else falls back to `{ kind: 'agenda' }`; [DisplayArtifactPanel](apps/mail/modules/home/components/DisplayArtifactPanel.tsx) renders `CardListCanvasView` for the canvas kind.

### 3.3 Schema

Full schema:

```ts
// ── crm_conversations (apps/server/src/db/crm-schema.ts) ──────────────────
// Existing columns shown for context; NEW marked.
interface CrmConversationsRow {
  id: string;                         // uuid pk
  userId: string;
  aopId: string | null;               // FK → agent_operating_procedures.id
  name: string | null;
  status: string | null;
  priority: string | null;            // 'low'|'medium'|'high'|'urgent'
  risk: string | null;                // NEW native text: 'low'|'medium'|'high'
  dealValue: number | null;           // bigint
  nextStepDate: Date | null;
  lastContactedAt: Date | null;
  important: boolean;                  // starred
  conversationIndex: number | null;   // NEW real/double — the attention score, maintained by reindexConversation
  // …existing columns unchanged…
}

// ── agent_operating_procedures (apps/server/src/db/aop-schema.ts) ─────────
interface AopRow {
  id: string;                         // uuid pk
  userId: string;
  orgAopId: string | null;            // FK → org_aops.id
  name: string;                       // 'Deals' identifies a deal pipeline (AopName)
  computedStats: {                    // NEW jsonb bag (typed)
    medianDealValue?: number;
    computedAt?: string;              // ISO
  } | null;
  // …existing jsonb columns (conversation_field_definitions, custom_field_definitions,
  //   display_config, user_diff, conversation_scope_config) unchanged…
}

// ── default field registry: risk (parallel to priority) ───────────────────
// server: apps/server/src/services/crm/conversation-field-definitions.ts
const DEFAULT_RISK_OPTIONS = [           // NEW
  { value: 'low', label: 'Low' },
  { value: 'medium', label: 'Medium' },
  { value: 'high', label: 'High' },
];
// added to DEFAULT_CONVERSATION_FIELD_DEFINITIONS as risk: { options: DEFAULT_RISK_OPTIONS }
// mirrored frontend: apps/mail/modules/crm/types/index.ts (DEFAULT_RISK_OPTIONS + entry)

// ── canvas cardList view config (apps/mail/modules/canvas/types/canvas-types.ts) ──
interface CardListViewConfig {           // NEW discriminated member
  type: 'cardList';
  pinnedConversationIds?: string[];      // set → manual curated list (ordered)
  filters?: ConversationFilters;         // unset pins → filtered view (e.g. Top Deals)
  sortBy?: SortSpec[];                   // e.g. [{ kind:'builtin', field:'conversationIndex', direction:'desc' }]
}
type CanvasType = /* …existing… */ | 'cardList';  // NEW

// ── DisplayArtifact (apps/mail/modules/cedar-os/src/store/messages/MessageTypes.ts) ──
type DisplayArtifact =
  | { kind: ContextKind; id: string }
  | { kind: 'agenda' }
  | { kind: 'canvas'; id: string };      // NEW — used for base-agent Top Deals default
```

Relationship diagram:

```text
┌─────────────────────────────┐        ┌──────────────────────────────────────┐
│ agent_operating_procedures  │        │ crm_conversations                    │
│  id (pk)                    │◄──FK────│  aopId                               │
│  name  ('Deals' = pipeline) │  aopId  │  id (pk)                            │
│  computedStats ▼ contains   │         │  priority                            │
│    { medianDealValue,       │         │  risk            (NEW)               │
│      computedAt }           │         │  dealValue                           │
└─────────────────────────────┘         │  important (starred)                 │
        ▲                               │  conversationIndex (NEW) ◄─ written  │
        │ refreshAopComputedStats        │      by reindexConversation()        │
        │ (median over pipeline)         └──────────────────────────────────────┘
                                                     ▲
                                                     │ pinnedConversationIds / filter+sort
                                        ┌──────────────────────────────────────┐
                                        │ canvas (canvas-schema.ts)            │
                                        │  id (pk)                             │
                                        │  type = 'cardList'  (NEW)            │
                                        │  viewConfig ▼ CardListViewConfig     │
                                        │    pinnedConversationIds[] | filters │
                                        └──────────────────────────────────────┘
```

## 4) Implementation phases

### Phase 1 — `risk` native field

**Goal:** add `risk` (enum low/medium/high) everywhere `priority` lives, so the crm-updater can set it and it filters/sorts like any native field.

- [x] Add `risk: text('risk')` to `crmConversations` at [crm-schema.ts:437](apps/server/src/db/crm-schema.ts) (+ `idx_crm_conversations_risk`) and write the migration. **Divergence:** the drizzle journal is badly out of sync with `schema.ts` (`db:generate` prompts to reconcile unrelated drift like `aop_agents`), so the migration is a hand-written idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` at [add_conversation_risk.sql](apps/server/src/db/migrations/add_conversation_risk.sql), applied directly to the shared DB — matching the repo's raw-`.sql` migration convention and avoiding a wrong mega-migration.
- [x] Add `risk?` to `ConversationFieldDefinitions` at [aop-schema.ts:59](apps/server/src/db/aop-schema.ts).
- [x] Add `DEFAULT_RISK_OPTIONS` + `DEFAULT_CONVERSATION_FIELD_DEFINITIONS.risk` and extend the `'status'|'priority'` unions/switches (`getConversationEnumOptions`, `getConversationEnumValues`, `getAllConversationEnumOptions`, `normalizeConversationFieldDefinitionValues`) to include `'risk'` in [conversation-field-definitions.ts:32](apps/server/src/services/crm/conversation-field-definitions.ts). Ungated `priority` pattern (all AOPs), not the `status` Deals-gate.
- [x] Mirror `DEFAULT_RISK_OPTIONS` + field-definition entry + `Conversation.risk` + `CEDAR_OVERVIEW_FIELDS`/`CEDAR_FIELD_LABELS`/`DEFAULT_OVERVIEW_CONFIGURATION` frontend at [crm/types/index.ts:25](apps/mail/modules/crm/types/index.ts).
- [x] Register the `risk` column (`type: 'select'`) in [conversation-columns.ts:60](apps/mail/modules/crm/config/conversation-columns.ts).
- [x] Add `risk` to the agent write-path: `CONVERSATION_METADATA_FIELD_NAMES` + `columns` select at [updateConversationFieldsTool.ts:283](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts), and `updateConversationFieldsAndWorkingMemory` (metadata Set + before-state columns + write switch `case 'risk'`) + `conversationMetadataFields` at [conversations.ts:1585](apps/server/src/services/crm/conversations.ts).
- [x] Add `risk` filter (input + `buildEnumFilter` + `ConversationFilterParams`/`ListConversationsParams` + `BuiltInSortEntry`) at [crm.ts:509](apps/server/src/trpc/routes/crm.ts) / [conversations.ts:594](apps/server/src/services/crm/conversations.ts), the sort builtin enum at [crm.ts:636](apps/server/src/trpc/routes/crm.ts), and the sort-column case + `FilterField`/select at [conversation-lookup.ts:470](apps/server/src/services/crm/conversation-lookup.ts).
- [x] **(Not in original plan — surfaced by the e2e)** `risk` also had to be added to: the user-facing `updateConversationSchema` route input at [crm.ts:466](apps/server/src/trpc/routes/crm.ts); **both** hydration CTEs' explicit column lists (`c.risk`) in `listConversationsSingleQuery`; and **both** raw-row→object parsers (`risk: row.risk`) in [conversations.ts](apps/server/src/services/crm/conversations.ts). Without these the write route ignored `risk` and the read projection dropped it (the filter/sort path via `conversation-lookup.ts` was correct, but hydration is a separate query).

**Tests:**

- [x] Integration/e2e (gated `E2E_CRM_INDEX=1`, live DB as jesse): `crm.updateConversation({ risk:'high' })` persists and `crm.listConversations` round-trips it via both the pinned-hydration path and the `risk` filter + `sortBy risk` — [conversation-index.e2e.test.ts](apps/server/src/services/crm/conversation-index.e2e.test.ts). Green; test data restored + orphaned rows reset to null. (Subsumes the planned `getAllConversationEnumOptions` unit test — the e2e exercises the enum end to end through the real route.)

### Phase 2 — `computeConversationIndex()` pure function

**Goal:** the single calculation home, unit-tested against the agreed formula, no IO.

- [x] Create [conversation-index.ts](apps/server/src/services/crm/conversation-index.ts) with `INDEX_CONSTANTS`, `ConversationIndexInput`, `computeConversationIndex`, `companySizeBucket`, `clamp01` — heavily commented per §3.2.
- [x] Implement `companySizeBucket(headcount, employeeCountRange)` with fixed thresholds (`<10→0.1, <50→0.3, <200→0.5, <1000→0.7, <5000→0.85, else 1.0`; null → 0). Parses the low end of an `employeeCountRange` string as a fallback.
- [x] **(Amendment — the original doc had no explicit column task)** Add `conversationIndex: real('conversation_index')` (+ index) to `crmConversations` at [crm-schema.ts](apps/server/src/db/crm-schema.ts) with idempotent migration [add_conversation_index.sql](apps/server/src/db/migrations/add_conversation_index.sql), applied to the shared DB. `real` (float4) is plenty for a coarse sort key.

**Tests:**

- [x] Unit: [conversation-index.test.ts](apps/server/src/services/crm/conversation-index.test.ts) — worked cases (high-priority high-risk whale ≈ 4.275; null-priority whale lifts to ~0.8 importance; low-risk never suppresses action; starred adds exactly 1), monotonicity, and `companySizeBucket` thresholds. **8 passed.**
- [x] `pnpm --filter @zero/server exec vitest run src/services/crm/conversation-index.test.ts` → green.

### Phase 3 — `medianDealValue` per-pipeline cache

**Goal:** one cheap cached aggregate per AOP, refreshed opportunistically.

- [x] Add `computedStats jsonb` to `agentOperatingProcedures` at [aop-schema.ts:689](apps/server/src/db/aop-schema.ts) + idempotent migration [add_aop_computed_stats.sql](apps/server/src/db/migrations/add_aop_computed_stats.sql) (applied to shared DB).
- [x] Add `refreshAopComputedStats(db, aopId)` (in [conversation-index.ts](apps/server/src/services/crm/conversation-index.ts)) computing `percentile_cont(0.5)::float8` over non-null `dealValue` for the AOP's conversations, writing `{ medianDealValue, computedAt }`. Plus `getAopMedianDealValue(db, aopId)` — reads the cache, refreshing when older than `AOP_STATS_TTL_MS` (6h) or missing. This is what `reindexConversation` (Phase 4) calls.

**Tests:**

- [x] Integration (live DB, gated `E2E_CRM_INDEX=1`, as jesse): `refreshAopComputedStats` computes + caches the median for jesse's Deals AOP and `getAopMedianDealValue` returns the cached value — [conversation-index.e2e.test.ts](apps/server/src/services/crm/conversation-index.e2e.test.ts). Green.

### Phase 4 — `reindexConversation()` + write hooks

**Goal:** the stored index stays current via the crm-updater flow and the mutations that change its inputs.

- [x] Add `reindexConversation(db, conversationId)` to [conversation-index.ts](apps/server/src/services/crm/conversation-index.ts): one hydration query (conversation row + company headcount via `crm_company_relationships → crm_companies_global` + open-task `EXISTS(status='todo')` + AOP name) + cached median, compute, write `conversation_index`. Returns null (skips) for non-Deal AOPs. Plus `safeReindexConversation` (best-effort, never throws) and `backfillDealConversationIndexes`.
- [x] Call it from the crm-updater path. **Divergence — better placement:** hooked into `updateConversationFieldsAndWorkingMemory` at [conversations.ts:5247](apps/server/src/services/crm/conversations.ts) (the central write the crm-updater, the `crm.updateConversation` route, AND the star toggle all funnel through) rather than in `updateConversationFieldsTool` alone — one hook covers risk/priority/dealValue/nextStep/important in every path.
- [x] Call it from task mutations: `safeReindexConversation` wired into ~10 sites across [user-tasks.ts](apps/server/src/trpc/routes/user-tasks.ts) (createTask, updateTask, updateTaskStatus, acceptRecommendation, deleteTask/deleteTasks, applyFieldChange, createStandaloneTask, processAgendaTasks) and the `completeTask` service at [execution.ts](apps/server/src/services/task-scheduling/execution.ts). Static imports, no cycles (`deps:check` clean, 1084 modules). `conversation_id` is `.notNull()` so no null-guard needed.
- [x] Backfill: `backfillDealConversationIndexes(db, userId?)` — reindexed **113** of jesse's deal conversations (index range 0.375–4.500).

**Tests:**

- [x] Integration/e2e (gated `E2E_CRM_INDEX=1`, live DB as jesse) — [conversation-index.e2e.test.ts](apps/server/src/services/crm/conversation-index.e2e.test.ts): reindex responds to inputs (star flip = exactly ±1 via the real function), the `crm.updateConversation` write hook persists a fresh index, and **the task-mutation hook moves it** (createTask via the real route raises the index, deleteTask restores it). Backfill returns > 0. **4 e2e green; all test data (tasks, risk) cleaned — `deleteTask` is a soft delete so the test hard-deletes by description.**

### Phase 5 — Sortable in crmFilters

**Goal:** Top Deals is expressible as a saved sort over deal conversations.

- [x] Add `'conversationIndex'` to the sortBy builtin `z.enum` at [crm.ts:637](apps/server/src/trpc/routes/crm.ts), the `BuiltInSortEntry` type at [conversations.ts:3722](apps/server/src/services/crm/conversations.ts), and the `FilterField` union + `getColumnForField` case at [conversation-lookup.ts:465](apps/server/src/services/crm/conversation-lookup.ts). The single-query ORDER BY builder at [conversations.ts:4034](apps/server/src/services/crm/conversations.ts) is generic (`crmConversations[sort.field]`) + `NULLS LAST`, so no per-field branch was needed. Also projected `c.conversation_index` into both hydration CTEs + both raw-row parsers so it's readable in the output (same pattern as `risk`).
- [x] Deal-AOP scoping + tiebreaker. **Divergence:** (a) `NULLS LAST` naturally floats indexed (deal) conversations above non-deal (null) ones — no explicit AOP gate needed on the sort; a Top Deals view still filters `aopIds:[Deals]`. (b) The `dealValue → lastContactedAt` tiebreaker is expressed as a **multi-key `sortBy` array** (already supported) rather than hard-appended. (c) The CRM list **force-prepends `ORDER BY important DESC`** ([conversations.ts:4117](apps/server/src/services/crm/conversations.ts)) — pre-existing behavior — so the effective order is `(important DESC, conversationIndex DESC)`; starred pinned first is consistent with the index's own +1 star boost.

**Tests:**

- [x] Integration/e2e (gated `E2E_CRM_INDEX=1`, live DB as jesse): `listConversations({ aopIds:[Deals], sortBy:[conversationIndex desc] })` returns deal conversations ordered by `(important desc, conversationIndex desc)`, AND starring an un-starred conversation raises its stored index (~+1 via the hook) and moves it into the pinned group in the re-sorted list — the full **input-change → index-moves → sort-reorders** end-to-end. **5 e2e green, all data cleaned.**

### Phase 6 — `cardList` canvas type + `ConversationCard` renderer

**Goal:** conversations render as cards in a column, each card matching the reference layout (§3.2 step 6). **Frontend — verified by typecheck + `deps:check`; visual behavior is manual-tested (skill frontend exception).**

- [x] Add `'cardList'` to `CanvasType` + a `CardListViewConfig` discriminated member at [canvas-types.ts:31](apps/mail/modules/canvas/types/canvas-types.ts); add to `CanvasViewConfig`. **Divergence:** the frontend has no `ConversationFilters`/`SortSpec` types as the doc sketched — the real shape sibling configs use is `filterSortConfiguration?: CanvasFilterSortConfiguration` (+ `selectedAopIds`/`searchQuery`/`ownerUserIds`/`pinnedConversationIds`), so `CardListViewConfig` mirrors `ConversationViewConfig` and `use-canvas-conversations` drives it unchanged.
- [x] Register the type: `'cardList'` in `CANVAS_TYPES` at [canvas-schema.ts](apps/server/src/db/canvas-schema.ts) (the single server registry — the tRPC `CanvasTypeSchema = z.enum(CANVAS_TYPES)` derives from it; `viewConfig` itself is validated as `z.any`, so no discriminated-union to extend), the `buildCanvasViewConfig` default factory at [NewCanvasScreen.tsx](apps/mail/modules/home/components/NewCanvasScreen.tsx) (canvasSlice has none), and the exhaustive `HomeCanvas` label/order maps.
- [x] Create `CardListCanvasView` at [CardListCanvasView.tsx](apps/mail/modules/canvas/components/CardListCanvasView.tsx): hydrate via `useCanvasConversations(canvas.id, pinnedConversationIds, { fetchAllPages: true })`, preserve the store's ordered list, render a single column of `ConversationCard`.
- [x] Create `ConversationCard` at [ConversationCard.tsx](apps/mail/modules/canvas/components/ConversationCard.tsx): header (native `risk`-color dot + name ←→ `formatDealValue(dealValue)`); subtitle (`status · nextStep · owner · status badge`); `MOVE`/`RISK` rows from `customFields['the_play']`/`['risks']` (only if present) + a native `risk` badge; `[N tasks due]` + first `todo` task description; footer (last touch · nextSteps · `people.length` stakeholders). **Divergence:** `getEnumTextColor` has no `'risk'` key, so a small local `RISK_DOT/TEXT_COLOR` map is used; status uses `DEFAULT_STATUS_OPTIONS` (no per-AOP option set in a card list). Probability/weighted/close-date omitted (deferred).
- [x] Whole card click → `openConversation({ conversationId })` (store action).
- [x] Add `case 'cardList'` to `CanvasRenderer`; `CanvasHeader` gate needs no change (it's `type !== 'report'`).

**Tests:**

- [x] Typecheck (touched files clean, 0 new errors; repo error count 6200→6199) + `pnpm deps:check` clean (1084 modules). **Divergence:** the jest component test is deferred — this is pure UI with heavy store deps; visual behavior is manual-tested per the skill's frontend exception. The data it renders (index-sorted deals) is already headlessly proven in Phase 5.

### Phase 7 — Manual card lists + seeded Top Deals

**Goal:** users curate a card list; a Top Deals system list exists per user.

- [x] Manual curation on a `cardList` canvas: remove writes `viewConfig.pinnedConversationIds` via the existing `updateCanvas` mutation. New store action `setCardListPinnedIds(canvasId, ids)` at [canvasSlice.ts](apps/mail/modules/canvas/slice/canvasSlice.ts) (optimistic set + `api.canvas.updateCanvas.mutate({ viewConfig })`, mirroring `updateCanvasColour`); a remove "×" on each `ConversationCard` shows in pinned mode ([ConversationCard.tsx](apps/mail/modules/canvas/components/ConversationCard.tsx) / [CardListCanvasView.tsx](apps/mail/modules/canvas/components/CardListCanvasView.tsx)). **Deferred:** drag-reorder UI — no trivial drag infra in the card view; `setCardListPinnedIds` already takes a full ordered array, so reorder is a one-line call once a DnD wrapper is added.
- [x] Seed a per-user "Top Deals" `cardList` canvas (filter `aop=Deals`, sort `conversationIndex desc`, NOT pinned) as a system canvas; idempotent `ensureTopDealsCanvas(db, userId)` at [top-deals-canvas.ts](apps/server/src/services/canvas/top-deals-canvas.ts). **Idempotency marker:** reserved title `TOP_DEALS_CANVAS_TITLE = 'Top Deals'` scoped to `(primaryOwner, type='cardList')` — the canvas schema has no marker column and §3.2 step 7 sanctions a reserved title. Added a server `CardListViewConfig` to [canvas-schema.ts](apps/server/src/db/canvas-schema.ts) (Phase 6 only added the `CANVAS_TYPES` string), and mapped `conversationIndex` in the frontend sort `fieldMap`/`BuiltInSortField` at [compute-canvas-filters.ts](apps/mail/modules/crm/utils/compute-canvas-filters.ts) so the seeded `filterSortConfiguration` actually drives the sort.

**Tests:**

- [x] Integration/e2e (gated `E2E_CRM_INDEX=1`, live DB as jesse) — [conversation-index.e2e.test.ts](apps/server/src/services/crm/conversation-index.e2e.test.ts): `ensureTopDealsCanvas` twice returns the same canvas id (idempotent); viewConfig targets the Deals AOP + sorts `conversationIndex desc` + is not manually pinned; driving `crm.listConversations` with that sort returns `(important desc, conversationIndex desc)`-ordered deals. **6 e2e green.** Component test deferred (pure UI, heavy store deps — visual behavior manual-tested per the skill's frontend exception; the data path is headlessly proven).

### Phase 8 — Base-agent default flip (long-term)

**Goal:** the base agent opens Top Deals instead of the calendar agenda, gated on the seeded list existing.

- [x] Add `{ kind: 'canvas'; id }` to `DisplayArtifact` at [MessageTypes.ts](apps/mail/modules/cedar-os/src/store/messages/MessageTypes.ts).
- [x] Resolve the user's default Top Deals canvas in `getDisplayArtifact()` at [messagesSlice.ts](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts) via `findTopDealsCanvas(state.canvasesById)` ([top-deals.ts](apps/mail/modules/canvas/utils/top-deals.ts)) → `{ kind:'canvas', id }`, else `{ kind:'agenda' }`. **Gate:** reads only from already-loaded canvases (no new fetch) and the `agenda` fallback is unconditional when no Top Deals canvas is present, so users without one are unaffected.
- [x] Render `CardListCanvasView` for the `canvas` kind in [DisplayArtifactPanel](apps/mail/modules/home/components/DisplayArtifactPanel.tsx) — when nothing is explicitly open, a loaded Top Deals canvas renders the card list (lazy-loaded to preserve the crm ⇄ home cycle guard), else `AgendaMeetings`.

**Tests:**

- [x] Typecheck (touched files clean; pre-existing errors only) + `pnpm deps:check` clean (1085 modules). Component test deferred — pure UI, manual-tested per the skill's frontend exception. **Note for review:** the resolver reads `canvasesById`, which `useCanvases` populates with `homeViewOnly: true`. The seeded Top Deals canvas has `homeViewOrder = null`, so it is NOT loaded by the current hook and the flip stays dormant until either the canvas is pinned to HomeView or `useCanvases` is broadened to also fetch it — flagged as a deliberate open decision (the default-flip code is complete and correctly gated either way).