# Cross-channel Conversation Workspace Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Every task uses a fresh implementer, then a specification reviewer and a code-quality reviewer; Critical/Important findings are fixed and re-reviewed before the next task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Give Phone Call and Showroom F2F the same human-reviewed AI action-plan, per-recording Ask AI, confirmed pipeline/commission, and Lead-grouped Sales Checklist workflow already available for Zoom, using one shared implementation with explicit source provenance.

**Architecture:** Keep the two existing Zoom tables and route contracts as compatibility surfaces, add a channel-neutral source identity and parent models, and move behavior into `Src\Conversation` services. Expand storage first, make every write path dual-write, then tighten the shared identity to NOT NULL and unique. All three recording hosts use one 90%-viewport workspace and endpoint-driven capabilities; approved generated tasks continue through the existing Lead-grain `SalesWorkQueue` and carry per-task provenance.

**Tech Stack:** Laravel 13 · PHP 8.4 · MySQL · Inertia · Vue 3 · Tailwind v4 · PHPUnit · Vitest/happy-dom · Pint · Vite SSR

## Global Constraints

- Work only in `/Users/dadadineiyou/Documents/GitHub/petav3-dev-chen-integration` on local branch `dev-chen`.
- Do not push, open a PR, or enable production flags during Tasks 1–11. Push
  and open the requested PR only after the final flag-off verification and
  Fable xhigh release review are clean.
- Preserve every existing Zoom row ID, UUID, public route, approval lock, and `(source_action_plan_id, source_step_key)` idempotency boundary.
- Persist integer source/status codes; define conversation source-type integers exactly once in `ConversationSourceType`.
- An AI suggestion is never a confirmed pipeline fact. Product and commission render only from a human-confirmed, Lead-owned `Engagement`.
- Super Admin alone may review/approve team plans and confirm/reject pipeline matches. Non-Super-Admins receive only checklist tasks assigned to themselves.
- Recording detail/Ask AI keep the current channel boundary: Zoom uses the `VIEW_ZOOM` module permission plus `LeadVisibility::allowsLeadOrOwner`, Phone Call uses `VIEW_CALLS`, Showroom uses `VIEW_F2F`.
- Provider context excludes phone/contact fields, `deepgram_json`, media URLs, credentials, provider payloads, imported lineage IDs, and `ai_analysis_zh`; preserve `<RECORDING_CONTEXT>` plus `JSON_HEX_TAG`.
- No automatic approval, automatic pipeline confirmation, conversion probability, claimed uplift, or separate Call/F2F task dashboard.
- `SalesWorkQueue` remains generated-plan-only (`source_action_plan_id IS NOT NULL`); do not alter the manual Lead Discussion Action Item path or headline counting semantics.
- Every generated task carries `provenance`; the persisted snapshot contains channel type/label, public UUID, safe title, and occurrence timestamp only. URL is computed per viewer after authorization and is never persisted.
- A Lead is the pagination/grouping unit and cannot split across pages. Phone, Showroom, and Zoom tasks for the same Lead appear together, each retaining its own source.
- A disabled feature must return 404 before validation. Parse literal environment `false` correctly; never rely on `(bool) env(...)`.
- The four release gates are database-backed, Super-Admin-editable, and absent
  means false. They must not require an environment change to enable after
  merge; old Zoom env keys do not control the final runtime state.
- Use TDD, one logical concern per commit, task-specific review after every task, and a final authorization/security/browser review.

---

## File Structure

### New shared domain files

| Path | Responsibility |
|---|---|
| `src/Conversation/ConversationSourceType.php` | Single integer source-type vocabulary and stable UI codes/labels. |
| `src/Conversation/ConversationSource.php` | Immutable normalized source value used by shared services. |
| `src/Conversation/Contracts/ConversationSourceAdapter.php` | Adapter interface: build, find, batch-find, authorize, and derive URL. |
| `src/Conversation/Services/ConversationSourceRegistry.php` | Resolve one or many sources without a morph map or N+1. |
| `src/Conversation/Adapters/{ZoomMeeting,CallRecording,F2fRecording}SourceAdapter.php` | Channel column/relation mapping and visibility. |
| `src/Conversation/Support/ConversationFeatureFlags.php` | Neutral release gates; Task 1 establishes compatibility aliases and Task 11 makes the database the only runtime source. |
| `src/Conversation/ConversationActionPlan.php` | Shared parent model mapped to `zoom_meeting_action_plans`. |
| `src/Conversation/ConversationOpportunityLink.php` | Shared parent model mapped to `zoom_meeting_opportunity_links`. |
| `src/Conversation/Repositories/ConversationActionPlanRepository.php` | Locked draft edit/approval/materialization behavior for every channel. |
| `src/Conversation/Repositories/ConversationOpportunityLinkRepository.php` | Locked suggestion/confirmation/rejection behavior for every channel. |
| `src/Conversation/Services/ConversationActionPlanDraftService.php` | Source-neutral draft synchronization. |
| `src/Conversation/Services/ConversationRecordingChatContextBuilder.php` | Bounded, allow-listed per-recording provider snapshot. |
| `src/Conversation/Services/ConversationOpportunityCandidateBuilder.php` | Existing Engagement candidates for the source's Lead. |
| `src/Conversation/Services/ConversationOpportunityLinkPresenter.php` | Suggestion and human-confirmed pipeline review shape. |
| `src/Conversation/Services/ConversationOpportunitySignals.php` | AI-read intent/stage and confirmed-only value/completeness. |
| `src/Conversation/Services/ConversationSourcePresenter.php` | Viewer-safe per-task provenance and optional source URL. |
| `app/Http/Controllers/Manage/Conversation/*` | Shared plan, plan chat/revision, recording chat, and opportunity endpoints. |
| `app/Http/Requests/Manage/Conversation/*` | Neutral flag/role gates and bounded validation. |
| `app/Console/Commands/Conversation/InitConversationActionPlans.php` | Dry-run/filterable Call/F2F historical backfill. |
| `resources/js/Components/Conversation/RecordingWorkspaceModal.vue` | Shared 90% modal, independent left/right scroll, aligned footer. |
| `resources/js/Components/Conversation/ActionPlanReviewModal.vue` | Endpoint-neutral human-review UI moved from Zoom namespace. |
| `resources/js/Components/Conversation/OpportunityLinkPanel.vue` | Endpoint-neutral pipeline-match panel moved from Zoom namespace. |

### New migrations

