> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Plan 07 — Cutover Runbook

> **This is NOT a code plan.** It's an ops runbook. Run by a human operator (on-call engineer or release captain) after Plans 03-06 have all merged + integration tests pass in test env.

> **For agentic workers:** REQUIRED SUB-SKILL — none. This plan is procedural. An agent **must not** execute it autonomously; each step requires human verification before the next step runs.

**Goal:** Cut over from Phase 1 transitional schema(3-value status enum + legacy columns)to final state(2-value status + legacy columns dropped). After this runbook completes,`@retaintive/common@2.0.0` ships with final schema,and the Phase 1 invariants (§5.0 of normative-spec) hold in test env.

**Architecture:** All caller code in Plans 03-06 already writes `'open'` for new tasks and reads with `status IN ('pending', 'open')`. This runbook only:

1. Backfills data (`UPDATE pending → open`)
2. Drops the now-unused legacy enum value, columns, and CHECK clauses
3. Bumps `@retaintive/common` major (1.2.x → 2.0.0) because the enum shape is breaking

**Tech Stack:** PostgreSQL 16 (Neon) / drizzle-kit / psql / GitHub Actions release workflow

**Spec source:** `docs/product-design/v2/unified-pipeline/implementation-plan/normative-spec.md` §6.3 cutover preflight + §6.4 cleanup

**Dependency:** Plans 03, 04, 05, 06 all merged + their integration test suites green + test env smoke tests pass for ≥1 hour without errors.

***

## Why this is a separate runbook

The destructive operations (UPDATE, DROP COLUMN, enum narrow) cannot be put in a normal PR because:

1. **Order-sensitive.** Caller code must be deployed first (writing `'open'`, reading `IN (...)`); then DB; then bump common to drop enum value.
2. **Not atomic across services.** `UPDATE pending → open` runs once at the DB level; before it runs, some caller might still be reading `'pending'`. Order matters.
3. **Rollback playbook differs.** A code PR rolls back via `git revert`. This runbook rolls back via specific SQL.
4. **Human gate required.** Before each destructive step, the operator visually verifies the prior step succeeded and no caller is still writing legacy values.

That's why this is a **runbook** (per-step manual verification) rather than a **PR** (atomic commit).

***

## Preflight (do all of these before running ANY destructive step)

This section is `checkbox` because the operator literally checks each item.

### P1: All caller PRs merged

- [ ] Plan 03 contacts-analyzer migration PR is merged to its repo's main branch
- [ ] Plan 04 lead-processor migration PR is merged
- [ ] Plan 05 message-processor migration PR is merged
- [ ] Plan 06 studio-api migration PR is merged

### P2: All caller integration tests green

- [ ] `cd /Users/maxwsy/workspace/callytics-infrastructure/lambda/contacts-analyzer && bun test --grep integration` — all pass
- [ ] `cd /Users/maxwsy/workspace/callytics-infrastructure/lambda/lead-processor && bun test --grep integration` — all pass
- [ ] `cd /Users/maxwsy/workspace/callytics-infrastructure/lambda/message-processor && bun test --grep integration` — all pass
- [ ] `cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test --grep integration` — all pass (E2E `close` / `progress` / `reopen` / `postpone` endpoints)
- [ ] **`cd /Users/maxwsy/workspace/lead-tracking && bun test --grep integration`**(Codex audit 2026-06-02 抓的 missing — lead-tracking 也写 tasks)

### P3: Test env Lambdas deployed with new code

- [ ] CloudFormation / CDK deploy stack for `contacts-analyzer` shows latest commit (newer than Plan 03 merge)
- [ ] Same for `lead-processor`
- [ ] Same for `message-processor`
- [ ] studio-api stack shows latest commit (newer than Plan 06 merge)
- [ ] **`lead-tracking` stack shows latest commit**(Codex audit 抓的 missing)

Verify via:

```bash
aws lambda get-function --function-name retaintive-test-contacts-analyzer --query 'Configuration.LastModified'
# repeat for: lead-processor, message-processor, lead-tracking
```

### P4: No caller writing legacy values (no `'pending'`, no `action_needed=true`, no `task_type` set)

First verify the repo, not only recent DB writes:

```bash
cd /Users/maxwsy/workspace/callytics-infrastructure
grep -rn "status: 'pending'\\|status = 'pending'\\|status='pending'\\|actionNeeded: true\\|taskType:" lambda/ --include='*.ts' | grep -v node_modules | grep -v dist

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api
grep -rn "status: 'pending'\\|status = 'pending'\\|status='pending'\\|action_needed\\|task_type" src/ --include='*.ts' | grep -v "__tests__"

cd /Users/maxwsy/workspace/lead-tracking   # ← Codex audit 抓的 missing
grep -rn "status: 'pending'\\|status = 'pending'\\|status='pending'\\|actionNeeded: true\\|taskType:" src/ --include='*.ts' | grep -v node_modules | grep -v dist
```

**Expected output:** no active writer writes `status='pending'`, `tasks.action_needed`, or `tasks.task_type`. Read paths may still contain transitional `status IN ('pending','open')` until final cutover; those should be annotated as read-only transitional compatibility.

Run smoke read against test Neon over the past 1 hour of writes:

```bash
psql $TEST_NEON_URL -c "
SELECT 
  status, 
  COUNT(*) AS count,
  MAX(created_at) AS latest
FROM tasks
WHERE created_at >= NOW() - INTERVAL '1 hour'
GROUP BY status;
"
```

**Expected output:** Only `'open'` and `'closed'` should appear for tasks created in the last hour. If `'pending'` shows up:

- Identify which Lambda is writing it (`SELECT contact_phone, source_type, created_at FROM tasks WHERE status = 'pending' AND created_at >= NOW() - INTERVAL '1 hour' LIMIT 10` — then trace by `source_type` and look at the writer Lambda)
- Verify that Lambda's deployment includes the Plan 03-06 changes
- Re-deploy if not, then re-run P4 after 30 minutes

```bash
psql $TEST_NEON_URL -c "
SELECT 
  COUNT(*) AS recent_writes_with_legacy_action_needed
FROM tasks
WHERE created_at >= NOW() - INTERVAL '1 hour'
  AND action_needed IS NOT NULL;
"
```

**Expected output:** > 0 is OK (legacy callers transitioning). But verify by querying:

- `contact_analyzer` writes `action_needed = true` during Plan 03 transitional window (intended; removed in this runbook)
- No code path should write `action_needed = false`

### P5: CloudWatch logs show no recent old-path errors

```bash
# Search the past 1 hour for "status='pending'" write errors or unknown column errors
aws logs filter-log-events \
  --log-group-name /aws/lambda/retaintive-test-contacts-analyzer \
  --start-time $(date -v-1H +%s)000 \
  --filter-pattern '"status" "pending" ERROR'
```

**Expected:** No matches.

### P6: Dashboard parallel-query verification

Before cutover, `routes/v3/dashboard-*.ts` should already be using the new `task_progress_events` count for "Attempt workload" metric. Quickly compare:

Old way (still works during transitional):

```sql
SELECT COUNT(*) FROM tasks WHERE close_result = 'attempted';
```

New way:

```sql
SELECT COUNT(*) FROM task_progress_events
WHERE store_id = $1 AND occurred_at >= NOW() - INTERVAL '30 days';
```

- [ ] Numbers within ±10% of each other (some drift is expected because `attempted` was set at close time, `progress_events` are per-attempt and finer-grained)
- [ ] Trend over the past week is consistent (no sudden divergence)

If drift > 10% or trend diverges, STOP. Investigate before proceeding.

### P7: Backup test Neon

```bash
# Trigger Neon branch (point-in-time copy) so we have a rollback target
neon-cli branches create --parent main --name pre-cutover-2026-06-XX
```

- [ ] Confirm branch exists in Neon dashboard

***

## Runbook steps

Operate one step at a time. After each step, verify it succeeded **before** running the next.

### Step 1: Apply `0011_phase1_cutover.sql` (UPDATE data + change CHECK)

This is the data-and-CHECK migration. Schema columns stay (drop happens later).

#### 1.a Generate the migration file

```bash
cd /Users/maxwsy/workspace/callytics-common
git checkout -b phase-1/cutover-data
```

Create `drizzle/0011_phase1_cutover.sql` manually (not via `drizzle-kit generate`, because this migration needs ordered statements):

