Plan 01 — callytics-common Schema Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add the Phase 1 schema changes to callytics-common(new task_progress_events table, new tasks columns/index/CHECKs, updated enums, drizzle migration files), shipping @retaintive/common 跨过渡期 + final state 双兼容。

Architecture: 改 5 个文件 — src/db/schema/tasks.ts(enum + column 改)、src/db/schema/task-progress-events.ts(新建)、src/db/schema/task-ui.ts(UI const 改)、src/db/schema/index.ts(export)、drizzle/0010_phase1_transitional.sql(新 migration)。同时加 unit test 验证 enum + Drizzle table 定义。先 ship transitional 状态(三值 status enum + 旧字段保留),让 caller 在过渡期内能继续工作;final cutover 由 Plan 07 单独执行。

Tech Stack: TypeScript 5 / Drizzle ORM / drizzle-kit 0.20+ / bun test / PostgreSQL 16(Neon)/ @retaintive/common npm package

Spec source: docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md §2 + §6.2 transitional schema


File Structure

FilePurposeAction
src/db/schema/tasks.tstasks 表 + 6 个 enum const + CHECK + indexModify(加 3 enum / 加 3 column / 改 status enum 三值 / 加 partial unique)
src/db/schema/task-progress-events.ts新 task_progress_events 表 + 3 个新 enumCreate
src/db/schema/contact-timeline.tstask.progress_recorded 进 TIMELINE_EVENT_TYPESModify(Plan 06 §timeline event emit 依赖,Codex audit 抓的 missing)
src/db/schema/task-ui.tsUI label const(CLOSE_RESULT_OPTIONS / STATUS_OPTIONS 等)Modify(加 'open' STATUS_OPTIONS value / 加 PROGRESS_OPTIONS / closeResult 加 unable_to_reach)
src/db/schema/index.tsbarrel exportModify(加 task-progress-events export)
drizzle/0010_phase1_transitional.sqlmigration SQLCreate
tests/schema/task-progress-events.test.tsunit test for new table + enumsCreate
tests/schema/tasks-phase1.test.tsunit test for tasks 改动Create

Task 1: Add task_progress_events Drizzle table file

Files:

  • Create: src/db/schema/task-progress-events.ts
  • Create: tests/schema/task-progress-events.test.ts (unit — 纯 TS,不连 DB)
  • Create: tests/integration/task-progress-events.test.ts (integration — 连 test Neon)

Step 1: Map test scenarios (before writing any test code)

原则:schema 是 declarative,读 file 就能 verify 大部分东西。不 test framework / TS 编译保证 / DB 自带约束。只 test 真 invariant —— 违反会出 bug 那些。

#Scenario类型为什么 test 它
13 个 enum 值 + 顺序 exactly match normative-spec §2.1Unit防 spec drift —— 改 spec 时这条 test 会逼着同步改代码
2taskProgressEvents 有 15 个 expected column 名Unit防漏 column / typo column 名
3INSERT 完整合法 row → SELECT 可拿回Integration验 schema 能用 + Drizzle table def 跟实际 SQL 匹配
4INSERT duplicate idempotency_key → fail with UNIQUE violationIntegration关键 invariant —— idempotency_key 是 record_progress 唯一去重机制,违反 = attempt_count 翻倍 bug
5INSERT task_id 指向不存在 tasks 行 → fail with FK violationIntegration验 FK 真生效(Drizzle references() 不一定都 emit FK,要 verify)

不 test 的事(明确列出来让 implementer 知道为啥不写):

  • Enum as const immutable / TS narrowing — TypeScript 编译时强制
  • column nullability / DEFAULT NOW / index 存在 — read schema file 看就行,test 边际价值低
  • NOT NULL violation on 各 column — DB 自带约束,test PostgreSQL 本身没意义
  • enum CHECK violation —— Drizzle 用 text + enum hint,不是 PG enum type,PG 不强制,test 这条会假阳性

Step 2: Write tests (sample style — implementer 自己完成所有 case)

写 unit test 用 bun test BDD 风格。Sample(U1 — 让 implementer 知道 import + assertion 形态):

// tests/schema/task-progress-events.test.ts
import { describe, expect, test } from 'bun:test';
import {
  taskProgressEvents,
  TASK_PROGRESS_TYPE,
  TASK_CHANNEL,
  TASK_ACTOR_TYPE,
} from '../../src/db/schema/task-progress-events';

describe('task-progress-events schema — unit', () => {
  test('U1: TASK_PROGRESS_TYPE matches spec §2.1 exact order', () => {
    expect(TASK_PROGRESS_TYPE).toEqual([
      'no_answer',
      'left_voicemail',
      'text_sent',
      'callback_requested',
      'follow_up_scheduled',
      'customer_considering',
    ]);
  });

  // U2-U14: implementer 按上方 case map 全 cover
  // 整 file 估计 ~150 行 test code
});

Integration test 文件 sample(I1 — 让 implementer 知道连 DB pattern):

// tests/integration/task-progress-events.test.ts
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import { taskProgressEvents } from '../../src/db/schema/task-progress-events';
import { tasks } from '../../src/db/schema/tasks';

const sql = neon(process.env['DATABASE_URL_TEST']!);
const db = drizzle(sql);

describe('task-progress-events — DB integration', () => {
  let seedTaskId: string;
  beforeAll(async () => {
    // seed 1 个 task 给 FK reference 用
    const [task] = await db.insert(tasks).values({
      contactPhone: '+15555550001',
      franchiseId: 'TEST',
      accountId: 'TEST',
      storeId: 'STORE_TEST',
      taskType: 'follow_up',
      typeCategory: 'lead_follow_up',
      sourceType: 'manual',
      status: 'open',
    }).returning();
    seedTaskId = task!.taskId;
  });

  afterAll(async () => {
    // cleanup
    await db.delete(taskProgressEvents).where(/* by test storeId */);
    await db.delete(tasks).where(/* same */);
  });

  test('I1: INSERT 完整合法 row succeeds and is retrievable', async () => {
    const [row] = await db.insert(taskProgressEvents).values({
      taskId: seedTaskId,
      storeId: 'STORE_TEST',
      contactPhone: '+15555550001',
      progressType: 'no_answer',
      channel: 'phone',
      actorType: 'staff',
      callId: 'call_abc',
      occurredAt: new Date(),
      idempotencyKey: `progress:${seedTaskId}:call:call_abc:no_answer`,
    }).returning();
    expect(row).toBeDefined();
    expect(row?.taskId).toBe(seedTaskId);
  });

  // I2-I14: implementer 按 case map 全 cover
  // 整 file 估计 ~250 行 test code
});