| Path | Responsibility |
|---|---|
| `database/migrations/2026_08_14_100001_expand_conversation_action_plans.php` | Nullable shared identity + JSON provenance; Zoom backfill; nullable legacy key. |
| `database/migrations/2026_08_14_100002_expand_conversation_opportunity_links.php` | Nullable shared identity; Zoom backfill; nullable legacy key. |
| `database/migrations/2026_08_14_100003_tighten_conversation_source_identity.php` | Preflight, NOT NULL, composite unique after dual writes exist. |

### Existing compatibility adapters/consumers

The task file lists below are authoritative. They cover the Zoom plan/link
models and repositories, Lead queue relations, `RecordingDetail.vue`, the three
modal hosts, Call/F2F presenters and analysis jobs, route/controller aliases,
AI prompt registration, and each named focused test.

---

### Task 1: Conversation source contract, adapters, and neutral flags

**Files:**
- Create: `src/Conversation/ConversationSourceType.php`
- Create: `src/Conversation/ConversationSource.php`
- Create: `src/Conversation/Contracts/ConversationSourceAdapter.php`
- Create: `src/Conversation/Services/ConversationSourceRegistry.php`
- Create: `src/Conversation/Adapters/ZoomMeetingSourceAdapter.php`
- Create: `src/Conversation/Adapters/CallRecordingSourceAdapter.php`
- Create: `src/Conversation/Adapters/F2fRecordingSourceAdapter.php`
- Create: `src/Conversation/Support/ConversationFeatureFlags.php`
- Modify: `config/features.php`
- Modify: `app/Http/Middleware/HandleInertiaRequests.php`
- Test: `tests/Feature/Conversation/ConversationSourceRegistryTest.php`
- Test: `tests/Feature/Conversation/ConversationFeatureFlagsTest.php`

**Interfaces:**
- Produces one source vocabulary used by migrations, models, repositories, routes, presenters, and queue provenance.
- Produces batch resolution with at most one query per represented source type.

- [ ] **Step 1: Write the source-type and registry contract tests**

Cover exact integer mappings, all normalized fields, missing rows, stable input order, viewer-safe URL behavior, Zoom `LeadVisibility::allowsLeadOrOwner`, Phone `VIEW_CALLS`, Showroom `VIEW_F2F`, and a query-count assertion for mixed `findMany()`.

```php
$this->assertSame(1, ConversationSourceType::ZOOM);
$this->assertSame(2, ConversationSourceType::PHONE_CALL);
$this->assertSame(3, ConversationSourceType::SHOWROOM_F2F);
$sources = app(ConversationSourceRegistry::class)->findMany([
    ConversationSourceType::PHONE_CALL => [$callA->id, $callB->id],
    ConversationSourceType::SHOWROOM_F2F => [$f2f->id],
]);
$this->assertSame('phone_call', $sources->get(ConversationSourceType::PHONE_CALL)->first()->code);
```

- [ ] **Step 2: Run the new tests and verify the missing classes fail**

Run: `herd php artisan test tests/Feature/Conversation/ConversationSourceRegistryTest.php tests/Feature/Conversation/ConversationFeatureFlagsTest.php`

Expected: FAIL because the source/flag classes do not exist.

- [ ] **Step 3: Implement the exact shared types**

```php
final class ConversationSourceType
{
    public const ZOOM = 1;
    public const PHONE_CALL = 2;
    public const SHOWROOM_F2F = 3;

    public const META = [
        self::ZOOM => ['code' => 'zoom', 'label' => 'Zoom'],
        self::PHONE_CALL => ['code' => 'phone_call', 'label' => 'Phone Call'],
        self::SHOWROOM_F2F => ['code' => 'showroom_f2f', 'label' => 'Showroom F2F'],
    ];
}

final readonly class ConversationSource
{
    public function __construct(
        public int $type,
        public int $id,
        public string $uuid,
        public string $code,
        public string $label,
        public string $title,
        public ?CarbonInterface $occurredAt,
        public ?int $leadId,
        public ?int $ownerAdminId,
        public ?string $transcript,
        public ?array $analysis,
        public Model $subject,
    ) {}

    public function provenance(): array
    {
        return [
            'type' => $this->code,
            'label' => $this->label,
            'uuid' => $this->uuid,
            'title' => $this->title,
            'occurred_at' => $this->occurredAt?->toIso8601String(),
        ];
    }
}
```

`ConversationSourceAdapter` must declare `type(): int`, `fromModel(Model): ConversationSource`, `find(int): ?ConversationSource`, `findMany(array): Collection`, `canView(User, ConversationSource): bool`, and `detailUrl(User, ConversationSource): ?string`. The registry maps adapters once in its constructor and rejects unknown types; it does not use `morphMap`.

- [ ] **Step 4: Implement robust neutral feature flags**

Add `conversation_action_plan_demo`, `conversation_opportunity_demo`, and
`conversation_recording_chat_demo` compatibility aliases and share neutral keys
through Inertia. During Tasks 1–10 these may delegate to the existing Zoom
config so channel work can proceed without changing behavior. Task 11 must
replace the runtime source with strict database-backed release controls and
prove the old env keys no longer control the feature.

- [ ] **Step 5: Run tests and review query count**

Run the two Task 1 suites. Expected: PASS. Confirm `findMany()` performs no per-record query.

- [ ] **Step 6: Commit**

Commit: `feat(conversation): add shared recording source contract`

---

### Task 2: Additive storage expansion and compatibility parent models

**Files:**
- Create: `database/migrations/2026_08_14_100001_expand_conversation_action_plans.php`
- Create: `database/migrations/2026_08_14_100002_expand_conversation_opportunity_links.php`
- Create: `src/Conversation/ConversationActionPlan.php`
- Create: `src/Conversation/ConversationOpportunityLink.php`
- Modify: `src/Zoom/ZoomMeetingActionPlan.php`
- Modify: `src/Zoom/ZoomMeetingOpportunityLink.php`
- Modify: `src/Lead/LeadActionItem.php`
- Test: `tests/Feature/Database/ConversationWorkflowExpansionMigrationTest.php`
- Test: `tests/Feature/Conversation/ConversationCompatibilityModelsTest.php`

**Interfaces:**
- Consumes `ConversationSourceType::ZOOM`.
- Produces nullable shared columns only; Task 3 dual-writes before Task 4 tightens them.
- `ZoomMeetingActionPlan extends ConversationActionPlan`; `ZoomMeetingOpportunityLink extends ConversationOpportunityLink`.

- [ ] **Step 1: Write migration and compatibility failures first**

Assert expansion preserves row IDs/UUIDs, backfills every legacy row as Zoom, permits two non-Zoom rows with `zoom_meeting_id = null`, and safe `down()` succeeds only before non-Zoom rows. Assert `LeadActionItem::sourcePlan()` resolves the neutral parent.

- [ ] **Step 2: Run focused tests and verify failure**

