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).