```sql
-- Phase 1 cutover — data backfill + CHECK transitional → final
-- Operator: run this AFTER all Plan 03-06 callers deployed.
-- Rollback: see "Rollback" section in cutover runbook (Plan 07).

BEGIN;

-- (1) Backfill any leftover pending tasks to open
UPDATE tasks SET status = 'open' WHERE status = 'pending';

-- (2) Remove the transitional accept-both-status CHECK; install final accept-open-only.
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_closed_integrity;
ALTER TABLE tasks ADD CONSTRAINT chk_tasks_closed_integrity CHECK (
  (status = '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)
);

-- (3) Narrow the partial unique index to status = 'open' only.
DROP INDEX IF EXISTS uq_tasks_pending_contact_category;
CREATE UNIQUE INDEX uq_tasks_pending_contact_category
  ON tasks (contact_phone, store_id, type_category)
  WHERE status = 'open' AND store_id IS NOT NULL;

COMMIT;
```

#### 1.b Run dry-run via manual transaction wrapper

```bash
# psql has no --dry-run for -f. Manually wrap representative destructive statements:
psql $TEST_NEON_URL -c "BEGIN; UPDATE tasks SET status = 'open' WHERE status = 'pending'; ROLLBACK;"
```

**Expected:** `UPDATE N` where N is the count of remaining `pending` rows. If N = 0, the backfill is already done (good).

#### 1.c Apply for real

```bash
psql $TEST_NEON_URL -f drizzle/0011_phase1_cutover.sql
```

#### 1.d Verify

```bash
psql $TEST_NEON_URL -c "
SELECT status, COUNT(*) FROM tasks GROUP BY status;
"
```

**Expected:** only `'open'` and `'closed'`. **Zero** `'pending'` rows.

```bash
psql $TEST_NEON_URL -c "
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'tasks'::regclass AND conname = 'chk_tasks_closed_integrity';
"
```

**Expected:** the CHECK definition contains `status = 'open'` (not `IN ('pending', 'open')`).

#### 1.e If 1.d fails

```bash
# Rollback option A: revert the UPDATE (only if you ran ROLLBACK above)
# Rollback option B: restore from Neon branch
neon-cli branches checkout pre-cutover-2026-06-XX --as test-rollback
```

Then investigate the source of the writes that re-added `'pending'`. Common cause: a Lambda still on old code.

***

### Step 2: Narrow `TASK_STATUS` enum in `@retaintive/common`

Now that the DB has no `'pending'` rows, the TypeScript enum can be narrowed.

#### 2.a Bump version + edit schema

In the `phase-1/cutover-data` branch on `callytics-common`:

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

```typescript
// BEFORE (Plan 01 transitional):
export const TASK_STATUS = ['pending', 'open', 'closed'] as const;

// AFTER (Plan 07 final):
export const TASK_STATUS = ['open', 'closed'] as const;
```

This is **breaking** for any external consumer that imports `TASK_STATUS`. Bump version 1.2.x → 2.0.0.

Edit `package.json`:

```json
"version": "2.0.0"
```

#### 2.b Update other transitional-only constants

In `src/db/schema/task-ui.ts`, remove the legacy `'pending'` entry from `STATUS_OPTIONS`:

```typescript
// BEFORE (Plan 01 transitional):
export const STATUS_OPTIONS: readonly StatusOption[] = [
  { value: 'pending', label: 'Open' },
  { value: 'open', label: 'Open' },
  { value: 'closed', label: 'Closed' },
] as const;

// AFTER:
export const STATUS_OPTIONS: readonly StatusOption[] = [
  { value: 'open', label: 'Open' },
  { value: 'closed', label: 'Closed' },
] as const;
```

#### 2.c Run typecheck and test

```bash
cd /Users/maxwsy/workspace/callytics-common
bun run typecheck
bun test
```

**Expected:** All passes. If a test fails because it still asserts `TASK_STATUS.length === 3`, update the test.

#### 2.d Build + check exports

```bash
bun run build
grep -c "'pending'" dist/db/schema/tasks.js
```

**Expected:** `0` (no `'pending'` string literal in the compiled output).

#### 2.e Commit and PR

