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:

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:

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:

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

# 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):

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

New way:

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

# 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

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

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

# 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

psql $TEST_NEON_URL -f drizzle/0011_phase1_cutover.sql

1.d Verify

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

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

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

# 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:

// 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:

"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:

// 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

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

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

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:

    # 在所有 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 全通过:

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

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

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

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:

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

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

psql $TEST_NEON_URL -f drizzle/0012_phase1_drop_legacy.sql

3.e Verify

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

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

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.

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

-- 一次事务里: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 不需要同事务):

// 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 验证,可跳过(运行也无害):

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

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

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:

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

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

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.

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:

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

# 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:

-- 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 createcreate_open is no longer needed because prompt-engineer has shipped the new prompt (Plans 03-06 assumed this).

Verify prompt is shipped:

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

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.

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:

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

# 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:

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.