Run: `herd php artisan test tests/Feature/Database/ConversationWorkflowExpansionMigrationTest.php tests/Feature/Conversation/ConversationCompatibilityModelsTest.php`

- [ ] **Step 3: Implement expansion migrations in this order**

For `zoom_meeting_action_plans`: make `zoom_meeting_id` nullable without dropping its unique index; add nullable unsigned `source_type`, unsigned bigint `source_id`, and nullable JSON `provenance`; backfill `source_type=ConversationSourceType::ZOOM`, `source_id=zoom_meeting_id`, and safe Zoom provenance. For opportunity links, perform the same identity expansion without provenance. Do not add the shared unique index or NOT NULL yet.

Each `down()` must preflight for any row where `source_type != ZOOM` or `zoom_meeting_id IS NULL`; throw a descriptive `RuntimeException` instead of destroying cross-channel identity.

- [ ] **Step 4: Move shared model behavior to the parent**

The parent action-plan model owns table, fillable/casts (including `source_type`, `source_id`, `provenance`), statuses, progress, display state, assignee/reviewer/generated-item relations. The Zoom subclass adds a global Zoom source scope and retains `meeting()`. The opportunity parent owns statuses/confidences, common relations, and shared columns; the Zoom subclass retains Zoom scope plus `meeting()`.

- [ ] **Step 5: Run legacy Zoom foundations unchanged**

Run:

```bash
herd php artisan test tests/Feature/Zoom/ZoomActionPlanFoundationTest.php \
  tests/Feature/Zoom/ZoomOpportunityLinkFoundationTest.php \
  tests/Feature/Zoom/ZoomActionPlanDraftTest.php
```

Expected: PASS with unchanged Zoom IDs and public behavior.

- [ ] **Step 6: Commit**

Commit: `feat(conversation): expand Zoom workflow storage for shared sources`

---

### Task 3: Shared locked repositories, dual writes, then identity tightening

**Files:**
- Create: `src/Conversation/Repositories/ConversationActionPlanRepository.php`
- Create: `src/Conversation/Repositories/ConversationOpportunityLinkRepository.php`
- Modify: `src/Zoom/Repositories/ZoomMeetingActionPlanRepository.php`
- Modify: `src/Zoom/Repositories/ZoomMeetingOpportunityLinkRepository.php`
- Create: `database/migrations/2026_08_14_100003_tighten_conversation_source_identity.php`
- Test: `tests/Feature/Conversation/ConversationActionPlanRepositoryTest.php`
- Test: `tests/Feature/Conversation/ConversationOpportunityLinkRepositoryTest.php`
- Test: `tests/Feature/Database/ConversationSourceIdentityTighteningTest.php`

**Interfaces:**
- Shared repository methods accept `ConversationSource` and neutral parent models.
- Zoom repositories remain thin adapters with their current public signatures.

- [ ] **Step 1: Write tests covering every write path**

Test new create, soft-deleted restore, draft replace, draft edit, approval, suggestion, confirm, reject, and concurrent retry. Every resulting row must have non-null matching `source_type/source_id`; Zoom must also have matching `zoom_meeting_id`. Background suggestion save must return without changing a `REJECTED` row.

- [ ] **Step 2: Run the new and existing concurrency suites; verify failure**

Run:

```bash
herd php artisan test tests/Feature/Conversation/ConversationActionPlanRepositoryTest.php \
  tests/Feature/Conversation/ConversationOpportunityLinkRepositoryTest.php \
  tests/Feature/Zoom/ZoomActionPlanApprovalConcurrencyTest.php \
  tests/Feature/Zoom/ZoomOpportunityConfirmConcurrencyTest.php
```

- [ ] **Step 3: Extract one implementation, keep Zoom wrappers**

Move lock/restore/edit/approve/materialize logic into `ConversationActionPlanRepository`. Its create identity is always:

```php
[
    'source_type' => $source->type,
    'source_id' => $source->id,
    'zoom_meeting_id' => $source->type === ConversationSourceType::ZOOM ? $source->id : null,
    'provenance' => $source->provenance(),
]
```

Freeze provenance when approving. Keep the already-approved short circuit before formatting. Preserve one transaction, `lockForUpdate()`, whole-plan validation, and generated-step unique key.

Move opportunity logic similarly. `saveSuggestion()` skips both CONFIRMED and REJECTED unless an explicit reviewer-only regenerate method is called. Confirm re-resolves a candidate from the current source Lead inside the locked transaction.

- [ ] **Step 4: Make Zoom repositories compatibility adapters**

Convert each `ZoomMeeting` to `ConversationSource` through the registry/Zoom adapter, then delegate. Do not duplicate repository bodies. Existing controller/service type contracts must continue working.

- [ ] **Step 5: Add the tightening migration only after dual writes pass**

Preflight both tables for null/partial identity, throw if any exist, then make `source_type/source_id` NOT NULL and add unique indexes `conversation_plan_source_unique` and `conversation_opportunity_source_unique`. `down()` drops those indexes and makes the shared columns nullable; the expansion migration remains responsible for the cross-channel rollback refusal.

- [ ] **Step 6: Run repository, concurrency, migration, and Zoom regression suites**

Expected: all PASS. Mutation check: temporarily remove `lockForUpdate()` and verify the existing concurrency test fails; restore it.

- [ ] **Step 7: Commit**

Commit: `refactor(conversation): share locked action plan and opportunity writes`

---

### Task 4: Shared draft synchronization, Call/F2F analysis hooks, and backfill

**Files:**
- Create: `src/Conversation/Services/ConversationActionPlanDraftService.php`
- Modify: `src/Zoom/Services/ZoomActionPlanDraftService.php`
- Modify: `app/Jobs/Calls/AnalyzeCallRecording.php`
- Modify: `app/Jobs/F2f/AnalyzeF2fRecording.php`
- Modify: `src/Zoom/Repositories/ZoomMeetingRepository.php`
- Create: `app/Console/Commands/Conversation/InitConversationActionPlans.php`
- Test: `tests/Feature/Conversation/ConversationActionPlanDraftTest.php`
- Test: `tests/Feature/Conversation/ConversationActionPlanBackfillTest.php`
- Modify tests: `tests/Feature/Call/ProcessCallRecordingTest.php`, `tests/Feature/F2f/ProcessF2fRecordingTest.php`

**Interfaces:**
- `syncDraft(ConversationSource): ?ConversationActionPlan` is the sole implementation.
- The Zoom service converts then delegates; Call/F2F jobs call the shared service after successful analysis persistence.

- [ ] **Step 1: Write cross-channel draft tests**

For each source type assert: linked + analyzed + non-empty recommended actions creates one draft; unlinked creates none; retry reuses one row; reanalysis replaces draft but never approved rows; translation-only writes do nothing; provenance matches the source.