```bash
git add src/db/schema/tasks.ts src/db/schema/task-ui.ts package.json drizzle/0011_phase1_cutover.sql
git commit -m "feat!: narrow TASK_STATUS to ['open','closed'] + apply cutover migration

Phase 1 Plan 07 Step 1+2.

BREAKING CHANGE: TASK_STATUS no longer includes 'pending'.
Consumers must already be writing 'open' and reading IN ('pending','open').
Cutover migration 0011 was applied to test Neon manually before this commit.
See cutover runbook (Plan 07) for full procedure.

Spec: normative-spec.md §6.3
"
git push -u origin phase-1/cutover-data
gh pr create --title "feat!: Phase 1 cutover — narrow TASK_STATUS + apply 0011" \
  --body "See Plan 07 cutover runbook. Test Neon migration already applied; this PR ships the matching @retaintive/common 2.0.0."
```

#### 2.f Pre-publish lockstep verify(防 Lambda auto-update 拉 2.0.0 break legacy caller)

**Why this step exists**(Codex audit 2026-06-02 抓的 ordering bug):

Publish 2.0.0 后,任何还没升级到 Phase 1 caller code 的 Lambda 在下次 deploy
(`bun install` 时拉最新 2.x)会立刻 break — 老 caller 还在写 `status='pending'`,
但 2.0.0 已是 narrow status enum `['open','closed']`,运行时 throw。

**Lockstep gate**(全部满足才能 publish):

1. **All caller package.json 已 pin** 当前 1.x range 上限 — 防 auto-update:
   ```bash
   # 在所有 caller repo 跑(callytics-infrastructure / studio-website-monorepo / lead-tracking)
   grep -rn '"@retaintive/common"' \
     callytics-infrastructure/lambda/*/package.json \
     studio-website-monorepo/apps/*/package.json \
     lead-tracking/package.json   # ← Codex audit 抓的 missing scope
   # Expected: 每个 caller dep 都已被 Phase 1 PR 升到 2.0.0 实现
   #          OR 仍 pin 1.x.x 准确版本(防 npm install 自动 jump)
   ```

2. **All Phase 1 caller PR merged to main** — Plan 03 / 04 / 05 / 06 全 merged
   状态确认。**lead-tracking 也写 tasks**(Codex 抓的另一个 missing),scope 列入
   verification:`gh search code --owner retaintive 'applyTaskActions\|buildTaskActionSQL'`
   预期返回:contacts-analyzer / lead-processor / message-processor / studio-api / lead-tracking。

3. **CI 已通过** 所有 caller repo main branch 最新 commit 的 build + test。

#### 2.g Merge PR + publish 2.0.0

After lockstep gate 2.f 全通过:

```bash
git checkout main && git pull
bun publish --access restricted   # or however your CI handles publish to GitHub Packages
```

#### 2.h Verify 2.0.0 reachable

```bash
npm view @retaintive/common@2.0.0
```

**Expected:** Lists 2.0.0 in versions array.

***

### Step 3: DROP `tasks.action_needed` and `tasks.task_type` columns

Now the schema can shed the legacy columns. Caller code has been writing legacy shim values during Plans 03-06 and reading via `EXISTS`. Time to drop.

#### 3.a Verify no caller still reads the columns

```bash
# In each caller repo, search for explicit reads
cd /Users/maxwsy/workspace/callytics-infrastructure
grep -rn "action_needed\|task_type" lambda/ --include='*.ts' | grep -v node_modules | grep -v dist
```

**Expected:** No matches in code paths actually used by Phase 1. Some legacy migration files / tests may still reference. Investigate any production-path match.

```bash
cd /Users/maxwsy/workspace/studio-website-monorepo
grep -rn "action_needed\|task_type" apps/ --include='*.ts' | grep -v node_modules | grep -v dist
```

**Expected:** No matches in active code. Especially verify `routes/tasks/list.ts:164` (Plan 06 should have removed `t.task_type`) and `routes/v3/contacts.ts:112` (now uses EXISTS, not `c.action_needed`).

If any match exists in a production path, STOP and re-check Plans 03-06 PRs to ensure complete removal.

#### 3.b Generate destructive migration

Create `drizzle/0012_phase1_drop_legacy.sql`:

```sql
-- Phase 1 cutover — DROP legacy columns
-- Operator: only run after Step 2 (TASK_STATUS narrowed + 2.0.0 published).
-- Pre-condition: no caller code reads tasks.action_needed or tasks.task_type.
-- Rollback: SQL columns cannot be cheaply restored; use Neon branch checkout.

BEGIN;

-- (1) Drop dependent constraint first
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_task_type_category_consistency;

-- (2) Drop columns
ALTER TABLE tasks DROP COLUMN action_needed;
ALTER TABLE tasks DROP COLUMN task_type;

-- (3) Drop index on action_needed if it exists
DROP INDEX IF EXISTS idx_tasks_action_needed;

COMMIT;
```