Step 3: Run tests to verify they fail

Run:

cd /Users/maxwsy/workspace/callytics-common && bun test tests/schema/task-progress-events.test.ts

Expected: FAIL with "Cannot find module '../../src/db/schema/task-progress-events'"

Integration tests will additionally fail to import schema. 暂不跑 integration test — 等 Task 5 migration apply 后再跑。

  • Step 3: Implement the schema file

Create src/db/schema/task-progress-events.ts:

import {
  pgTable,
  text,
  timestamp,
  uuid,
  bigint,
  index,
  uniqueIndex,
  check,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

// ─── Enum definitions (single source of truth) ──────────────────

export const TASK_PROGRESS_TYPE = [
  'no_answer',
  'left_voicemail',
  'text_sent',
  'callback_requested',
  'follow_up_scheduled',
  'customer_considering',
] as const;
export type TaskProgressType = (typeof TASK_PROGRESS_TYPE)[number];

export const TASK_CHANNEL = ['phone', 'sms', 'voicemail', 'email'] as const;
export type TaskChannel = (typeof TASK_CHANNEL)[number];

export const TASK_ACTOR_TYPE = ['staff', 'system', 'ai_agent', 'contact_analysis'] as const;
export type TaskActorType = (typeof TASK_ACTOR_TYPE)[number];

// ─── Table definition ───────────────────────────────────────────

/**
 * task_progress_events — Progress 事件 source of truth
 *
 * 每次员工 / 系统 / AI 对某个 open task 做了一次"进展"动作,就 INSERT 一行。
 * 不 close task。例如:打电话没接 → no_answer;留语音邮件 → left_voicemail;
 * 发短信 → text_sent。task 仍 open,attemptCount += 1。
 *
 * 替代旧设计:closeResult 里混合的 'no_answer' / 'left_voicemail' / 'callback_later'
 * / 'attempted' 这 4 个 "其实是 progress,不是 outcome" 的值。
 *
 * 多租户隔离:store_id 冗余存储,避免 join tasks 查 store guard。
 *
 * 唯一去重键:idempotency_key UNIQUE。不用 (task_id, call_id, progress_type)
 * 复合 unique 因为 PostgreSQL NULL ≠ NULL —— SMS progress (call_id IS NULL)
 * 会被重复插入。caller 生成 idempotency_key 见 normative-spec §3.2。
 *
 * 设计文档:docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md §2.1
 */
export const taskProgressEvents = pgTable(
  'task_progress_events',
  {
    eventId: uuid('event_id').primaryKey().defaultRandom(),
    /**
     * FK → tasks.task_id(normative-spec §2.1 line 39 明文要求)。
     * 没 FK 会导致 orphan event row(task 被 hard-delete 时 event 残留)。
     * Phase 1 不做 cascade delete(spec 没要求),默认 NO ACTION 让 DB 阻止
     * task 删除直到 event 被 archive。
     */
    taskId: uuid('task_id').notNull().references(() => tasks.taskId),
    storeId: text('store_id').notNull(),
    contactPhone: text('contact_phone').notNull(),
    progressType: text('progress_type', { enum: TASK_PROGRESS_TYPE }).notNull(),
    channel: text('channel', { enum: TASK_CHANNEL }).notNull(),
    actorType: text('actor_type', { enum: TASK_ACTOR_TYPE }).notNull(),
    actorId: uuid('actor_id'),
    /**
     * evidence reference,no FK。
     * calls 表 PK 是 telephony_session_id(text)而非 uuid;且 call 可能在 progress
     * 写入之后才落库,加 FK 会引入时序耦合。Phase 1 不加 FK,只作 reference。
     */
    callId: text('call_id'),
    /**
     * evidence reference,no FK。
     * messages.id 是 bigint。同理不加 FK 避免时序耦合。
     */
    messageId: bigint('message_id', { mode: 'bigint' }),
    note: text('note'),
    nextDueAt: timestamp('next_due_at', { withTimezone: true }),
    occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
    /**
     * NOT NULL UNIQUE —— 全局唯一去重键。
     * 生成规则见 normative-spec §3.2:
     *   电话:`progress:{taskId}:call:{callId}:{progressType}`
     *   SMS:`progress:{taskId}:message:{messageId}:{progressType}`
     *   手动:`progress:{taskId}:manual:{actorId}:{occurredAt}:{progressType}`
     *   系统:`progress:{taskId}:system:{runId}:{progressType}`
     */
    idempotencyKey: text('idempotency_key').notNull().unique(),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (table) => [
    // task 维度查询(API 拉某 task 的全部进展)
    index('idx_tpe_task_occurred').on(table.taskId, table.occurredAt.desc()),
    // store 维度查询(dashboard "attempt workload")
    index('idx_tpe_store_occurred').on(table.storeId, table.occurredAt.desc()),
    // actor 维度查询(staff 个人产出统计)
    index('idx_tpe_actor').on(table.actorType, table.actorId, table.occurredAt.desc()),
    // progress 类型 + channel 统计
    index('idx_tpe_type_channel').on(table.progressType, table.channel, table.occurredAt.desc()),
    /*
     * DB-level CHECK constraints — normative-spec §2.1 line 42-47 明文要求。
     * Drizzle text+enum 只 TS 层验 — 任何 SQL 直插(staff ad-hoc / migration /
     * 旧 caller 没升级 client) 会绕过 TS 检查。CHECK 在 DB 层守 schema 不变量。
     */
    check('chk_tpe_progress_type', sql`progress_type IN (${sql.raw(TASK_PROGRESS_TYPE.map(v => `'${v}'`).join(', '))})`),
    check('chk_tpe_channel', sql`channel IN (${sql.raw(TASK_CHANNEL.map(v => `'${v}'`).join(', '))})`),
    check('chk_tpe_actor_type', sql`actor_type IN (${sql.raw(TASK_ACTOR_TYPE.map(v => `'${v}'`).join(', '))})`),
  ]
);
  • Step 4: Run test to verify it passes

Run:

cd /Users/maxwsy/workspace/callytics-common && bun test tests/schema/task-progress-events.test.ts

Expected: PASS — 4 tests pass

  • Step 5: Commit
cd /Users/maxwsy/workspace/callytics-common
git add src/db/schema/task-progress-events.ts tests/schema/task-progress-events.test.ts
git commit -m "feat(schema): add task_progress_events table + 3 new enums

Phase 1 Plan 01 Task 1.

新表替代旧设计里 closeResult 混合的 4 个 progress 值
(no_answer / left_voicemail / callback_later / attempted)。
task 不再因为 progress 被 close;改成 INSERT 一行 progress event +
tasks.attempt_count++。

Spec: docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md §2.1
"

Task 2: Update src/db/schema/tasks.ts — transitional TASK_STATUS enum + new columns

Files:

  • Modify: src/db/schema/tasks.ts:16 (TASK_STATUS), :32 (TASK_CLOSE_TYPE), :38-54 (TASK_CLOSE_RESULT), :90-309 (table definition)

  • Test: tests/schema/tasks-phase1.test.ts

  • Step 1: Write the failing test

Create tests/schema/tasks-phase1.test.ts:

import { describe, expect, test } from 'bun:test';
import {
  TASK_STATUS,
  TASK_CLOSE_TYPE,
  TASK_CLOSE_RESULT,
  tasks,
} from '../../src/db/schema/tasks';

describe('tasks schema — Phase 1 transitional', () => {
  test('TASK_STATUS has 3 values during transitional window: pending, open, closed', () => {
    expect(TASK_STATUS).toEqual(['pending', 'open', 'closed']);
  });

  test('TASK_CLOSE_TYPE adds create_closed (3 values total)', () => {
    expect(TASK_CLOSE_TYPE).toEqual(['auto_closed', 'manual_closed', 'create_closed']);
  });

  test('TASK_CLOSE_RESULT adds unable_to_reach (19 values total during transitional)', () => {
    expect(TASK_CLOSE_RESULT).toContain('unable_to_reach');
    // transitional: old 18 + new unable_to_reach = 19; final cutover (Plan 07) shrinks to 15
    expect(TASK_CLOSE_RESULT.length).toBe(19);
  });

  test('tasks table exposes new columns: executorType, attemptCount, sourceCallId', () => {
    const columns = Object.keys(tasks);
    expect(columns).toContain('executorType');
    expect(columns).toContain('attemptCount');
    expect(columns).toContain('sourceCallId');
  });
});
  • Step 2: Run test to verify it fails

Run:

cd /Users/maxwsy/workspace/callytics-common && bun test tests/schema/tasks-phase1.test.ts

Expected: FAIL — TASK_STATUS does not include 'open';TASK_CLOSE_TYPE length is 2;unable_to_reach not in TASK_CLOSE_RESULT;tasks.executorType undefined

  • Step 3: Update TASK_STATUS enum

Edit src/db/schema/tasks.ts line 16:

// BEFORE:
export const TASK_STATUS = ['pending', 'closed'] as const;

// AFTER:
/**
 * Phase 1 transitional: 3 values during compatibility window.
 * Final cutover (Plan 07) shrinks back to ['open', 'closed'].
 *   - 'pending' — legacy value, kept readable during transition
 *   - 'open'    — new canonical value, all new writes use this
 *   - 'closed'  — terminal state (unchanged)
 * Read paths must use status IN ('pending', 'open') for "open work" queries
 * until cutover migration runs (UPDATE pending → open + DROP from enum).
 * Spec: normative-spec.md §6.2
 */
export const TASK_STATUS = ['pending', 'open', 'closed'] as const;
  • Step 4: Update TASK_CLOSE_TYPE enum

Edit src/db/schema/tasks.ts line 32:

// BEFORE:
export const TASK_CLOSE_TYPE = ['auto_closed', 'manual_closed'] as const;

// AFTER:
/**
 * Phase 1: add 'create_closed' for tasks created already-closed
 * (e.g. inbound 当场成交 → 直接建 closed task,让 dashboard 能看到完成事项)。
 * Spec: normative-spec.md §2.2 + unified-pipeline-final.md Task Pipeline Deliverable
 */
export const TASK_CLOSE_TYPE = ['auto_closed', 'manual_closed', 'create_closed'] as const;
  • Step 5: Update TASK_CLOSE_RESULT enum

Edit src/db/schema/tasks.ts lines 38-54. Add 'unable_to_reach' to the array:

// BEFORE: 18 values
export const TASK_CLOSE_RESULT = [
  'attempted', 'converted', 'win_back', 'issue_resolved',
  'cancel_saved', 'renewed', 'upgraded', 'referral_obtained',
  'wrong_number', 'do_not_contact', 'other',
  'no_answer', 'left_voicemail', 'not_interested', 'callback_later', 'already_member',
  /* ... existing comment about booked/cancelled ... */
  'booked', 'cancelled',
] as const;

// AFTER: 19 values transitional (final cutover shrinks to 15 by removing 4 progress values)
export const TASK_CLOSE_RESULT = [
  'attempted', 'converted', 'win_back', 'issue_resolved',
  'cancel_saved', 'renewed', 'upgraded', 'referral_obtained',
  'wrong_number', 'do_not_contact', 'other',
  'no_answer', 'left_voicemail', 'not_interested', 'callback_later', 'already_member',
  /*
   * Added 2026-05-23 for OTF V1 prompt refinements (callytics-infrastructure
   * PR #980 series). Distinguish customer-facing outcomes the AI must record
   * on task close that the prior 16 values couldn't express cleanly:
   *   - `booked`: lead has a confirmed upcoming appointment/class/intro. Not
   *     the same as `converted` (paid membership) — booked may still no-show.
   *   - `cancelled`: cancellation request approved OR cancellation form sent
   *     to customer. Not the same as `cancel_saved` (customer was talked out
   *     of cancelling and is staying).
   */
  'booked', 'cancelled',
  /*
   * Added 2026-06 Phase 1 (normative-spec §2.2): represents "max attempts
   * reached, contact could not be reached" — the only legitimate "negative
   * outcome" after Phase 1 moves no_answer / left_voicemail / attempted into
   * task_progress_events. Final cutover (Plan 07) removes the 4 progress
   * values, leaving 15 outcome-only values.
   */
  'unable_to_reach',
] as const;
  • Step 6: Add new columns to tasks pgTable

Edit src/db/schema/tasks.ts — inside pgTable('tasks', { ... }) add 3 new columns. Find the section after closeNote / note (around line 213) and before createdAt (line 217):

    /* existing columns up to note ... */
    note: text('note'),

    // ── Phase 1 新增字段(normative-spec §2.2)──

    /**
     * 执行 / 完成者类型,metrics 归因用。
     * 'human'     — staff 通过 UI 操作
     * 'ai_agent'  — future tool calling AI agent
     * 'system'    — pipeline 自动(e.g. DNC cascade)
     * Phase 1 大多数 task 是 human / system,ai_agent 留给 future。
     */
    executorType: text('executor_type', {
      enum: ['human', 'ai_agent', 'system'] as const,
    }),
    // CHECK 见 Step 7(connected to existing chk_tasks_* checks block)

    /**
     * 列表展示用 snapshot,真实 source of truth 是 task_progress_events 表。
     * Orchestrator 在 record_progress action 内用 CTE 保证只在真插入 progress event 时
     * 才 increment(normative-spec §3.2 B8 fix)。
     */
    attemptCount: integer('attempt_count').notNull().default(0),

    /**
     * create_closed task 的 evidence reference,对应触发"当场办成"的 call ID。
     * 用于 partial unique 防同一通电话重复建 closed task(normative-spec §3.1)。
     * 普通 create_open / staff manual close 时为 NULL。
     */
    sourceCallId: text('source_call_id'),

    // ── 系统时间戳 ──

    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
    /* ... rest unchanged ... */

Important: Also add integer to the import at the top of the file:

// BEFORE:
import {
  pgTable, text, timestamp, uuid, boolean, jsonb,
  index, uniqueIndex, check,
} from 'drizzle-orm/pg-core';

// AFTER:
import {
  pgTable, text, timestamp, uuid, boolean, jsonb, integer,
  index, uniqueIndex, check,
} from 'drizzle-orm/pg-core';
  • Step 7: Update CHECK constraints — transitional window

Edit src/db/schema/tasks.ts line 262 — replace chk_tasks_closed_integrity:

// BEFORE:
check('chk_tasks_closed_integrity', sql`
  (status = 'pending' AND close_type IS NULL AND closed_at IS NULL)
  OR
  (status = 'closed' AND close_type IS NOT NULL AND closed_at IS NOT NULL)
`),

// AFTER (transitional — accepts both 'pending' and 'open' as open-work state):
check('chk_tasks_closed_integrity', sql`
  (status IN ('pending', 'open') AND close_type IS NULL AND closed_at IS NULL)
  OR
  (status = 'closed' AND close_type IS NOT NULL AND closed_at IS NOT NULL)
`),

Edit src/db/schema/tasks.ts line 276 — update chk_tasks_close_result to include 'unable_to_reach':

// BEFORE:
check('chk_tasks_close_result', sql`
  close_result IS NULL OR close_result = ANY(ARRAY[
    'attempted', 'converted', 'win_back', 'issue_resolved',
    'cancel_saved', 'renewed', 'upgraded', 'referral_obtained',
    'wrong_number', 'do_not_contact', 'other',
    'no_answer', 'left_voicemail', 'not_interested', 'callback_later', 'already_member',
    'booked', 'cancelled'
  ])
`),

// AFTER (adds 'unable_to_reach'):
check('chk_tasks_close_result', sql`
  close_result IS NULL OR close_result = ANY(ARRAY[
    'attempted', 'converted', 'win_back', 'issue_resolved',
    'cancel_saved', 'renewed', 'upgraded', 'referral_obtained',
    'wrong_number', 'do_not_contact', 'other',
    'no_answer', 'left_voicemail', 'not_interested', 'callback_later', 'already_member',
    'booked', 'cancelled',
    'unable_to_reach'
  ])
`),
  • Step 8: Update partial unique indexes — transitional window

Edit src/db/schema/tasks.ts line 254 — uq_tasks_pending_contact_category WHERE clause:

// BEFORE:
uniqueIndex('uq_tasks_pending_contact_category')
  .on(table.contactPhone, table.storeId, table.typeCategory)
  .where(sql`status = 'pending' AND store_id IS NOT NULL`),

// AFTER (transitional — covers both 'pending' and 'open' open-state names):
uniqueIndex('uq_tasks_pending_contact_category')
  .on(table.contactPhone, table.storeId, table.typeCategory)
  .where(sql`status IN ('pending', 'open') AND store_id IS NOT NULL`),

Add NEW partial unique for create_closed dedup. Insert after uq_tasks_pending_contact_category:

// #8b create_closed 防重 — 同 (contactPhone, storeId, typeCategory, sourceCallId)
// 在 closed 状态下只允许 1 行。sourceCallId IS NOT NULL 排除普通 manual close。
// 防止同一通电话被同一个 caller 重复建 closed task。
uniqueIndex('uq_tasks_create_closed_evidence')
  .on(table.contactPhone, table.storeId, table.typeCategory, table.sourceCallId)
  .where(sql`status = 'closed' AND source_call_id IS NOT NULL`),
  • Step 8c: Add DB-level CHECK for executor_type

normative-spec §2.2 line 89-91 明文要求 DB CHECK,不能只靠 Drizzle text+enum hint(那只是 TS 检查,SQL 直插 / 旧 client 都绕过)。

// Insert in the same constraints block(near chk_tasks_close_result):
check('chk_tasks_executor_type', sql`
  executor_type IS NULL OR executor_type = ANY(ARRAY['human', 'ai_agent', 'system'])
`),
  • Step 9: Run test to verify it passes

Run:

cd /Users/maxwsy/workspace/callytics-common && bun test tests/schema/tasks-phase1.test.ts

Expected: PASS — 4 tests pass

  • Step 10: Run full test suite to verify no regression

Run:

cd /Users/maxwsy/workspace/callytics-common && bun test

Expected: All existing tests + 2 new test files pass. If existing tests fail because they assumed 2-value TASK_STATUS, update those tests to accept 3-value transitional enum.

  • Step 11: Commit
cd /Users/maxwsy/workspace/callytics-common
git add src/db/schema/tasks.ts tests/schema/tasks-phase1.test.ts
git commit -m "feat(schema): tasks Phase 1 transitional — 3-value status + new columns + create_closed dedup

Phase 1 Plan 01 Task 2.

- TASK_STATUS = ['pending','open','closed'] (3-value transitional;
  Plan 07 cutover shrinks to ['open','closed'])
- TASK_CLOSE_TYPE += 'create_closed'
- TASK_CLOSE_RESULT += 'unable_to_reach' (19-value transitional;
  Plan 07 cutover removes 4 progress values → 15-value final)
- New columns: executor_type, attempt_count (NOT NULL DEFAULT 0), source_call_id
- chk_tasks_closed_integrity 接受 status IN ('pending','open')
- chk_tasks_close_result 加 unable_to_reach
- uq_tasks_pending_contact_category WHERE 改 status IN ('pending','open')
- NEW: uq_tasks_create_closed_evidence partial unique
  防同一通电话(source_call_id)重复 create_closed task

Spec: docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md §2.2, §6.2
"

Task 3: Update task-ui.ts — STATUS_OPTIONS / CLOSE_RESULT_OPTIONS / new PROGRESS_OPTIONS

Files:

  • Modify: src/db/schema/task-ui.ts

  • Test: tests/schema/task-ui-phase1.test.ts

  • Step 1: Write the failing test

Create tests/schema/task-ui-phase1.test.ts:

import { describe, expect, test } from 'bun:test';
import {
  STATUS_OPTIONS,
  CLOSE_RESULT_OPTIONS,
  PROGRESS_OPTIONS,
} from '../../src/db/schema/task-ui';

describe('task-ui phase 1', () => {
  test('STATUS_OPTIONS includes open with label Open', () => {
    const openOpt = STATUS_OPTIONS.find((o) => o.value === 'open');
    expect(openOpt).toBeDefined();
    expect(openOpt?.label).toBe('Open');
  });

  test('CLOSE_RESULT_OPTIONS includes unable_to_reach', () => {
    const opt = CLOSE_RESULT_OPTIONS.find((o) => o.value === 'unable_to_reach');
    expect(opt).toBeDefined();
    expect(opt?.label).toBe('Unable to Reach');
  });

  test('PROGRESS_OPTIONS lists 6 values with UI labels', () => {
    expect(PROGRESS_OPTIONS.length).toBe(6);
    const values = PROGRESS_OPTIONS.map((o) => o.value);
    expect(values).toEqual([
      'no_answer',
      'left_voicemail',
      'text_sent',
      'callback_requested',
      'follow_up_scheduled',
      'customer_considering',
    ]);
    const noAnswer = PROGRESS_OPTIONS.find((o) => o.value === 'no_answer');
    expect(noAnswer?.label).toBe('No Answer');
  });
});
  • Step 2: Run test to verify it fails

Run:

cd /Users/maxwsy/workspace/callytics-common && bun test tests/schema/task-ui-phase1.test.ts

Expected: FAIL — STATUS_OPTIONS has no 'open' entry;CLOSE_RESULT_OPTIONS has no 'unable_to_reach';PROGRESS_OPTIONS undefined export

  • Step 3: Update STATUS_OPTIONS

Read current STATUS_OPTIONS (around line 59 of task-ui.ts) and add the 'open' entry. Keep 'pending' during transitional window:

// BEFORE (assuming current shape):
export const STATUS_OPTIONS: readonly StatusOption[] = [
  { value: 'pending', label: 'Open' },
  { value: 'closed', label: 'Closed' },
] as const;

// AFTER (transitional — both 'pending' and 'open' map to UI label "Open"):
export const STATUS_OPTIONS: readonly StatusOption[] = [
  // Legacy value during Phase 1 transitional window; final cutover (Plan 07)
  // removes 'pending' entry. UI label stays "Open" for both during transition.
  { value: 'pending', label: 'Open' },
  { value: 'open', label: 'Open' },
  { value: 'closed', label: 'Closed' },
] as const;
  • Step 4: Add unable_to_reach to CLOSE_RESULT_OPTIONS

Append to the existing CLOSE_RESULT_OPTIONS array:

// Add this entry to the array (preserve all existing entries):
{ value: 'unable_to_reach', label: 'Unable to Reach', category: 'negative', description: 'Max attempts reached, contact could not be reached' },
  • Step 5: Add new PROGRESS_OPTIONS export

After CLOSE_RESULT_OPTIONS, add:

/**
 * Progress 类型 UI label 列表。
 * 对应 task_progress_events.progress_type enum(TASK_PROGRESS_TYPE in
 * task-progress-events.ts)。staff 用 progress endpoint 时前端 dropdown 用这个。
 *
 * 注意:no_answer / left_voicemail 现在是 progress(不再是 closeResult)。
 * 旧 UI 把这两个 + callback_later 放在 close dropdown 里,Phase 1 后必须移到 progress dropdown。
 */
export interface ProgressOption {
  value: TaskProgressType;
  label: string;
  description: string;
}

import type { TaskProgressType } from './task-progress-events';

export const PROGRESS_OPTIONS: readonly ProgressOption[] = [
  { value: 'no_answer', label: 'No Answer', description: 'Called but no one picked up' },
  { value: 'left_voicemail', label: 'Left Voicemail', description: 'Left a voicemail message' },
  { value: 'text_sent', label: 'Text Sent', description: 'Sent SMS, waiting for reply' },
  { value: 'callback_requested', label: 'Callback Requested', description: 'Customer asked to be called back' },
  { value: 'follow_up_scheduled', label: 'Follow-up Scheduled', description: 'Staff scheduled next contact attempt' },
  { value: 'customer_considering', label: 'Customer Considering', description: 'Customer needs time to think' },
] as const;
  • Step 6: Run test to verify it passes

Run:

cd /Users/maxwsy/workspace/callytics-common && bun test tests/schema/task-ui-phase1.test.ts

Expected: PASS — 3 tests pass

  • Step 7: Commit
cd /Users/maxwsy/workspace/callytics-common
git add src/db/schema/task-ui.ts tests/schema/task-ui-phase1.test.ts
git commit -m "feat(schema): task-ui Phase 1 — STATUS_OPTIONS adds 'open' + PROGRESS_OPTIONS + unable_to_reach

Phase 1 Plan 01 Task 3.

- STATUS_OPTIONS: 'pending' 和 'open' 双值并存(transitional);UI label 同为 'Open'
- CLOSE_RESULT_OPTIONS += unable_to_reach
- NEW: PROGRESS_OPTIONS 6 values for staff progress endpoint dropdown

Spec: normative-spec.md §2.2
"

Task 3.5: Add task.progress_recorded to TIMELINE_EVENT_TYPES

Files:

  • Modify: src/db/schema/contact-timeline.ts(加 1 个 enum value)
  • Modify: tests/schema/contact-timeline.test.ts(若有,同步)

Why: Plan 06(studio-api)record_progress endpoint emit eventType='task.progress_recorded' timeline event。但 live contact-timeline.ts:21-49TIMELINE_EVENT_TYPES 不含此值,DB CHECK constraint 会 reject INSERT。Codex audit 抓到的 missing。

  • Step 1: Locate TIMELINE_EVENT_TYPES const

Read src/db/schema/contact-timeline.ts lines 21-49 — find the const array.

  • Step 2: Add task.progress_recorded 到 enum array
// BEFORE: 15 values(verbatim list per current schema)
export const TIMELINE_EVENT_TYPES = [
  'call.completed',
  'message.received',
  // ... 13 more
] as const;

// AFTER: 16 values(加 'task.progress_recorded')
export const TIMELINE_EVENT_TYPES = [
  'call.completed',
  'message.received',
  // ... 13 more
  'task.progress_recorded',  // NEW(Phase 1 Plan 06 record_progress endpoint)
] as const;
  • Step 3: Update DB CHECK constraint(if separate from enum)

If contact-timeline.ts has a check('chk_timeline_event_type', ...) constraint, 必须同步加 'task.progress_recorded'。否则 DB 仍 reject。

  • Step 4: Migration SQL

Add to drizzle/0010_phase1_transitional.sql:

-- Phase 1: add task.progress_recorded to timeline event types
ALTER TABLE contact_timeline DROP CONSTRAINT IF EXISTS chk_timeline_event_type;
ALTER TABLE contact_timeline ADD CONSTRAINT chk_timeline_event_type CHECK (
  event_type = ANY(ARRAY[
    -- ... existing 15 values verbatim ...
    'task.progress_recorded'  -- NEW
  ])
);
  • Step 5: Run schema test + integration
cd /Users/maxwsy/workspace/callytics-common && bun test contact-timeline

Expected: PASS — TIMELINE_EVENT_TYPES includes new value, INSERT with event_type='task.progress_recorded' succeeds.

  • Step 6: Commit
cd /Users/maxwsy/workspace/callytics-common
git add src/db/schema/contact-timeline.ts drizzle/0010_phase1_transitional.sql
git commit -m "feat(schema): contact_timeline + 'task.progress_recorded' event type

Phase 1 Plan 01 Task 3.5(Codex audit 2026-06-02 抓的 missing).

Plan 06 studio-api record_progress endpoint emit
eventType='task.progress_recorded' timeline event(see normative-spec.md §3.5 line 417-420)。
live TIMELINE_EVENT_TYPES + DB CHECK 没此值 → INSERT 被 reject。

加 enum + 同步 DB CHECK 解 missing。
"

Task 4: Export task-progress-events from barrel index.ts

Files:

  • Modify: src/db/schema/index.ts

  • Step 1: Read current barrel file

Read src/db/schema/index.ts to see current export pattern.

  • Step 2: Add export for task-progress-events

Add this line in src/db/schema/index.ts, following the same pattern as other schema exports (alphabetical placement):

export * from './task-progress-events';
  • Step 3: Verify package builds

Run:

cd /Users/maxwsy/workspace/callytics-common && bun run build

Expected: TypeScript compiles without errors. dist/db/schema/task-progress-events.js exists.

  • Step 4: Verify external consumer can import

Run:

cd /Users/maxwsy/workspace/callytics-common && cat << 'EOF' | bun run --bun -
import { taskProgressEvents, TASK_PROGRESS_TYPE } from './src/db/schema';
console.log('taskProgressEvents keys:', Object.keys(taskProgressEvents).slice(0, 5));
console.log('TASK_PROGRESS_TYPE:', TASK_PROGRESS_TYPE);
EOF

Expected: prints column names + 6 enum values without errors.

  • Step 5: Commit
cd /Users/maxwsy/workspace/callytics-common
git add src/db/schema/index.ts
git commit -m "feat(schema): export task-progress-events from barrel

Phase 1 Plan 01 Task 4.

让 caller(callytics-infrastructure / studio-api / lead-tracking)能
import { taskProgressEvents, TASK_PROGRESS_TYPE, ... } from '@retaintive/common/db'。
"

Task 5: Generate Drizzle migration SQL drizzle/0010_phase1_transitional.sql

Files:

  • Create: drizzle/0010_phase1_transitional.sql

  • (Auto-generated) drizzle/meta/_journal.json will be updated by drizzle-kit

  • Step 1: Set DATABASE_URL to test Neon

Confirm DATABASE_URL env var points to test Neon:

cd /Users/maxwsy/workspace/callytics-common
echo $DATABASE_URL | grep -q neon && echo "OK: Neon URL set" || echo "MISSING: set DATABASE_URL to test Neon connection string"

Expected: prints OK: Neon URL set. If missing, source .env or export the URL before continuing.

  • Step 2: Generate migration

Run drizzle-kit to generate migration SQL from the updated schema:

cd /Users/maxwsy/workspace/callytics-common
bunx drizzle-kit generate --name=phase1_transitional

Expected: a new file drizzle/0010_phase1_transitional.sql is created. drizzle-kit prints Saved 1 migration. _journal.json updated.

  • Step 3: Inspect generated SQL
cd /Users/maxwsy/workspace/callytics-common && cat drizzle/0010_phase1_transitional.sql

Verify the SQL contains:

  1. CREATE TABLE "task_progress_events" with all 15 columns
  2. CREATE UNIQUE INDEX ... idempotency_key
  3. CREATE INDEX "idx_tpe_task_occurred" (and 3 more)
  4. ALTER TABLE "tasks" ADD COLUMN "executor_type" etc.
  5. ALTER TABLE "tasks" DROP CONSTRAINT "chk_tasks_closed_integrity" (then re-create with transitional CHECK)
  6. DROP INDEX "uq_tasks_pending_contact_category" (then re-create with transitional WHERE)
  7. CREATE UNIQUE INDEX "uq_tasks_create_closed_evidence"

If any of these are missing, the schema changes are incomplete. Stop and fix the schema file before continuing.

  • Step 4: Hand-review the generated SQL for safety

Specifically verify:

  • No DROP COLUMN statements (this migration is additive only — destructive DROP belongs to Plan 07 cutover)
  • No UPDATE data statements (data migration also belongs to Plan 07)
  • No ALTER COLUMN ... SET NOT NULL on tasks.store_id (still nullable during transitional)

If drizzle-kit generated any of these, manually edit the SQL file to comment out / remove. Add a header comment at the top:

-- Phase 1 Plan 01 — ADDITIVE-ONLY migration
-- 不包含任何 DROP COLUMN / UPDATE data / store_id SET NOT NULL。
-- 那些 destructive 操作由 Plan 07 cutover migration 单独执行。
-- Spec: docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md §6.2
  • Step 5: Apply migration to test Neon
cd /Users/maxwsy/workspace/callytics-common
bunx drizzle-kit push

Expected: drizzle-kit prints Changes applied. No errors.

  • Step 6: Verify schema in Neon
cd /Users/maxwsy/workspace/callytics-common
psql $DATABASE_URL -c "\d task_progress_events"
psql $DATABASE_URL -c "\d tasks" | grep -E "executor_type|attempt_count|source_call_id"
psql $DATABASE_URL -c "SELECT conname FROM pg_constraint WHERE conrelid = 'tasks'::regclass AND conname LIKE 'chk_%';"
psql $DATABASE_URL -c "SELECT indexname FROM pg_indexes WHERE tablename = 'tasks' AND indexname LIKE 'uq_%';"

Expected output:

  • task_progress_events exists with 15 columns + 1 unique constraint(idempotency_key)+ 4 indexes
  • tasks has new columns executor_type / attempt_count / source_call_id
  • CHECK constraints chk_tasks_closed_integrity exists with status IN ('pending','open')
  • partial unique uq_tasks_pending_contact_category exists with updated WHERE clause
  • new partial unique uq_tasks_create_closed_evidence exists

If any verification fails, rollback by checking out the migration file before push, then re-generate.

  • Step 6.b: Add DB invariant tests for transitional constraints

Create / update tests/integration/tasks-transitional-schema.test.ts.

These tests verify application-level schema invariants that are easy to regress when the generated SQL is hand-edited:

#InvariantTest setupExpected
1chk_tasks_closed_integrity accepts transitional open statesINSERT status='pending' with all close fields null; INSERT status='open' with all close fields nullboth pass
2chk_tasks_closed_integrity rejects invalid closed rowsINSERT status='closed' without closed_at / close_type / close_resultCHECK violation
3uq_tasks_pending_contact_category covers both pending and openINSERT one pending task then one open task for same (contact_phone, store_id, type_category)UNIQUE violation
4uq_tasks_create_closed_evidence enforces closed evidence dedupeINSERT two closed create_closed rows with same (contact_phone, store_id, type_category, source_call_id)UNIQUE violation

Run:

cd /Users/maxwsy/workspace/callytics-common
bun test tests/integration/tasks-transitional-schema.test.ts

Expected: all PASS. Do not replace these with psql \d checks; \d verifies shape, not behavior.

  • Step 7: Commit migration file
cd /Users/maxwsy/workspace/callytics-common
git add drizzle/0010_phase1_transitional.sql drizzle/meta/_journal.json
git commit -m "feat(migration): 0010 Phase 1 transitional — additive only

Phase 1 Plan 01 Task 5.

ADDITIVE only:
- CREATE TABLE task_progress_events (15 columns + 4 indexes + idempotency_key UNIQUE)
- ALTER TABLE tasks ADD COLUMN executor_type / attempt_count / source_call_id
- Recreate chk_tasks_closed_integrity accepting status IN ('pending','open')
- Recreate chk_tasks_close_result with unable_to_reach
- Recreate uq_tasks_pending_contact_category WHERE status IN ('pending','open')
- CREATE UNIQUE INDEX uq_tasks_create_closed_evidence

不包含 DROP COLUMN / UPDATE data / store_id SET NOT NULL。
那些 destructive 操作由 Plan 07 cutover 执行。

Applied to test Neon manually via drizzle-kit push.
Spec: normative-spec.md §6.2
"

Task 6: Bump @retaintive/common version + publish to GitHub Packages

Files:

  • Modify: package.json:5 (version field)

  • Step 1: Decide version bump

Current version: read package.json:

cd /Users/maxwsy/workspace/callytics-common && jq -r .version package.json

This Phase 1 change adds new exports and new columns to existing table — semver minor bump. If current is 1.0.0, bump to 1.1.0.

  • Step 2: Bump version

Edit package.json. Replace "version": "1.0.0" (or whatever the current value is) with "version": "1.1.0".

  • Step 3: Verify build artifact has new exports
cd /Users/maxwsy/workspace/callytics-common
bun run build
grep -r "task-progress-events" dist/ | head -5

Expected: at least one match in dist/db/schema/index.d.ts or similar.

  • Step 4: Run full test suite one more time
cd /Users/maxwsy/workspace/callytics-common && bun test

Expected: all tests pass, including the 3 new schema test files.

  • Step 5: Commit version bump
cd /Users/maxwsy/workspace/callytics-common
git add package.json
git commit -m "chore: bump @retaintive/common to 1.1.0

Phase 1 Plan 01 Task 6.

Minor bump for additive Phase 1 schema:
- new exports: taskProgressEvents, TASK_PROGRESS_TYPE,
  TASK_CHANNEL, TASK_ACTOR_TYPE, PROGRESS_OPTIONS
- new columns: tasks.executor_type, tasks.attempt_count, tasks.source_call_id
- enum widening: TASK_STATUS, TASK_CLOSE_TYPE, TASK_CLOSE_RESULT

All Phase 1 caller PRs (Plans 02-06) pin this version.
"
  • Step 6: Push branch + create PR
cd /Users/maxwsy/workspace/callytics-common
git push -u origin <feature-branch-name>
gh pr create --title "feat(schema): Phase 1 — task_progress_events + tasks transitional schema" \
  --body-file <(cat << 'EOF'
## Phase 1 Plan 01

Implements the additive schema changes for unified-pipeline Phase 1.

### What's added

- New table `task_progress_events` (15 columns, idempotency_key UNIQUE, 4 indexes)
- New `tasks` columns: `executor_type`, `attempt_count`, `source_call_id`
- New enums: `TASK_PROGRESS_TYPE`, `TASK_CHANNEL`, `TASK_ACTOR_TYPE`
- Enum widening: `TASK_STATUS` += `'open'`, `TASK_CLOSE_TYPE` += `'create_closed'`, `TASK_CLOSE_RESULT` += `'unable_to_reach'`
- New partial unique: `uq_tasks_create_closed_evidence`
- Transitional CHECK / partial unique WHERE clauses accept both `'pending'` and `'open'`

### What's NOT here

- No DROP COLUMN
- No `UPDATE pending → open`
- No `tasks.store_id SET NOT NULL`

Those destructive operations are Plan 07 cutover, only after Plans 02-06 caller migrations all ship.

### Test

- 3 new schema unit test files, all pass
- Migration applied to test Neon, verified via `psql \d` + index inspection
- Full test suite green

### Spec

See `docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md` §2 + §6.2 in the docs repo.
EOF
)

After PR merges, downstream caller plans (02-06) bump @retaintive/common dependency to 1.1.0.


Self-Review Checklist (skill requires)

Run through this once Plan 01 is fully written, before declaring done:

Spec coverage

Skim normative-spec.md §2 + §6.2:

  • ✅ §2.1 task_progress_events CREATE TABLE → Task 1 + Task 5
  • ✅ §2.2 tasks.status enum widen → Task 2 Step 3
  • ✅ §2.2 new executor_type / attempt_count / source_call_id columns → Task 2 Step 6
  • ✅ §2.2 TASK_CLOSE_TYPE += create_closed → Task 2 Step 4
  • ✅ §2.2 TASK_CLOSE_RESULT += unable_to_reach → Task 2 Step 5
  • ✅ §2.2 create_closed partial unique → Task 2 Step 8
  • ✅ §2.2 chk_tasks_closed_integrity transitional CHECK → Task 2 Step 7
  • ✅ §2.4 task-ui.ts STATUS_OPTIONS / PROGRESS_OPTIONS → Task 3
  • ✅ §2.4 barrel export → Task 4
  • ✅ §6.2 transitional (no destructive in this plan) → All tasks emphasize "additive only"
  • ⚠️ §2.2 tasks.store_id SET NOT NULLNot in this plan (correct — belongs to Plan 07 cutover)
  • ⚠️ §2.2 DROP COLUMN action_needed / task_type → Not in this plan (correct — Plan 07)

Placeholder scan

  • No TBD / TODO / implement later strings
  • Every code block contains the actual code (no ... truncations except clearly-marked "existing comment" blocks where reader is told to keep current content)
  • Every test has actual expect(...) assertions, not "write tests for the above"

Type consistency

  • TaskProgressType used in Task 3 matches the type defined in Task 1
  • taskProgressEvents table name used in Task 4 matches the export from Task 1
  • executor_type / attempt_count / source_call_id snake_case in SQL / camelCase in TS — consistent across Task 2, 5, and Self-Review

Outstanding gaps

None identified. Plan 01 is self-contained and produces shippable software:

  • After all 6 tasks: @retaintive/common 1.1.0 is published with additive schema
  • callytics-infrastructure / studio-api / lead-tracking can update their dependency and start using the new exports in Plans 02-06
  • test Neon has new table + new columns + transitional CHECKs, ready for Plan 02 module implementation against it

Execution Handoff

After saving the plan, the user picks an execution mode:

Option 1: Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks, fast iteration. Sub-skill: superpowers:subagent-driven-development.

Option 2: Inline Execution — execute tasks in the current session using superpowers:executing-plans, batch execution with checkpoints.

When ready to execute, tell the agent which mode to use.