- [ ] **Step 2: Implement the neutral service by moving Zoom rules intact**

Read normalized recommended actions from the source subject's shared conversation-performance contract. Preserve step-key issuance, AI priority/date provenance, per-row assignee fields, and existing eligibility. The feature gate is `ConversationFeatureFlags::actionPlans()`.

- [ ] **Step 3: Hook Call and F2F only after analysis is persisted**

After `recordAnalysis(...)`, refresh the model and call the shared draft service; do this before the terminal DONE marker only if failure semantics remain retry-safe. A provider failure or empty transcript must not create a draft.

- [ ] **Step 4: Add bounded historical backfill**

Command signature:

```text
conversation:init-action-plans
  {--channel= : phone_call|showroom_f2f}
  {--id=* : public recording UUID(s)}
  {--dry-run : report without writing}
  {--limit=500 : maximum rows per run}
```

Only analyzed, linked records are candidates. Dry-run performs no write; rerunning produces no duplicate.

- [ ] **Step 5: Run tests and mutation check**

Run:

```bash
herd php artisan test tests/Feature/Conversation/ConversationActionPlanDraftTest.php \
  tests/Feature/Conversation/ConversationActionPlanBackfillTest.php \
  tests/Feature/Zoom/ZoomActionPlanDraftTest.php \
  tests/Feature/Call/ProcessCallRecordingTest.php \
  tests/Feature/F2f/ProcessF2fRecordingTest.php
```

Temporarily let reanalysis overwrite approved plans and verify the immutability
test fails; restore.

- [ ] **Step 6: Commit**

Commit: `feat(conversation): generate reviewed plans from calls and showroom recordings`

---

### Task 5: Channel-neutral Action Plan APIs and review UI

**Files:**
- Create: `app/Http/Controllers/Manage/Conversation/ActionPlansController.php`
- Create: `app/Http/Controllers/Manage/Conversation/ActionPlanChatController.php`
- Create: `app/Http/Controllers/Manage/Conversation/ActionPlanSuggestionController.php`
- Create: `app/Http/Requests/Manage/Conversation/ActionPlans/UpdateRequest.php`
- Create: `app/Http/Requests/Manage/Conversation/ActionPlans/ApproveRequest.php`
- Create: `app/Http/Requests/Manage/Conversation/ActionPlans/ChatRequest.php`
- Create: `app/Http/Requests/Manage/Conversation/ActionPlans/SuggestionRequest.php`
- Modify: `app/Http/Controllers/Manage/Zoom/ZoomActionPlansController.php`
- Modify: `app/Http/Controllers/Manage/Zoom/ActionPlanChatController.php`
- Modify: `app/Http/Controllers/Manage/Zoom/ActionPlanSuggestionController.php`
- Modify: `app/Http/Requests/Manage/Zoom/ActionPlans/UpdateRequest.php`
- Modify: `app/Http/Requests/Manage/Zoom/ActionPlans/ApproveRequest.php`
- Modify: `app/Http/Requests/Manage/Zoom/ActionPlans/ChatRequest.php`
- Modify: `app/Http/Requests/Manage/Zoom/ActionPlans/SuggestionRequest.php`
- Modify: `routes/web.php`
- Move: `resources/js/Components/Zoom/ActionPlanReviewModal.vue` → `resources/js/Components/Conversation/ActionPlanReviewModal.vue`
- Move: `resources/js/Components/Zoom/ActionPlanRevisionPreview.vue` → `resources/js/Components/Conversation/ActionPlanRevisionPreview.vue`
- Move: `resources/js/Components/Zoom/ActionPlanReviewModal.test.js` → `resources/js/Components/Conversation/ActionPlanReviewModal.test.js`
- Move: `resources/js/Components/Zoom/ActionPlanRevisionPreview.test.js` → `resources/js/Components/Conversation/ActionPlanRevisionPreview.test.js`
- Modify: `resources/js/Pages/Manage/Zoom/Actions/Index.vue`
- Test: `tests/Feature/Manage/Conversation/ConversationActionPlansEndpointsTest.php`

**Interfaces:**
- Neutral endpoints use plan UUID: GET/PUT `/manage/conversation/action-plans/{id}`, POST approve/chat/suggestion.
- Existing `/manage/zoom/action-plans/*` routes remain valid and delegate to the same implementation.

- [ ] **Step 1: Write endpoint tests for all source types and both roles**

Assert flag-off 404 precedes 422/403, ordinary Admin/Sales gets 403, Super Admin gets the same payload shape for all channels, save/approve is idempotent, and cross-Lead/source UUID tricks cannot widen data.

- [ ] **Step 2: Implement neutral requests/controllers**

Load `ConversationActionPlan` by public UUID, resolve its source via registry, require Super Admin, and return `source` instead of Zoom-only `meeting`:

```php
'source' => [
    'type' => $source->code,
    'label' => $source->label,
    'uuid' => $source->uuid,
    'occurred_at' => $source->occurredAt?->toIso8601String(),
    'agent' => $presenter->agentName($source),
    'customer' => $presenter->customerName($source),
    'lead' => $presenter->lead($source),
    'url' => $presenter->url($request->user(), $source),
],
```

Keep the current allow-listed analysis and assignee options. Update context builders to accept neutral plans/sources; no second prompt or algorithm.

- [ ] **Step 3: Make the Vue review component endpoint-neutral**

Add required `urls` prop (`show`, `update`, `approve`, `chat`, `suggestion`), remove every hard-coded `/manage/zoom`, rename `meeting` to `source`, and render the source label/time/Open recording link. Keep review, edit, assignment, priority, schedule, agree/disagree, AI chat, and revision behavior unchanged.

- [ ] **Step 4: Run backend and frontend tests**

Run:

```bash
herd php artisan test tests/Feature/Manage/Conversation/ConversationActionPlansEndpointsTest.php \
  tests/Feature/Manage/Zoom/ZoomActionPlansEndpointsTest.php \
  tests/Feature/Manage/Zoom/ActionPlanChatTest.php \
  tests/Feature/Manage/Zoom/ActionPlanSuggestionTest.php
npm test -- resources/js/Components/Conversation/ActionPlanReviewModal.test.js \
  resources/js/Components/Conversation/ActionPlanRevisionPreview.test.js
```

Expected: PASS.

- [ ] **Step 5: Commit**

Commit: `refactor(conversation): share action plan review across channels`

---

### Task 6: Shared per-recording Ask AI backend