#### 3.c Dry-run

```bash
psql $TEST_NEON_URL -c "
BEGIN;
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_task_type_category_consistency;
ALTER TABLE tasks DROP COLUMN action_needed;
ALTER TABLE tasks DROP COLUMN task_type;
ROLLBACK;
"
```

**Expected:** No errors. Tables look correct after rollback (since we rolled back).

#### 3.d Apply

```bash
psql $TEST_NEON_URL -f drizzle/0012_phase1_drop_legacy.sql
```

#### 3.e Verify

```bash
psql $TEST_NEON_URL -c "
SELECT column_name 
FROM information_schema.columns 
WHERE table_name = 'tasks' 
  AND column_name IN ('action_needed', 'task_type');
"
```

**Expected:** Zero rows (columns are gone).

```bash
psql $TEST_NEON_URL -c "
SELECT conname 
FROM pg_constraint 
WHERE conrelid = 'tasks'::regclass 
  AND conname = 'chk_tasks_task_type_category_consistency';
"
```

**Expected:** Zero rows (constraint is gone).

#### 3.f Commit migration to common

```bash
cd /Users/maxwsy/workspace/callytics-common
git checkout -b phase-1/drop-legacy
# Edit src/db/schema/tasks.ts — remove the actionNeeded, taskType column definitions
# Edit any test that referenced these columns
git add src/db/schema/tasks.ts drizzle/0012_phase1_drop_legacy.sql
git commit -m "feat!: DROP tasks.action_needed and tasks.task_type

Phase 1 Plan 07 Step 3.

BREAKING CHANGE: tasks table no longer has these columns. Caller code must
already use applyTaskAction() (which doesn't touch them).

Cutover migration 0012 applied to test Neon manually.
"
git push -u origin phase-1/drop-legacy
gh pr create --title "feat!: drop tasks.action_needed + task_type"
```

After review: bump `@retaintive/common@2.1.0` and publish.

***

### Step 4: Narrow `TASK_CLOSE_RESULT` to 15 final values

Now that no caller writes progress values to `closeResult` (they go through `record_progress`), shrink the enum.

#### 4.a + 4.b 同事务执行 backfill + CHECK 收紧

**Why 合并事务**(Codex audit 2026-06-02 抓的 race window):

如果 4.a backfill COMMIT 后、4.b CHECK install 之前,有 writer 写 `'attempted'`,
4.b 新 CHECK 直接 reject 这些刚写入的行(已 invalid)。**4.a + 4.b 必须同事务**,
DDL + DML 一起 atomic — 或在 4.a 之前 freeze writers。

The 4 progress-style values (`no_answer`, `left_voicemail`, `callback_later`, `attempted`) might still exist in **historical** rows of the `tasks` table. These have already-closed tasks; they don't represent active work.

```sql
-- 先 dry-run 查 count
psql $TEST_NEON_URL -c "
SELECT close_result, COUNT(*)
FROM tasks
WHERE close_result IN ('no_answer', 'left_voicemail', 'callback_later', 'attempted')
GROUP BY close_result;
"
```

**Expected:** Some count for `attempted` (historical), possibly small counts for the others.

```sql
-- 一次事务里:backfill + DROP 老 CHECK + ADD 新 CHECK。失败任一步全 rollback。
psql $TEST_NEON_URL -c "
BEGIN;

-- 4.a Backfill — 4 progress-style values → 'unable_to_reach'
UPDATE tasks
SET close_result = 'unable_to_reach',
    close_note = COALESCE(close_note, '') || ' [migrated from ' || close_result || ']'
WHERE close_result IN ('no_answer', 'left_voicemail', 'callback_later', 'attempted');

-- 验证 backfill 完成
DO \$\$
DECLARE leftover INTEGER;
BEGIN
  SELECT COUNT(*) INTO leftover FROM tasks
  WHERE close_result IN ('no_answer', 'left_voicemail', 'callback_later', 'attempted');
  IF leftover > 0 THEN
    RAISE EXCEPTION 'Backfill incomplete — % rows still have legacy closeResult', leftover;
  END IF;
END
\$\$;

-- 4.b 同一事务内 DROP 老 CHECK + ADD 新 15-value CHECK
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_close_result;
ALTER TABLE tasks ADD CONSTRAINT chk_tasks_close_result CHECK (
  close_result IS NULL OR close_result = ANY(ARRAY[
    'converted', 'win_back', 'issue_resolved',
    'cancel_saved', 'renewed', 'upgraded', 'referral_obtained',
    'wrong_number', 'do_not_contact', 'other',
    'not_interested', 'already_member',
    'booked', 'cancelled',
    'unable_to_reach'
  ])
);

COMMIT;
"
```