**Files:**
- Create: `src/Conversation/Services/ConversationRecordingChatContextBuilder.php`
- Modify: `src/Zoom/Services/ZoomRecordingChatContextBuilder.php` into a wrapper
- Create: `app/Http/Controllers/Manage/Conversation/RecordingChatController.php`
- Create: `app/Http/Requests/Manage/Conversation/RecordingChatRequest.php`
- Modify: `app/Http/Controllers/Manage/Zoom/ZoomRecordingChatController.php` into a delegate
- Modify: `app/Http/Requests/Manage/Zoom/ZoomRecordingChatRequest.php` into an alias/delegate
- Modify: `routes/web.php`
- Modify: `src/Ai/AiRequest.php`
- Modify: `config/ai_prompts.php`
- Modify: `resources/prompts/zoom_recording_chat.md` description/content wording only where it says Zoom
- Test: `tests/Feature/Conversation/ConversationRecordingChatContextBuilderTest.php`
- Test: `tests/Feature/Manage/Conversation/ConversationRecordingChatTest.php`

**Interfaces:**
- Channel routes remain explicit: Zoom `/manage/zoom/recordings/{id}/ai-chat`, Phone `/manage/calls/recordings/{id}/ai-chat`, Showroom `/manage/f2f/showroom/recordings/{id}/ai-chat`.
- Controller converts route channel + UUID through the registry; history contract stays unchanged.

- [ ] **Step 1: Write security and behavior tests before extraction**

For all three channels assert correct visibility, flag-off-before-validation, UUID isolation, transcript head/tail truncation, empty context without provider call, answered-turn-only replay, retryable 503, and `AiRequest` attribution. Seed every excluded Call/F2F field with recognizable sentinels and assert none appears in the provider payload.

Add the delimiter mutation assertion: a transcript containing literal `<RECORDING_CONTEXT>` must reach JSON as `\u003CRECORDING_CONTEXT\u003E`.

- [ ] **Step 2: Extract the existing allow-list, not the stored array**

`ConversationRecordingChatContextBuilder::build(ConversationSource)` returns the current `meeting/analysis/transcript` shape. Keep exact limits 40,000 total, 25,000 head, 15,000 tail. Include display names only; never email/phone. Never pass `source_meta`, `deepgram_json`, audio/media URLs, raw provider JSON, or Chinese duplicate analysis.

- [ ] **Step 3: Implement one neutral controller and request gate**

The grounding turn retains `JSON_UNESCAPED_UNICODE | JSON_HEX_TAG`, fixed acknowledgement, bounded history, and prompt selection. Introduce `PROMPT_CONVERSATION_RECORDING_CHAT` as the canonical constant while aliasing `PROMPT_ZOOM_RECORDING_CHAT` to the same stored key for configured-model compatibility.

- [ ] **Step 4: Run tests and security mutation**

Run:

```bash
herd php artisan test tests/Feature/Conversation/ConversationRecordingChatContextBuilderTest.php \
  tests/Feature/Manage/Conversation/ConversationRecordingChatTest.php \
  tests/Feature/Zoom/ZoomRecordingChatContextBuilderTest.php \
  tests/Feature/Manage/Zoom/ZoomRecordingChatTest.php
```

Temporarily remove `JSON_HEX_TAG` and verify the injection test fails; restore.

- [ ] **Step 5: Commit**

Commit: `feat(conversation): support grounded Ask AI for calls and showroom recordings`

---

### Task 7: One 90% recording workspace and capability-driven chat rail

**Files:**
- Create: `resources/js/Components/Conversation/RecordingWorkspaceModal.vue`
- Modify: `resources/js/Components/ZoomRecordingDetailModal.vue`
- Modify: `resources/js/Pages/Manage/Calls/History/Partials/CallDetailDrawer.vue`
- Modify: `resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue`
- Modify: `resources/js/Components/RecordingDetail/RecordingDetail.vue`
- Modify: `resources/js/Components/RecordingDetail/Tabs/RecordingChatTab.vue`
- Modify: `resources/js/composables/useSalesCoachChat.js`
- Test: `resources/js/Components/Conversation/RecordingWorkspaceModal.test.js`
- Modify test: `resources/js/Components/RecordingDetail/RecordingDetail.test.js`
- Modify test: `resources/js/Components/RecordingDetail/Tabs/RecordingChatTab.test.js`
- Modify test: `resources/js/Components/ZoomRecordingDetailModal.test.js`
- Create test: `resources/js/Pages/Manage/Calls/History/Partials/CallDetailDrawer.test.js`
- Create test: `resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.test.js`

**Interfaces:**
- `RecordingWorkspaceModal` owns 90vh/90vw geometry and accepts host identity/footer slots.
- `RecordingDetail` receives `chatUrl` and `opportunityUrls`; availability is capability-based, never `type === 'zoom'`.
- `useRecordingChat(endpoint)` receives an endpoint, not a Zoom UUID.

- [ ] **Step 1: Write host parity and lifecycle tests**

Assert all three hosts use the shared workspace, identity header aligns with rail top, left and right own independent desktop scrolling, composer stays outside chat scroll content, bottom aligns with Close row, mobile DOM order is content → Ask AI → Close, and switching source recreates chat with the new endpoint/history.

Retain keyboard cases: plain Enter sends once, Shift+Enter does not send, `isComposing`, `compositionstart`, legacy keyCode 229, immediate composition-end Enter, button click, and rapid duplicate Enter.

- [ ] **Step 2: Extract geometry from the Zoom host**

Move Modal `size="full"`, `padded=false`, `{height:'90vh', maxWidth:'90vw'}`, header/footer slot placement, and rail ownership into `RecordingWorkspaceModal`. Keep channel-specific identity/edit/link controls in narrow slots supplied by each host.

- [ ] **Step 3: Replace type gates with endpoints**

```js
const canChat = computed(() => Boolean(props.chatUrl));
const canReviewOpportunity = computed(() => Boolean(
    props.recording.opportunity && props.opportunityUrls?.confirm,
));
```

Pass `chatUrl` to `RecordingChatTab`, then into `useRecordingChat(chatUrl)`. Key the tab and rail on `${recording.type}:${recording.id}:${chatUrl}` so an in-place source swap cannot post to the previous channel.

- [ ] **Step 4: Wire Call and F2F endpoints**

Call host builds `/manage/calls/recordings/{uuid}/ai-chat`; Showroom builds `/manage/f2f/showroom/recordings/{uuid}/ai-chat`. Both preserve existing edit/link/retry/translate behavior and supply their identity slot.

- [ ] **Step 5: Run focused Vitest and build**

Run:

```bash
npm test -- resources/js/Components/Conversation/RecordingWorkspaceModal.test.js \
  resources/js/Components/RecordingDetail/RecordingDetail.test.js \
  resources/js/Components/RecordingDetail/Tabs/RecordingChatTab.test.js \
  resources/js/Components/ZoomRecordingDetailModal.test.js
npm run build
```