#### 4.c Narrow the schema enum(TS side — `callytics-common@2.x` PR)

In `callytics-common` schema(这步在 PR 而非 psql,跟 4.a+4.b 不需要同事务):

```typescript
// BEFORE (Plan 01 transitional, 19 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',
  'booked', 'cancelled',
  'unable_to_reach',
] as const;

// AFTER (final, 15 values — outcome only):
export const TASK_CLOSE_RESULT = [
  'converted', 'win_back', 'issue_resolved',
  'cancel_saved', 'renewed', 'upgraded', 'referral_obtained',
  'wrong_number', 'do_not_contact', 'other',
  'not_interested', 'already_member',
  'booked', 'cancelled',
  'unable_to_reach',
] as const;
```

**Note**: TS enum 收紧的 CHECK 跟 4.b SQL CHECK 已对应。下面 `psql` 重复 CHECK 写出
仅作 idempotent 验证,可跳过(运行也无害):

```sql
psql $TEST_NEON_URL -c "
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_close_result;
ALTER TABLE tasks ADD CONSTRAINT chk_tasks_close_result CHECK (
  close_result IS NULL OR close_result = ANY(ARRAY[
    'converted', 'win_back', 'issue_resolved',
    'cancel_saved', 'renewed', 'upgraded', 'referral_obtained',
    'wrong_number', 'do_not_contact', 'other',
    'not_interested', 'already_member',
    'booked', 'cancelled',
    'unable_to_reach'
  ])
);
"
```

Also update `CLOSE_RESULT_OPTIONS` in `task-ui.ts` to remove the 4 progress entries.

#### 4.c Bump and publish 2.2.0

```bash
git add ...
git commit -m "feat!: TASK_CLOSE_RESULT narrowed to 15 final values

Phase 1 Plan 07 Step 4.

BREAKING CHANGE: closeResult enum dropped 4 progress values.
Historical rows migrated to 'unable_to_reach'.
"
# bump version to 2.2.0, publish
```

***

### Step 5: `tasks.store_id SET NOT NULL`

Now that all caller code passes `storeId` (verified by Plan 06's `getAuthorizedStoreNeon()` and Plans 03-05's lookup paths), enforce at DB level.

#### 5.a Verify no NULL rows

```bash
psql $TEST_NEON_URL -c "
SELECT COUNT(*) FROM tasks WHERE store_id IS NULL;
"
```

**Expected:** Zero rows in test env (Plan 01 Step 5 already DELETEd them during the 0010 migration; this re-verifies no stray writes recreated them).

If non-zero, identify and DELETE before proceeding:

```bash
psql $TEST_NEON_URL -c "
SELECT task_id, contact_phone, source_type, created_at FROM tasks WHERE store_id IS NULL ORDER BY created_at DESC LIMIT 20;
"
# Investigate, then if confirmed deletable:
psql $TEST_NEON_URL -c "DELETE FROM tasks WHERE store_id IS NULL;"
```

#### 5.b Apply NOT NULL

```bash
psql $TEST_NEON_URL -c "
BEGIN;
ALTER TABLE tasks ALTER COLUMN store_id SET NOT NULL;

-- Drop the index that had partial WHERE clause; rebuild without it
DROP INDEX IF EXISTS idx_tasks_store_id;
CREATE INDEX idx_tasks_store_id ON tasks (store_id);

COMMIT;
"
```

#### 5.c Verify

```bash
psql $TEST_NEON_URL -c "
SELECT is_nullable, data_type 
FROM information_schema.columns 
WHERE table_name = 'tasks' AND column_name = 'store_id';
"
```

**Expected:** `is_nullable = NO`.

```bash
psql $TEST_NEON_URL -c "
SELECT indexdef FROM pg_indexes WHERE tablename = 'tasks' AND indexname = 'idx_tasks_store_id';
"
```

**Expected:** index definition no longer contains `WHERE`.

#### 5.d Update common schema

Edit `src/db/schema/tasks.ts`:

```typescript
// BEFORE:
storeId: text('store_id'),  // nullable

// AFTER:
storeId: text('store_id').notNull(),
```

Commit + publish 2.3.0.

***

### Step 6: Final invariant check

After Steps 1-5, run the full §5.0 invariant test suite from normative-spec.

```bash
# All caller integration tests
cd /Users/maxwsy/workspace/callytics-infrastructure
for lambda in contacts-analyzer lead-processor message-processor; do
  cd lambda/$lambda && bun test --grep integration && cd ../../
done

cd /Users/maxwsy/workspace/studio-website-monorepo/apps/api && bun test
```

Plus the schema-level invariants directly via psql:

```sql
-- Invariant: tasks 只有二态
SELECT status FROM tasks GROUP BY status;
-- Expected: only 'open' and 'closed'

-- Invariant: legacy columns gone
SELECT column_name FROM information_schema.columns 
WHERE table_name = 'tasks' 
  AND column_name IN ('action_needed', 'task_type', 'lifecycle_state');
-- Expected: zero rows (lifecycle_state was already retained per engineer feedback,
-- so only check action_needed and task_type; if lifecycle_state appears that's fine)

-- Invariant: store_id is NOT NULL
SELECT is_nullable FROM information_schema.columns 
WHERE table_name = 'tasks' AND column_name = 'store_id';
-- Expected: NO

-- Invariant: progress values out of closeResult
SELECT COUNT(*) FROM tasks 
WHERE close_result IN ('no_answer', 'left_voicemail', 'callback_later', 'attempted');
-- Expected: 0

-- Invariant: closeResult uses exactly the final 15 values
SELECT close_result, COUNT(*) FROM tasks 
WHERE close_result IS NOT NULL GROUP BY close_result ORDER BY close_result;
-- Expected: only values from the final 15-value list

-- Invariant: open task uniqueness
SELECT contact_phone, store_id, type_category, COUNT(*) AS open_count
FROM tasks
WHERE status = 'open'
GROUP BY contact_phone, store_id, type_category
HAVING COUNT(*) > 1;
-- Expected: zero rows

-- Invariant: timeline audit for task mutations
SELECT t.task_id, t.status, t.contact_phone, t.store_id
FROM tasks t
WHERE NOT EXISTS (
  SELECT 1
  FROM contact_timeline ct
  WHERE ct.entity_type = 'task'
    AND ct.entity_id = t.task_id::text
    AND ct.store_id = t.store_id
    AND ct.event_type IN ('task.created', 'task.status_changed', 'task.updated', 'task.progress_recorded')
);
-- Expected: zero rows for tasks created/mutated after Phase 1 migration.
-- If historical rows predate timeline audit, scope with t.created_at >= '<phase1-start-ts>'.

-- Invariant: progress is stored in task_progress_events, not tasks.close_result
SELECT t.task_id, t.status, t.close_result, COUNT(tpe.id) AS progress_events
FROM task_progress_events tpe
JOIN tasks t ON t.task_id = tpe.task_id
WHERE t.close_result IN ('no_answer', 'left_voicemail', 'callback_later', 'attempted')
GROUP BY t.task_id, t.status, t.close_result;
-- Expected: zero rows
```

- [ ] All caller integration tests pass
- [ ] All direct DB invariants verified
- [ ] Integration scenarios explicitly cover DNC hard stop, cross-store taskId rejection, deterministic lead path, and progress endpoint idempotency

***

### Step 7: Cleanup transitional code

These are not destructive but should happen now for hygiene.

#### 7.a Remove `.transform()` shim in TaskDecision Zod schema

The shim in `contacts-analyzer/src/core/models.ts` that mapped legacy `create` → `create_open` is no longer needed because prompt-engineer has shipped the new prompt (Plans 03-06 assumed this).

Verify prompt is shipped:

```bash
grep "'create_open'" /Users/maxwsy/workspace/callytics-infrastructure/lambda/contacts-analyzer/src/core/prompt-builder.ts | head -3
```

If the new prompt is in place, remove the `.transform()` from `models.ts`. Otherwise leave it.

#### 7.b Delete `shared/utils/dnc-cascade.ts`