- [ ] **Step 6: Commit**

Commit: `feat(conversation): share the recording workspace and Ask AI rail`

---

### Task 8: Shared pipeline matching, confirmed commission, and Call/F2F analysis hooks

**Files:**
- Create: `src/Conversation/Services/ConversationOpportunityCandidateBuilder.php`
- Create: `src/Conversation/Services/ConversationOpportunityLinkPresenter.php`
- Create: `src/Conversation/Services/ConversationOpportunitySignals.php`
- Modify: `src/Zoom/Services/ZoomOpportunityCandidateBuilder.php`
- Modify: `src/Zoom/Services/ZoomOpportunityLinkPresenter.php`
- Modify: `src/Zoom/Services/ZoomOpportunitySignals.php`
- Modify: `src/Zoom/Services/ZoomOpportunitySuggestionContextBuilder.php`
- Modify: `app/Jobs/Calls/AnalyzeCallRecording.php`
- Modify: `app/Jobs/F2f/AnalyzeF2fRecording.php`
- Create: `app/Console/Commands/Conversation/InitConversationOpportunitySuggestions.php`
- Test: `tests/Feature/Conversation/ConversationOpportunityCandidateBuilderTest.php`
- Test: `tests/Feature/Conversation/ConversationOpportunitySignalsTest.php`
- Test: `tests/Feature/Conversation/ConversationOpportunitySuggestionTest.php`

**Interfaces:**
- Candidates always come from live `Engagement::where('lead_id', $source->leadId)` with `project` and latest `booking` relations.
- Signals return AI-read `intent/buying_stage`, confirmed-only commercial value, completeness count/parts, evidence, and missing fields; never a score/probability.

- [ ] **Step 1: Write candidate, confirmation, retirement, and retry tests**

Assert unlinked source has zero candidates; another Lead's engagement cannot be selected; suggestion columns never set confirmed fields; only confirmation exposes product/value; commission equals `EngagementOpportunityValue::calculate()`; soft-deleted confirmed Engagement remains historically visible and marked retired; automatic retry preserves REJECTED.

- [ ] **Step 2: Extract existing Zoom logic to source-neutral services**

Keep `Engagement::with(['project','booking'])`, no bookings join, and confirmation revalidation inside the locked transaction. Reuse existing confidence/status wording. Generalize evidence timestamps/duration through the source contract.

- [ ] **Step 3: Add source-neutral suggestion sync after analysis**

After successful Call/F2F analysis, create/update an unreviewed suggestion only when the feature is enabled, the source has a Lead, and the link is neither CONFIRMED nor REJECTED. The AI may propose one offered Engagement UUID or null; it cannot confirm.

- [ ] **Step 4: Add bounded dry-run/filterable historical suggestion command**

Mirror Task 4 command options and idempotency. Do not copy production data into fixtures.

- [ ] **Step 5: Run shared and legacy Zoom suites**

Run:

```bash
herd php artisan test tests/Feature/Conversation/ConversationOpportunityCandidateBuilderTest.php \
  tests/Feature/Conversation/ConversationOpportunitySignalsTest.php \
  tests/Feature/Conversation/ConversationOpportunitySuggestionTest.php \
  tests/Feature/Zoom/ZoomOpportunityCandidateBuilderTest.php \
  tests/Feature/Zoom/ZoomOpportunitySignalsTest.php \
  tests/Feature/Zoom/ZoomOpportunitySuggestionContextBuilderTest.php \
  tests/Feature/Zoom/ZoomOpportunityConfirmConcurrencyTest.php
```

Expected: PASS.

- [ ] **Step 6: Commit**

Commit: `refactor(conversation): share confirmed pipeline matching and value`

---

### Task 9: Shared opportunity APIs, panels, and Call/F2F list signals

**Files:**
- Create: `app/Http/Controllers/Manage/Conversation/OpportunityLinksController.php`
- Create: `app/Http/Requests/Manage/Conversation/Opportunities/SuggestRequest.php`
- Create: `app/Http/Requests/Manage/Conversation/Opportunities/ConfirmRequest.php`
- Create: `app/Http/Requests/Manage/Conversation/Opportunities/RejectRequest.php`
- Modify: `app/Http/Controllers/Manage/Zoom/ZoomOpportunityLinksController.php`
- Modify: `app/Http/Requests/Manage/Zoom/Opportunities/SuggestRequest.php`
- Modify: `app/Http/Requests/Manage/Zoom/Opportunities/ConfirmRequest.php`
- Modify: `app/Http/Requests/Manage/Zoom/Opportunities/RejectRequest.php`
- Modify: `routes/web.php`
- Move: `resources/js/Components/Zoom/OpportunityLinkPanel.vue` → `resources/js/Components/Conversation/OpportunityLinkPanel.vue`
- Move: `resources/js/Components/Zoom/OpportunitySignals.vue` → `resources/js/Components/Conversation/OpportunitySignals.vue`
- Move: `resources/js/Components/Zoom/OpportunitySignalsNote.vue` → `resources/js/Components/Conversation/OpportunitySignalsNote.vue`
- Modify: `src/Call/Support/CallRecordingPresenter.php`
- Modify: `src/F2f/Support/F2fRecordingPresenter.php`
- Modify: `app/Http/Controllers/Manage/Calls/HistoryController.php`
- Modify: `app/Http/Controllers/Manage/F2f/ShowroomController.php`
- Modify: `resources/js/Pages/Manage/Calls/History/Index.vue`
- Modify: `resources/js/Pages/Manage/F2f/Showroom/Index.vue`
- Modify: `resources/js/Pages/Manage/Calls/History/Partials/CallDetailDrawer.vue`
- Modify: `resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue`
- Test: `tests/Feature/Manage/Conversation/ConversationOpportunityReviewTest.php`
- Test: `tests/Feature/Manage/Calls/CallOpportunityColumnsTest.php`
- Test: `tests/Feature/Manage/F2f/F2fOpportunityColumnsTest.php`
- Move test: `resources/js/Components/Zoom/OpportunityLinkPanel.test.js` → `resources/js/Components/Conversation/OpportunityLinkPanel.test.js`
- Move test: `resources/js/Components/Zoom/OpportunitySignals.test.js` → `resources/js/Components/Conversation/OpportunitySignals.test.js`

**Interfaces:**
- Channel recording routes expose suggest/confirm/reject; shared controller resolves source by route channel.
- Opportunity payload is omitted entirely unless neutral flag is on and viewer is Super Admin.

- [ ] **Step 1: Write authorization/data-minimization tests**