This helper is no longer called by anyone (Plan 05 message-processor migrated to `closeAllOpenForContact()` which inlined the logic).

```bash
cd /Users/maxwsy/workspace/callytics-infrastructure
grep -rn "from.*dnc-cascade" lambda/ --include='*.ts'
```

**Expected:** Only matches inside `dnc-cascade.ts` itself or its test file. If others, investigate.

```bash
rm lambda/shared/utils/dnc-cascade.ts
rm lambda/shared/utils/dnc-cascade.test.ts  # if exists
```

Commit with a `chore: remove unused dnc-cascade helper` message.

***

### Step 8: Ship release notes

Document what changed for downstream awareness.

Update `docs/product-design/v2/unified-pipeline/unified-pipeline-final.md` or create a release note in `docs/release-notes/2026-06-XX-phase-1-cutover.md`:

```markdown
# Phase 1 Cutover Complete (2026-06-XX)

`@retaintive/common` 2.3.0 is published with Phase 1 final schema.

## Breaking changes

- `TASK_STATUS` enum is `['open', 'closed']` (no `'pending'`)
- `tasks.action_needed` column is dropped
- `tasks.task_type` column is dropped
- `TASK_CLOSE_RESULT` enum is 15 values (4 progress values removed; `unable_to_reach` added)
- `tasks.store_id` is NOT NULL
- `contacts.lifecycleState` is **retained** (engineer feedback during Phase 1; the original Phase 1 plan was to drop it)
- `contacts.action_needed` is **retained** in schema (callers read via EXISTS subquery; Phase 2 will drop)

## New surfaces

- `task_progress_events` table (progress source of truth)
- `tasks.executor_type / attempt_count / source_call_id` columns
- `@retaintive/common/domain` module export (Task Orchestrator, Policy Guard, Contact Writer, Timeline Writer)
- `POST /v2/tasks/:taskId/progress` endpoint on studio-api

## Phase 2 backlog

- `contacts.actionNeeded` DROP COLUMN
- `needs_review` queue design (CloudWatch metric `mutation_rejected_total{reason='low_confidence'}` data should inform threshold)
- Postgres RLS evaluation
- Tool calling runtime
- SMS meaningful classification
- studio-api Drizzle migration (issue #449)
```

- [ ] Release note committed and pushed

***

## Rollback procedure

If any step fails halfway and the system is unstable, follow these rollback paths.

### Rollback after Step 1 (data backfill failed)

```bash
# Restore from Neon branch taken in P7
neon-cli branches checkout pre-cutover-2026-06-XX --target main
```

Verify `SELECT status FROM tasks GROUP BY status` returns 3-value enum again. Investigate root cause before re-attempting.

### Rollback after Step 2 (common 2.0.0 published but DB unhealthy)

Common is already published; cannot unpublish. Mitigation:

- Pin downstream consumers to 1.2.x in their `package.json`
- Fix the DB issue
- Re-attempt Step 2 with a `2.0.1` patch if needed

### Rollback after Step 3 (DROP COLUMN — destructive)

Cannot restore columns cheaply. Restore the whole Neon branch:

```bash
neon-cli branches checkout pre-cutover-2026-06-XX --target main
```

This **loses any data written between cutover start and rollback**. Coordinate with team before doing this. Better path is to verify P4 / P5 thoroughly before Step 3 so this never triggers.

### Rollback after Steps 4-5 (closeResult narrow / NOT NULL)

Restore Neon branch. Same caveat as Step 3.

***

## Estimated time

- Preflight checklist: 30-60 minutes
- Steps 1-3 (data + enum + drop columns): 30-60 minutes (waiting for Neon, common publish, etc.)
- Steps 4-5 (closeResult + NOT NULL): 30 minutes
- Step 6 (invariants): 15 minutes
- Steps 7-8 (cleanup + release notes): 30 minutes

Total: 2-3 hours under happy path. Allow a half-day window. Schedule outside of business hours if test env is shared with other dev work.

***

## Sign-off

After completion, the operator confirms:

- [ ] All preflight items P1-P7 verified
- [ ] Steps 1-6 completed without unplanned rollback
- [ ] Step 6 invariants all green
- [ ] Steps 7-8 cleanup and release notes shipped
- [ ] Operator: `__________________` Date: `__________`

Phase 1 is done. Move to Phase 2 backlog.