Assert Super Admin sees candidates/product/commission for all channels; ordinary Admin receives no `opportunity` key and cannot call review endpoints; flag off returns 404; cross-Lead engagement confirmation returns ineligible; suggestions never render as confirmed values.

- [ ] **Step 2: Implement neutral endpoint controller and route aliases**

Use the shared registry/repository/presenter. Keep existing Zoom public routes and add parallel Call/F2F routes. All three request classes enforce flag-off 404, then Super Admin 403, before validation.

- [ ] **Step 3: Make panel components endpoint-neutral**

Move components to `Components/Conversation`, retain the exact confirmed/suggestion separation and disclaimer, and accept URL props only. Update Zoom imports without changing behavior.

- [ ] **Step 4: Add honest Call/F2F columns**

When permitted, each row/detail carries the same opportunity shape: AI-read intent, buying stage, confirmed pipeline product, confirmed commission, and `N of 3` completeness. Product/value are `—` unless human-confirmed. Reuse `ConversationOpportunitySignals`; do not write a second derivation or add conversion percentages.

- [ ] **Step 5: Add Action Plan review entry to Call/F2F rows/details**

Expose the neutral plan UUID/state only to Super Admin under the action-plan flag. The page mounts `Conversation/ActionPlanReviewModal` once and opens it for the selected plan, using neutral endpoint URLs. Existing Call/F2F Action Items pages remain unchanged.

- [ ] **Step 6: Run tests and build**

Run the three new backend tests, `tests/Feature/Manage/Zoom/ZoomOpportunityReviewTest.php`, `tests/Feature/Manage/Zoom/ZoomOpportunitySuggestionTest.php`, `tests/Feature/Manage/Zoom/RecordingsOpportunityColumnsTest.php`, both moved Vue tests, and `npm run build`.

- [ ] **Step 7: Commit**

Commit: `feat(conversation): review pipeline matches on calls and showroom recordings`

---

### Task 10: Source-neutral Sales Checklist provenance and role boundaries

**Files:**
- Create: `src/Conversation/Services/ConversationSourcePresenter.php`
- Modify: `src/Lead/Services/SalesWorkQueue.php`
- Modify: `src/Lead/LeadActionItem.php`
- Modify: `resources/js/Components/ActionPlanChecklistPanel.vue`
- Modify: `resources/js/Components/ActionPlanChecklistSection.vue`
- Modify: `resources/js/Pages/Manage/ActionItems.vue`
- Test: `tests/Feature/Lead/CrossChannelSalesWorkQueueTest.php`
- Modify: `tests/Feature/Lead/SalesWorkQueueTest.php`
- Modify: `tests/Feature/Lead/SalesWorkQueueVisibilityTest.php`
- Modify test: `resources/js/Components/ActionPlanChecklistPanel.test.js`
- Modify test: `resources/js/Components/ActionPlanChecklistSection.test.js`
- Create test: `tests/Feature/Manage/CrossChannelConversationWorkflowTest.php`

**Interfaces:**
- Queue row grain stays Lead; task rows gain `provenance`.
- Registry batch-loads represented plan sources once per type; inaccessible/deleted source falls back to persisted snapshot with no URL.

- [ ] **Step 1: Write the Lead-grouping/provenance invariant test**

Seed one Lead with approved Zoom, Phone, and F2F plans plus another Lead. Assert the first Lead appears once, all tasks are present, it never splits across pages, urgency/priority ordering stays intact, each task has the correct source type/time, and total open counts match Hub/Action Items/AI Agent consumers.

- [ ] **Step 2: Write explicit role/privacy tests**

Assert Super Admin sees all team generated tasks; ordinary Admin/Sales sees only tasks assigned to self and only on Leads visible to that viewer; team payload contains no phone/contact strings; My Tasks retains contact actions. An assignee without `VIEW_CALLS`/`VIEW_F2F` sees source label/title/time but no URL. Deleted source also yields snapshot/no URL without breaking counts.

- [ ] **Step 3: Implement batch provenance**

Change `LeadActionItem::sourcePlan()` to the neutral parent. Eager-load plans, collect `(source_type, source_id)` pairs, call registry `findMany()` once per represented type, and add response-only:

```php
'provenance' => [
    'type' => $snapshot['type'],
    'label' => $snapshot['label'],
    'title' => $snapshot['title'],
    'occurred_at' => $snapshot['occurred_at'],
    'url' => $authorizedResolvedSourceUrl,
],
```

Never admit manual null-plan rows. Preserve `no_active_steps` and active non-soft-deleted completion semantics.

- [ ] **Step 4: Render per-task badges and links inside existing Lead groups**

Show `Zoom`, `Phone Call`, or `Showroom F2F` beside each task with its occurrence time. Render Open recording only when URL is non-null. Do not add three task pages or regroup by channel.

- [ ] **Step 5: Run focused backend/frontend suites and mutation checks**

Run:

```bash
herd php artisan test tests/Feature/Lead/CrossChannelSalesWorkQueueTest.php \
  tests/Feature/Lead/SalesWorkQueueTest.php \
  tests/Feature/Lead/SalesWorkQueueVisibilityTest.php \
  tests/Feature/Manage/CrossChannelConversationWorkflowTest.php
npm test -- resources/js/Components/ActionPlanChecklistPanel.test.js \
  resources/js/Components/ActionPlanChecklistSection.test.js
```

Mutation checks: remove per-viewer URL authorization and ensure the permission
test fails; resolve sources in a loop and ensure the query-count test fails;
restore both.

- [ ] **Step 6: Run focused integration verification**

```bash
vendor/bin/pint --test
herd php artisan test
npm test
npm run build
git diff --check
php scripts/check-migration-constants.php
```

Record the full PHP baseline comparison if pre-existing failures remain; no new failure is acceptable.

- [ ] **Step 7: Commit checklist integration**

Commit: `feat(conversation): unify cross-channel sales follow-up workspace`

Do not push and do not open a PR.

---

### Task 11: Database-backed release controls, flag-off handoff, and PR

**Files:**
- Modify: `src/Setting/Setting.php`
- Modify: `src/Conversation/Support/ConversationFeatureFlags.php`
- Create: `app/Http/Requests/Manage/Integrations/Ai/ConversationFeatureSettingsRequest.php`
- Modify: `app/Http/Controllers/Manage/Integrations/AiPromptsController.php`
- Modify: `app/Http/Middleware/HandleInertiaRequests.php`
- Modify: `routes/web.php`
- Modify: `resources/js/Pages/Manage/Integrations/Ai/Prompts.vue`
- Modify: every remaining direct consumer of the four legacy demo config keys
- Modify: `config/features.php` — delete the four legacy demo keys
  (`zoom_action_plan_demo`, `lead_sales_coach_demo`, `zoom_opportunity_demo`,
  `zoom_recording_chat_demo`) and any Task 1 `conversation_*` compatibility
  aliases; leave `projects_enabled`/`subsale_enabled`/`video_enabled`/
  `analyze_property_locked` untouched
- Modify: `tests/Concerns/InteractsWithZoomActionPlans.php` and every test that
  currently enables these features via `config(['features.zoom_*' => true])` —
  they must seed a `Setting` row (with resolver memo reset) or use one shared
  resolver test helper instead
- Test: `tests/Feature/Conversation/ConversationFeatureFlagsTest.php`
- Test: AI Prompts settings authorization/persistence coverage
- Modify: affected flag-off endpoint and Vue feature-gate tests

**Interfaces:**
- Database keys are defined once on `Setting`; booleans are stored as `1`/`0`.
- `ConversationFeatureFlags` is the only runtime reader. It is database-first,
  request-memoized, and defaults to false when a row is absent or malformed.
- The resolver memo is flushed per HTTP request AND per queued job (scoped
  container binding, e.g. `$app->scoped()`, or an explicit reset on the
  job-processing boundary): a long-lived `queue:work` process must observe a
  changed setting on its next job without a worker restart, in both the enable
  and the disable direction.
- First access loads all four keys with one batched `whereIn` query, not four
  single-key queries; the Inertia share must not add per-key queries per render.
- Four independent controls: Action Plans / Sales Checklist, per-recording Ask
  AI, Pipeline Product / Commission, and Lead AI Sales Coach.
- Old Zoom-named Inertia props may remain aliases, but they use resolver values.
- No production or local `.env` entry is required or honored for these gates.

- [ ] **Step 1: Write failing runtime-setting and authorization tests**

Assert absent rows mean all four false; persisted `1`/`0` takes effect without
changing config; malformed values are false; ordinary Admin cannot update —
including a non-Super-Admin who HOLDS `MANAGE_INTEGRATIONS` (the legacy `admin`
role is seeded with it, so a bare-admin exclusion test would pass against a
permission-gated endpoint and prove nothing); Super Admin can update each
independently; the request accepts exactly the four fixed keys and rejects any
client-supplied key name; a GET never writes settings; and a second queued job
in the same worker process observes a setting changed between jobs.

- [ ] **Step 2: Implement the database-backed resolver and settings endpoint**

Add constants, strict boolean parsing, per-request memoization/reset needed by
tests, validated writes through `SettingRepository`, and explicit Super Admin
authorization. The Super Admin gate must be named and mechanical, not inherited
from the page's `permission:MANAGE_INTEGRATIONS` pattern: use the registered
`super-admin` route middleware (`App\Http\Middleware\EnsureUserIsSuperAdmin`)
and/or `abort_unless($this->user()->isSuperAdmin(), 403)` in
`ConversationFeatureSettingsRequest`, following the
`Requests/Manage/Zoom/ActionPlans/UpdateRequest` precedent. Register the
resolver as a scoped binding (flushed per request and per queued job) and load
all four keys in one `whereIn` query on first access. Save all four toggles in
one `DB::transaction` around `SettingRepository::put()` calls. Move every
backend and Inertia consumer of these four features to the resolver. Do not
touch unrelated Projects/Subsale/Video flags.

- [ ] **Step 3: Add the Super Admin settings UI**

Add a clearly labelled Release controls card to the existing AI Prompts page.
Show current state, explain that disabled features return 404, save all four
toggles atomically, and hide/disable mutation for non-Super-Admins. The server
must omit the release-control props entirely for non-Super-Admin viewers of
the page (design §9: responses omit restricted data; do not rely on a Vue-only
hide — the page GET is reachable by any `VIEW_INTEGRATIONS` holder). This is
the supported post-merge activation path.

- [ ] **Step 4: Verify both states, then release every flag on**

With real database settings, prove each feature can be enabled without an env
edit and its representative UI/API appears. Prove all four can still be set to
`0`, with UI entries disappearing and protected endpoints returning 404. Then
persist all four as `1` and prove the release UI is present after a normal
migration. Record database evidence without exposing secrets.

Zero-grep acceptance criterion: after the consumer migration,
`rg "features\.(zoom_action_plan_demo|zoom_recording_chat_demo|zoom_opportunity_demo|lead_sales_coach_demo)" app/ src/ routes/ resources/ config/ tests/`
must return zero matches (the `config/features.php` key deletion makes any
missed runtime read fail loudly instead of silently re-coupling to env).
Record the empty grep output as evidence alongside both-state proof.

- [ ] **Step 5: Run mandatory full verification and browser walkthrough**

Run Pint, full PHP tests with baseline comparison, full Vitest, production+SSR
build, diff check, migration checks, and the complete Zoom/Phone/Showroom
Super-Admin plus ordinary Admin/Sales walkthrough. Do not consume the only demo
draft without restoring a repeatable state.

- [ ] **Step 6: Run final Fable 5 xhigh release reviews**

Dispatch independent architecture/integration, authorization/privacy,
database/concurrency, Vue UX/accessibility, and test-quality reviewers. Include
the runtime-toggle design, flag-off evidence, and full diff. Use Opus 5 xhigh to
fix every Critical/Important finding, then re-run affected Fable reviews.

- [ ] **Step 7: Commit, push, and open the PR**

Confirm a clean worktree, four persisted flags off, and no tracked secrets.
Push `dev-chen` and open a ready PR against `master` with summary, security and
role boundaries, migration/rollback notes, test evidence, browser evidence, and
the exact post-merge UI activation path. Do not deploy or merge the PR.

---

## Self-review record

- **Spec coverage:** All 17 design sections map to Tasks 1–10. Storage expansion/tightening, source deletion, rejected retry, privacy exclusions, role differences, UI geometry, pipeline confirmation, commission derivation, Lead grouping, provenance, and browser rehearsal each have a named task/test.
- **No-placeholder scan:** No TBD/TODO/“similar to” instruction remains. Every task names exact files, interfaces, test behavior, commands, and commit boundaries.
- **Type consistency:** `ConversationSourceType`, `ConversationSource`, `ConversationSourceRegistry`, parent plan/link models, neutral repositories, neutral URLs, and response key `provenance` are used consistently. Persisted provenance omits URL; only response provenance includes an authorized URL.
- **Migration consistency:** Task 2 is additive only. Task 3 dual-writes every path before the tightening migration runs. Expansion down refuses after cross-channel writes.
- **Scope consistency:** Manual Action Items remain outside `SalesWorkQueue`; existing Call/F2F standalone Action Items pages are not redesigned; no predicted conversion or automatic human decision is added.
