# DATA-MAPPING.md — AI Appointment Engine → PETA backend

*Required by the build spec §0 and acceptance criterion 2. Written against the working tree on
`dev-wk`, 2026-08-29. Every claim carries a `path:line`. Where a mapper and a verifier disagreed,
the verifier's reading is what is written here.*

---

## 1. Verdict in one paragraph

**Roughly half the console can be built now, mostly by extending screens that already exist; the
other half is blocked on one missing write path, one missing column, and one missing role grant.**
The single biggest dependency is this: **nothing in PETA ever creates an `appointments` row except a
human pressing Save.** `AppointmentRepository::create()` has exactly one caller,
`app/Http/Controllers/Manage/Appointment/AppointmentsController.php:43` (the only other
`Appointment::create()` in the repo is the demo seeder,
`app/Console/Commands/LeadDistribution/DemoSeed.php:192`). The AI's own verdict lives as a boolean
inside `ai_voice_calls.analysis->custom_analysis_data.appointment_booked` — present on 2 of 4 live
call profiles, on **2 of 16 live call rows, both with `lead_id` NULL**. There is no
`ai_voice_call_id` on `appointments` and no `appointment_id` on `ai_voice_calls`
(`src/Appointment/Appointment.php:163-178`; live `describe ai_voice_calls`). So the product's
headline claim — the AI replaced the 20 % appointment-setter — **cannot be computed from any table
today.** §5.7 (Automation canvas) is the one spec section that is mostly a gap: PETA has two
automation engines and neither is the spec's workflow (the branching, versioned, canvas-able one
cannot place a call or send email/SMS; the multi-channel one has no steps, no order column, no
branching and no versioning). Everything else — Leads, WhatsApp review, AI calling, Campaigns,
document ingestion, the agent-allocation engine — has real, reusable code behind it, and the work is
mostly scoping, aggregation and honest labelling rather than new domain modelling. Three facts to
absorb before planning: (a) the suite is **double feature-flagged** and invisible on a default
install (§2); (b) the persona the console is for, `sales-leader`, **holds neither console
permission** on any install; (c) live data is thin — 4 appointments with 0 recorded outcomes, 16 AI
calls, 0 `salesperson_tier_weeks` rows, 0 users holding `sales-agent`. Much of what looks like a
column gap is a **rollout gap**.

---

## 2. How this console talks to the backend

**PETA is Inertia. There is no REST API for pages.** A controller method returns
`Inertia::render('Page', [...props])` and the Vue page receives data as props
(`CLAUDE.md` §13). The suite's existing controller already demonstrates the contract:
`app/Http/Controllers/Manage/AppointmentEngine/ConsoleController.php:33` →
`Inertia::render('Manage/AppointmentEngine/Console', ['canManage' => ...])`.

**Reconciling this with the spec's §4 "typed data-access layer".** Honour the *intent*, drop the
transport assumption. There is no HTTP client to type, because the page never fetches. The
equivalent, and the thing the spec actually wants (one place that knows the shape, so a screen
cannot invent a field), is:

- **Server side** — one method per screen that returns the prop payload, and one *presenter* that
  owns each row shape. PETA already does this and the console must not fork it:
  `Src\VoiceAgent\Support\AiCallPresenter::row()` (`src/VoiceAgent/Support/AiCallPresenter.php:67`)
  is the AI-call row for three surfaces; `Src\Whatsapp\Support\MessagePresenter::inbox()`
  (`src/Whatsapp/Support/MessagePresenter.php:37`) is the message row for the inbox props *and* the
  broadcast payloads; `Src\Appointment\Appointment::toCalendarArray()`
  (`src/Appointment/Appointment.php:391`) is the appointment row.
- **Client side** — a props interface per page and a small `resources/js/` module per screen.
  Do **not** create a generic `shared.js` (`CLAUDE.md` §13).

**Handful of real JSON side-doors already exist** and are the pattern for anything genuinely
asynchronous: `manage.leads.quick` (`routes/web.php:294`), `manage.leads.search` (`:277`),
`manage.leads.activity` (`:298`), the inbox's `preview` (`:715`) and `handoff`/`resume-ai`
(`:706-707`). The real `v1` API (`routes/api/v1.php`) is for external consumers, not for this
console.

**Conventions every screen must follow (not optional — `CLAUDE.md` §14):**

| Need | Use | Reference consumer |
|---|---|---|
| Filterable/sortable/paginated list | `App\Http\Requests\Manage\ManageQueryRequest` (`app/Http/Requests/Manage/ManageQueryRequest.php:14`; `applyIn` :185, `applyInString` :205, `applyLike` :222, `applyDate` :236, `applyFiltersExcept` :161) + `Concerns\ResolvesListQuery` (`app/Http/Controllers/Concerns/ResolvesListQuery.php:28`) + `useResourceIndex.js:21` + `DataTable.vue:6` | `LeadsController::index` (`app/Http/Controllers/Manage/Leads/LeadsController.php:358`) |
| Export the current view | `Concerns\ExportsResource::downloadExport()` (`app/Http/Controllers/Concerns/ExportsResource.php:22`) + one `App\Exports\*` class + `<ExportMenu :base-url>` | `LeadsController::export` (`:718`), `app/Exports/LeadsExport.php:20` |
| Detail page back link | `Concerns\ResolvesBackUrl::resolveBackUrl($request, $indexPaths)` (`app/Http/Controllers/Concerns/ResolvesBackUrl.php:27` — the parameter is **plural** and accepts an array) | `Pages/Manage/Membership/Show.vue` |
| Lead scoping | `Src\Auth\Support\LeadVisibility::apply()` (`src/Auth/Support/LeadVisibility.php:83`) on **every** list *and* export | `LeadsController` index :553 and export :733 |
| Non-lead rows scoped to the agency group | `Src\Auth\Support\GroupScope` (`src/Auth/Support/GroupScope.php:26`) | `CampaignMappingController.php:46` |
| A screen that grows sub-views | `Components/HubTabs.vue:17` + a new `Components/AppointmentEngineTabs.vue` | `SettingTabs.vue:13-16` explains why HubTabs, not SectionTabs |

Two export rules that are not cosmetic: `ExportMenu.vue:23` deletes only `page`, so **`per_page`
reaches the export route and the controller must ignore it** (`LeadsController::export` calls
`listControls` for sort only, then `$query->get()` at `:736`); and a `GET export` route must be
declared **before** `GET {id}` (`routes/web.php:276` vs `:291`).

**⚠️ The suite is double feature-flagged and currently invisible on a default install.** The route
group at `routes/web.php:1742` is nested inside `if (config('features.projects_enabled'))` opened at
`routes/web.php:1703`. `.env.example:438` ships `FEATURE_PROJECTS_ENABLED=false`, while
`config/features.php:31` defaults `appointment_engine_enabled` to **true** — and
`app/Http/Middleware/HandleInertiaRequests.php:124` shares only `features.appointment_engine`. So on
any box that is not this one, the sidebar entry and Hub card render and every link 404s. Fix before
anything else ships: lift the block out of the `projects` `if`, or make the Inertia share
`projects_enabled && appointment_engine_enabled` so nav and router agree. Note also that the flag
gates route *registration* while `bootstrap/cache/routes-v7.php` exists, so flipping it needs
`route:cache` + `config:cache` — the opposite of the convention argued at `routes/web.php:13-18`.

---

## 3. Entity map

| Console vocabulary | PETA entity (class + table) | File:line | Notes |
|---|---|---|---|
| PipelineStage | `Src\Engagement\Engagement` (`engagements`) | `src/Engagement/Engagement.php:29` | **The real pipeline.** Grain is (lead, project), not the lead. 7 selectable statuses `STATUSES` :62-70, 9 stored `ALL_STATUSES` :76-86, retired 5/7 folded by `COLUMN_STATUS` :90-93. Live: 513 rows, **0 at `STATUS_APPOINTMENT_SET`**. |
| Lead | `Src\Lead\Lead` (`leads`) | `src/Lead/Lead.php:28` | A *person*. `leads.status` (:39-45) is a 5-value roll-up written only by `App\Actions\SyncLeadStatusFromEngagements::rollUp()` :54. Not soft-deletable; delete is a hard cascade :145-175. |
| Lead pool state | `leads.distribution_status` | `src/Lead/Lead.php:50-58` | `DIST_NONE/WAITING/ASSIGNED`, indexed (`2026_07_22_100003:30`). A **separate axis** from `leads.status`. |
| Attribution (per registration) | `Src\Lead\LeadFunnel` (`lead_funnels`) | `src/Lead/LeadFunnel.php:15` | `campaign_id/adset_id/ad_id` raw Meta ids, utm_*, `placement`, `registered_at` (indexed), `marketing_source` :32-39, `follow_up_status` (a **third** status vocabulary, 10 values :54-64), `ai_call_opt_in`. |
| CallRecord | `Src\VoiceAgent\AiVoiceCall` (`ai_voice_calls`) | `src/VoiceAgent/AiVoiceCall.php:24` | 7 connection statuses :37-47, `disconnection_reason` verbatim, `REFUSAL_REASONS` :57, `analysis` json, `transcript`, `cost_usd`, `recording_url`. **Every aggregate must scope `source = SOURCE_LEAD`** (:108). |
| CallRecord.transcript[] | `Src\VoiceAgent\AiVoiceCallTurn` (`ai_voice_call_turns`) | `src/VoiceAgent/AiVoiceCallTurn.php:16` | `seq/role/content/offset_ms`; `language` (migration `2026_08_18_150001:37`) is **never written** — 0 of 167 rows. |
| Campaign (the AI script) | `Src\VoiceAgent\AiCallProfile` (`ai_call_profiles`) | `src/VoiceAgent/AiCallProfile.php:20` | `extraction_fields` json (:117), `MAX_EXTRACTION_FIELDS = 12` (:98), content-hash versioned via `settingsStamp()` :301. |
| KnowledgeItem (caller) | `Src\VoiceAgent\AiCallKbEntry` (`ai_call_kb_entries`) | `src/VoiceAgent/AiCallKbEntry.php:13` | Per profile; pushed to a per-profile Retell KB and deleted/recreated each sync (`RetellAgentSync.php:96-97, 428`). PETA never retrieves from it. |
| Do-not-call | `Src\VoiceAgent\AiCallBlockedNumber` (`ai_call_blocked_numbers`) | `src/VoiceAgent/AiCallBlockedNumber.php:15` | Keyed on `phone_e164` (unique), **not** on a lead. Plus `whatsapp_contacts.blocked_at`. |
| Appointment | `Src\Appointment\Appointment` (`appointments`) | `src/Appointment/Appointment.php:17` | `type` :26-36, `status` (scheduling only) :47-49, `outcome` :104-121, derived `phase` :293. No duration/end (:139-143), no assignee, no source. Live: **4 rows, 0 outcomes**. |
| Appointment (the step before) | `Src\Appointment\ConsultationRequest` (`consultation_requests`) | `src/Appointment/ConsultationRequest.php:12-18` | **Materially under-used by the spec.** Has `assigned_admin_id` (+`assignedAdmin()` :135), `appointment_at`, `STATUS_NEW/CONTACTED/SCHEDULED/CLOSED` :94-97, and real enum columns `timeline`/`budget`/`goal` :49-90. Live: 174 rows, 172 assigned, **12 SCHEDULED with a real `appointment_at`** — three times the whole `appointments` table. |
| The *other* appointment | `Src\Zoom\ZoomMeeting` (`zoom_meetings`) | `app/Http/Controllers/Concerns/ResolvesLeadAppointments.php:13-18` | 384 live rows vs `appointments`' 4. The trait's own docblock: two records of the **same promise**. Any booking figure that reads one table understates the business by ~2 orders of magnitude. |
| MessageThread | `Src\Whatsapp\WhatsappConversation` (`whatsapp_conversations`) | `src/Whatsapp/WhatsappConversation.php:18` | `CHAT_INDIVIDUAL/GROUP` :26-27, `last_read_at` (**team-global**), `ai_replied_at`, `ai_handoff_at`. `assigned_admin_id` exists (:38) and is **dead** — 0 of 3,523 rows set. |
| Message | `Src\Whatsapp\WhatsappMessage` (`whatsapp_messages`) | `src/Whatsapp/WhatsappMessage.php:17` | `direction` :22-23, status ladder :60-72, `meta` carries provenance. **No `lead_id`** — the join is conversation → contact → user → lead. |
| Campaign (Meta, performance) | `Src\Marketing\MetaAdInsight` (`meta_ad_insights`) | `src/Marketing/MetaAdInsight.php:17` | **One row per ad per day.** Campaign/adset/ad are three columns; any grain is a GROUP BY. `reach` excluded from `summableColumns()` :68-71. |
| Campaign status/budget/creative | `Src\Marketing\MetaAdSetting` (`meta_ad_settings`) | `src/Marketing/MetaAdSetting.php:16` | `effective_status` + static `badge()` :150. Read-only against Meta. |
| Campaign → project | `Src\Marketing\MetaCampaign` (`meta_campaigns`) | `src/Marketing/MetaCampaign.php:21` | `project_id` XOR `event_funnel_id`. Live: **275 rows, 0 tied**. |
| Ad → conversation first touch | `Src\Messenger\MessengerCapture` (`messenger_captures`) | `src/Messenger/MessengerCapture.php:13` | Has `campaign_id/adset_id/ad_id` + `lead_id` + `conversation_id`, unique per contact. **WhatsApp has no equivalent.** |
| Workflow (branching) | `Src\Whatsapp\WhatsappFlow` (`whatsapp_flows`) | `src/Whatsapp/WhatsappFlow.php:25` | Steps ordered + branching + content-hash versioned + run-snapshotted. **Cannot call, email or SMS.** |
| Workflow (multi-channel) | `Src\Event\FunnelAutomationMessage` / `FunnelWhatsappMessage` | `src/Event/FunnelAutomationMessage.php:28` / `src/Event/FunnelWhatsappMessage.php:30` | Email/SMS/voice/AI-call rules bound to an `EventFunnel`. **No order column, no branching, no versioning.** |
| Send ledgers | `FunnelAutomationSend`, `FunnelWhatsappSend`, `WhatsappBroadcastRecipient` | `src/Event/FunnelAutomationSend.php:17`, `src/Event/FunnelWhatsappSend.php:17`, `src/Whatsapp/WhatsappBroadcastRecipient.php:51` | `sent_at` only — **no due/scheduled column.** `created_at` is the *claim* moment (`firstOrCreate` at `FunnelAutomationMessageRepository:152`). |
| Scheduled human follow-up | `Src\Lead\LeadActionItem` (`lead_action_items`) | `src/Lead/LeadActionItem.php:25` | **Has a real due date**: `scheduled_for` DATE, nullable, indexed (`2026_08_06_400001:34`), plus `scheduled_source` AI/HUMAN :72-73. Bucketed by `Src\Lead\Services\SalesWorkQueue` :53. |
| Agent (person) | `Src\People\User` + `UserProfile` + `Src\People\Admin` | `src/People/User.php:26`, `src/People/Admin.php:24-33` | Name is always `$user->profile->full_name` with an email fallback (GUIDELINES §4.8). No skill/language/availability anywhere. |
| Agent tier | `SalespersonTier` / `SalespersonTierWeek` | `src/LeadDistribution/SalespersonTier.php:16` / `SalespersonTierWeek.php:15` | Weekly score = appointments the agent booked on non-fresh leads (`SalespersonTierScorer.php:79-85`). **0 live rows** — see §5. |
| Assignment history | `Src\LeadDistribution\LeadAssignment` (`lead_assignments`) | `src/LeadDistribution/LeadAssignment.php:16` | `REASON_MANUAL/REQUESTED/AI_FAILED/NO_APPOINTMENT/NO_BOOKING/IMPORT` :20-34 (constants are `REASON_`-prefixed). |
| Suite permissions | `Src\Auth\Permission` | `src/Auth/Permission.php:181-182` | `VIEW_APPOINTMENT_ENGINE` / `MANAGE_APPOINTMENT_ENGINE`, grouped at :294. `allPermissions()` is derived (:386), so the group entry is the only registration step. |

---

## 4. Screen-by-screen mapping

### §5.1 Dashboard — **PARTIAL**

**Feeds it (real, reusable):**
- Pipeline by stage — `SalesProjectsController::pipelineView()`
  (`app/Http/Controllers/Manage/Engagements/SalesProjectsController.php:546-602`): one grouped
  query, `LeadVisibility`-scoped, retired statuses folded via `COLUMN_STATUS`, unassigned counted
  at :597. **Reuse verbatim.**
- Lead counts by status/source/funnel/quality — already props:
  `LeadsController.php:552-570`.
- Hot leads — `lead_enrichments.recommended_action = ACTION_HOT` (`src/Lead/LeadEnrichment.php:50`),
  the column is indexed (`2026_06_24_000002:35`).
- "Waiting on us" WhatsApp — `InboxController::applyUnrepliedFilter()`
  (`app/Http/Controllers/Manage/Whatsapp/InboxController.php:1131`) and `applyReviewFilter()` (:1115),
  the same predicates that feed the inbox's own count chips.
- "AI tried and failed" queue — do not invent a predicate; use
  `app/Console/Commands/LeadDistribution/ReapAiFailed.php:41-72`, because that is what the nightly
  job actually pools on.
- Cost — `ai_voice_calls.cost_usd` (`src/VoiceAgent/AiVoiceCall.php:165`).

**Missing:**
- **Today's appointments** has no ready query. `ResolvesLeadAppointments::nextAppointmentsForLeads()`
  (`app/Http/Controllers/Concerns/ResolvesLeadAppointments.php:35`) is *next-upcoming-per-lead* only
  — it filters `>= now()` (:52, :68), so it drops everything earlier today. Copy its **shape**
  (merge `appointments` + `zoom_meetings`), not the query. `appointments.scheduled_at` is **not
  indexed** (`2026_06_16_100001:22`).
- No AI-call aggregates anywhere. `AiCallsController` is per-row; its `statusCounts` (:68) and
  `total` (:76) are built on a **fresh builder** that ignores the page's filters and any date bound.
  Per-campaign counts do exist (`AiProfilesController.php:70-71, 107-108`) and a batched
  latest-call-per-lead resolver exists (`EventsController::aiCallsForLeads()` :1006).
- No "unassigned appointments" — `appointments` has no assignee (see §5).
- No "nurture" state anywhere (`lead_funnels.follow_up_status` is a human's manual outcome, not a
  nurture bucket).

**Honesty:** the appointment funnel has **4 rows and 0 recorded outcomes**, and `engagements` has
**0 rows at `STATUS_APPOINTMENT_SET`** (live spread: New 31, With Closer 2, Booked 60, Converted 204,
Lost 193). Render "not recorded", never 0 %.

### §5.2 Coverage — **PARTIAL, with two metrics BLOCKED**

Every contact PETA makes is recorded — in **five** separate ledgers, never together:
`ai_voice_calls`, `funnel_automation_sends`, `funnel_whatsapp_sends`, `whatsapp_messages` (+
`whatsapp_broadcast_recipients`), and `call_recordings`. There is no unified "we worked this lead"
table and **`activity_logs` is not one** — its 34 `TYPE_*` constants
(`src/Common/ActivityLog.php:23-103`) have no outbound-contact type; the only admin acts are
`TYPE_LEAD_ASSIGNED` (:63) and `TYPE_LEAD_POOLED` (:64). It does already carry `lead_id` + a dedicated
`occurred_at` with an index on `(lead_id, occurred_at)` (`2026_07_17_000001:47, :51`), so adding
contact-attempt types is constants-only, no schema change.

**Buildable now:** "N of N leads worked" as a UNION of the five ledger `lead_id` sets;
time-to-first-contact as a `MIN()` across them minus `lead_funnels.registered_at`; attempts per lead;
after-hours split; cost per call/day. Copy the assembly shape from
`app/Http/Controllers/Manage/Messages/DashboardController.php:95-158` (one bounded period select,
everything computed in PHP) and `app/Http/Controllers/Manage/Calls/DashboardController.php:43-51`.

**Three traps that will silently corrupt the numerator:**
1. `disconnection_reason NOT IN (REFUSAL_REASONS)` is **NULL-unsafe** — it drops every PENDING /
   IN_PROGRESS row and any settled row with no reason. PETA does it correctly in PHP:
   `in_array((string) $call->disconnection_reason, AiVoiceCall::REFUSAL_REASONS, true)`
   (`EventsController.php:1032`).
2. `call_recordings` has `SOURCE_TEST = 5` (`src/Call/CallRecording.php:29`) and a
   `NotIgnoredScope` global scope (`src/Call/Scopes/NotIgnoredScope.php`) — say which rows count.
3. `call_recordings.called_at` is **UTC**, not KL wall-clock — the migration says so
   (`2026_06_04_000001:41`) and both existing consumers convert
   (`Manage/Calls/DashboardController.php:112`, `SendCallBrief.php:99`). Everything else in PETA is
   Asia/Kuala_Lumpur wall-clock (`config/app.php:71`; `RetellCallMapper::timestamp()`
   `src/VoiceAgent/Support/RetellCallMapper.php:209-214`). A naive `HOUR()` here is 8 hours wrong.

**BLOCKED — see §7 for the exact wording each metric may use:** "follow-ups sent on schedule (%)"
(no due timestamp on either send ledger), "retries honoured" (no retry ladder exists), "promised
callbacks honoured" (nothing records a promised time).

### §5.3 Leads + lead detail — **BUILDABLE NOW** (qualification columns PARTIAL)

**Reuse, do not rebuild.** `LeadsController::index()` (`app/Http/Controllers/Manage/Leads/LeadsController.php:358-578`)
already ships: `LeadVisibility` scoping, whitelisted sort (`applySort` :624-709), status/source/funnel/
membership/agent/fake filters *with matching counts* (:537-570), six per-channel engagement
aggregates as correlated subqueries (:460-467) plus `lastEngagement()` (:588), the WhatsApp deep-link
uuid (:449-457) over a builder carrying the security-critical 1:1 + non-sandbox filters (:373-379),
and `can_assign` (:3250). Its `LeadQueryRequest` and `Pages/Manage/Leads/Index.vue:73-111` complete
the pattern. **Adding AI-call columns is ~30 lines; a second leads list is a fork.**

The **lead detail screen is a new tab on `LeadsController::show()`** (:868 → `Inertia::render` :1284,
~50 props), which already carries `aiVoiceCalls` (:1336 → `aiVoiceCallRows()` :3438, gated on
`Permission::VIEW_CALLS`), `appointments`, `engagements`, `discussion`, `enrichment`,
`callRecordings`, `whatsappThreads`.

**Stage column.** Use `Engagement::STATUSES` — **not** the spec's 7-value guess (see §6).

**Qualification columns are PARTIAL.** intent / budget / timeline / ownership / interest_level exist
only inside `ai_voice_calls.analysis->custom_analysis_data`, and the **field names differ per
campaign** (verified against the 4 live `ai_call_profiles` rows): intent is `goal` / `buy_intent` /
`buy_purpose` / absent; budget is enum `budget` on three profiles and free-text `budget_range` on the
fourth; `timeline` exists on **one** profile which has **zero calls**; `interest_level` is on all four
but with **two scales** (high/medium/low/none vs hot/warm/cold). `MAX_EXTRACTION_FIELDS = 12`
(`src/VoiceAgent/AiCallProfile.php:98`) bounds the normalisation table the console must own. Render
from the **call's own frozen `settings_snapshot['extraction']`** (`AiCallProfile::canonicalSettings()`
:218; 15 of 16 live rows carry one), never from the profile's current schema. `analysis` is an
unindexed json column (`2026_08_18_150000:57`) — JSON-path predicates compile
(`AiCallAftermath.php:229`) but full-scan, so per-lead reads are fine and **list filters are not**.

**Sub-states:** "in nurture" is derivable in **two hops** (`leads.user_id` →
`whatsapp_contacts.user_id` → `whatsapp_flow_runs.contact_id`, both indexed —
`ReapAiFailed.php:49-54, :68`), affordable per lead, batch it for a list. **"In retry" does not
exist.**

### §5.4 WhatsApp review — **BUILDABLE NOW by reuse; per-agent accountability BLOCKED**

PETA already ships a WhatsApp-Web-style supervisory inbox: `InboxController.php:106 → :133 → :323`
`Inertia::render('Manage/Messages/Inbox')`, a 2,929-line page, spanning two WhatsApp providers plus
Messenger. Live: 31,269 messages / 3,523 conversations / 3 channels.

**The right primitive for a console is the read-only preview**: `InboxController@preview` (:545,
route `:715`) + `Partials/PreviewConversationModal.vue` — deliberately SELECT-only: no `last_read_at`
stamp, no read job, **no blue ticks on the customer's phone**. Render with
`Components/Conversation/ConversationThread.vue` over `MessageBubble.vue`, which already satisfies
**acceptance criterion 7** (AI = violet `bg-[#ece5ff]` + Sparkles pill, :59/:154; Flow = blue, :60/:158;
human = green). For a lead's threads outside the inbox the *only sanctioned* path is
`Src\Whatsapp\Support\LeadConversationPresenter::forLead()` (:88) — three security rules are baked in
(:50-67).

**"Take over from the AI" is built**: `ai_handoff_at` + `WhatsappRepository::handOff()/resumeAi()`
(`src/Whatsapp/Repositories/WhatsappRepository.php:1093, :1112`), routes `:706-707`, enforced at
`GenerateAiReply.php:135`.

**Two security traps a new query will not inherit:**
- `LeadVisibility::applyToConversations()` (`src/Auth/Support/LeadVisibility.php:184`) does **not**
  exclude GROUP threads — a group resolves through `contact.user.lead` and passes. Add
  `chat_type = CHAT_INDIVIDUAL` yourself (`LeadConversationPresenter.php:52-58` documents this).
- The conversation **list is not channel-scoped**. `AccountVisibility::applyWhatsappChannels()` is
  applied only to the `channels` dropdown prop (`InboxController.php:334`); the list (:166), counts
  (:243), open thread (:259), markRead (:400) and preview (:550) are scoped by `LeadVisibility`
  alone. An agent holding only `view-whatsapp-channels-own` still sees threads on numbers they do
  not own.

**Missing:** a manual "flag for attention" (the three existing signals — `needs_review`, unreplied,
`phone_pinned_at` — are all machine-derived; person-level `whatsapp_tags` are the nearest human-set
marker); **per-agent unread** (`last_read_at` is team-global by design,
`2026_06_15_000001:8-9`); **thread ownership** (`assigned_admin_id` is a dead column, 0 of 3,523 rows
— though **channel-level ownership does exist**: `whatsapp_channels.user_id` + `group_id` via
`AccountVisibility::applyWhatsappChannels` `src/Auth/Support/AccountVisibility.php:109-119`, which in
a team where each agent runs their own number may be sufficient); **automation provenance in the
payload** — `MessagePresenter` exposes `ai` and `flow` only, ignoring `meta.funnel_whatsapp` (3,628
live rows), `meta.broadcast` (333) and `meta.source` (`login_otp`, `ai_call_button`,
`ai_call_followup`), so **~3,900 automated sends render as human-green**; and **in-thread search**
(client-side over the loaded 200, `Inbox.vue:1157`).

**Note the AI-call ↔ WhatsApp join already exists in both directions**: `SendAiCallFollowUp` stamps
`whatsapp_messages.meta.ai_voice_call` + `meta.source = 'ai_call_followup'`
(`app/Jobs/Automation/SendAiCallFollowUp.php:229-241`) and the call keeps
`meta.followup_message_id` (:170), read back by `AiCallPresenter` (:36, :118). Only 1 live row —
barely exercised, but the shape is built.

### §5.5 AI calling — **PARTIAL (and partly already shipped elsewhere)**

**⚠️ This screen already exists under another name.** `GET /manage/calls/ai-calls`
(`routes/web.php:474`, name `manage.calls.ai-calls.index`, permission `view-calls`) →
`Manage\Calls\AiCallsController@index` → `Pages/Manage/Calls/AiCalls/Index.vue`, inside SectionTabs'
`calls` section (`SectionTabs.vue:95`). It has the transcript-in-expand-row UI, the WhatsApp delivery
tick ladder, the inline `<audio>` player (:209) and the two write actions (block / unblock number,
`routes/web.php:472-473`, `manage-calls`). **Decide explicitly whether the console re-hosts this list
under the suite permission or links across to it** — rebuilding it is the expensive mistake.

**Reuse:** `AiCallPresenter::row()` (:67) + `deliveryMap()` (:31, one query per page);
`AiCallsController.php:56` for the cheap `is_blocked` (one `pluck`→`flip` of the whole blocklist, not
an `exists()` per row); `App\Actions\PlaceAiVoiceCall::run()` (`app/Actions/PlaceAiVoiceCall.php:62`)
for any "call now" button — never Retell directly; guards (blocklist, 09:00–21:00 window :34-35,
daily USD fuse :151) live inside and refusals come back as auditable FAILED rows.

**Missing / blocking:**
- **No `LeadVisibility` at all** on `AiCallsController::index` (:40-79) — an agent leader would read
  every group's calls and transcripts. Must be added before this screen ships, **and to any stat
  row**, or tiles count calls the leader cannot open.
- **No business outcome column.** `status` is connection lifecycle; `is_successful` is Retell's own
  coarse verdict (6 true / 9 false / 1 null live) and must never be labelled "appointment booked".
  The precedent to copy when adding one is `Appointment`'s own status-vs-outcome split
  (`src/Appointment/Appointment.php:47-49` vs `:104-108`).
- **No detail route** (`routes/web.php:470-474` has no `GET ai-calls/{id}`) and a narrow sort
  whitelist (`SORTABLE = ['created_at','duration_seconds','cost_usd']`, `AiCallsController.php:32`).
- **No filter** for campaign, sentiment, source or outcome (`AiCallQueryRequest.php:22` =
  search/status/date_from/date_to).
- **No export** (`app/Exports/` holds 8 classes, none for calls).
- `recording_url` is the **provider's bare CloudFront link**, unsigned, unproxied, not mirrored to
  GCS (`2026_08_19_010000:8-12`, played bare at `Index.vue:209`). A leader-facing page hands out a
  URL that is not permission-checked once copied. 7 of 16 rows have none.
- `sentiment` is a free provider string with **no constant map** in this module — live values
  `Positive` 3 / `Neutral` 6 / `Unknown` 6 / 1 `NULL`. Do not map it through
  `Src\Conversation\ConversationAnalysis::SENTIMENTS` (a different module's enum).
- Three doors place calls, and a count of "calls the AI placed" must know all of them:
  `app/Jobs/Automation/SendFunnelAiCall.php` (per **`lead_funnels` registration**, gated on
  `ai_call_opt_in`, `LandingController.php:845`), the self-service
  `Main\AiCallbackController` (7-day token), and the WhatsApp `AI_CALL_AGAIN` quick reply
  (`ProcessInboundWhatsAppWebhook.php:2271`, which stamps `dynamic_variables.is_repeat_call = 'yes'`
  — the one directly queryable "requested callback" marker). `app/Console/Commands/ReconcileAiVoiceCalls.php`
  is why a status can change hours after the call.

### §5.6 Campaigns — **REMOVED 2026-09-07. Do not build from this section.**

> The suite's Marketing page (`/manage/appointment-engine/marketing`), its controller and its
> `ae_campaigns` / `ae_campaign_days` models were deleted on the owner's instruction. Nothing
> ever wrote those two tables, so the page could only draw an empty report. Ad performance is
> the Operations suite's Traffics page (`/manage/marketing/campaigns`), which reads the real
> `meta_ad_insights`. The analysis below is kept as the record of what was surveyed at the time.

Meta is connected and syncing: 2 ACTIVE integrations, 23 ad accounts, 3,512 `meta_ad_insights` rows
current to today. **`app/Services/Marketing/AdPerformanceService::rollup()` (:59) is the single
definition** of spend / impressions / clicks / meta_leads / our-leads / buyers / revenue / CPL / CPA /
ROAS / conversion rate / profit / `is_delivering`, at campaign, adset or ad grain — and
`Manage\Marketing\CampaignsController@index` (:66) already assembles the whole row (account name,
status badge via `MetaAdSetting::badge()`, targeting/creative expansion, totals, sync freshness).
`FunnelDashboardService::campaignBreakdown()` (:244) is the closest existing analogue to this screen
and, importantly, reports `untagged_leads` (:269-272) — the honesty this console needs.

**Blocked / missing:**
- **Pause / resume / budget edit is impossible.** `FacebookAdsService` is GET-only (every method
  issues `$request->get(...)`); the only Graph writes are `MetaAdLauncherService::launch()` creating
  new objects with status hardcoded `'ACTIVE'` (:94, :152, :162) and `rollback()` deleting them
  (:279). Per the spec's own rule: **render the control disabled**, do not fake it.
- **Appointments per campaign does not exist** — `appointments` has no campaign/ad column and no
  marketing service computes it. The only path is `appointments.lead_id` → `lead_funnels`
  (first touch) → `campaign_id`. `appointments.lead_id` **is** indexed (`2026_06_16_100001:19`);
  `lead_funnels.campaign_id` is **not** (`2026_06_18_000002:31-33`) — that index is the real
  prerequisite.
- **No group scoping** on the Traffics page (no `GroupScope`, no `LeadVisibility`; gated by
  `view-marketing` alone) and `meta_ad_insights` has no `group_id`. A leader would see all 23
  accounts. `CampaignMappingController.php:46` shows the correct pattern.
- **Attribution coverage is thin**: 576 of 12,157 `lead_funnels` rows carry a `campaign_id`. On the
  default `last_30d` view that means 4 rows shown, 3 with our-leads — **one blank row, not thirty**
  (the "35 campaigns" figure is all-time). Still: show "Meta leads" beside "our leads" as Traffics
  already does (`Campaigns.vue:97-98`).
- **No `platform` concept** (no Google/TikTok anywhere) — hardcode `'Meta'`.
- No PETA uuid on a performance row (`AdPerformanceService.php:244` sets `id` to Meta's raw id), so a
  detail route keys on the Meta id string or is restricted to mapped campaigns.
- No export; the page is unpaginated with client-side search (`Campaigns.vue:313`).
- If no ACTIVE integration exists, `CampaignsController::index` **redirects** to
  `manage.facebook.index` (:68-70) — any reuse inherits that state.

**Already wired and directly relevant:** a `Schedule` Conversions-API event fires today from
`app/Http/Controllers/Main/ConsultationController.php:246` via
`App\Http\Controllers\Concerns\ReportsMetaConversions`. Constraint: the trait must run inside a
request because `_fbp`/`_fbc` are the visitor's cookies (:17-21), so an admin-created appointment
cannot use that path as-is.

### §5.7 Automation canvas — **BLOCKED as specified**

There is **no `Workflow`, `Node`, `Script`, `KnowledgeItem` or `WhatsAppSequence` class** in PETA.
There are two engines and neither is the spec's:

| | `WhatsappFlow` | Funnel rules |
|---|---|---|
| Ordered steps | ✅ `position` (`2026_06_30_000007:24`) | ❌ no order column; sorted by (trigger, offset) for display (`FunnelsController.php:1260`) |
| Branching | ✅ `options` json → step_key / `end` / `ai` / `handoff` (`WhatsappFlowStep.php:41-49`) | ❌ |
| Versioning + run snapshot | ✅ `steps_version`/`steps_hash` (`2026_07_12_000002:24-27`); a save **cannot** re-target a mid-drip run (`WhatsappFlowRun.php:160`) | ❌ live on the next 5-minute scan |
| Can place a call / email / SMS | ❌ types are text/media/template only | ✅ `MEDIUM_EMAIL/SMS/VOICE/AI_CALL` (`FunnelAutomationMessage.php:33-45`) |

**Buildable now:** a **read-only** canvas. `Pages/Manage/LeadDistribution/Flow.vue:109-114` is the
exact interaction (Vue Flow + dagre, `onNodeClick` → Modal hosting the real settings panel);
`Components/Whatsapp/FlowGraph.vue` renders a flow; `Src\Whatsapp\Support\FlowPresenter::diagram()`
(`src/Whatsapp/Support/FlowPresenter.php:229`) is the server-side graph DTO both Vue components
already consume. Deps are installed (`package.json:23, 40-42`). Write-back doors exist:
`WhatsappFlowRepository::update()` (:51, transactional, auto-deactivates a flow an edit would break,
`FlowsController.php:334-338`) and `FunnelAutomationMessageRepository::update()` (:57).

**Blocked:** one canvas over one workflow object. That needs a decision the spec does not make —
extend `WhatsappFlowStep` with call/SMS/email node types (copy `ai_call_profile_id` from the funnel
rule, don't invent an abstraction), or build a third engine. Also missing: **node coordinates** (and
the obvious shortcut is booby-trapped — `whatsapp_flow_steps.meta` is folded into `canonicalSteps()`
`WhatsappFlow.php:214`, so *dragging a node would fork a version*; use dedicated columns excluded
from the hash); **an assign node** (though automated consultant assignment already runs —
`FunnelCallerRotationRepository::assignCaller()` from `ConsultationController.php:134` and
`LandingController.php:218`); **a tag node**; **templating/cloning** (and `flow_type` is fixed after
creation, `StoreRequest.php:11`).

**The variable picker is a live hazard.** The funnel bag is **14 keys**
(`FunnelWhatsappComposer::assemble()` `src/Event/Support/FunnelWhatsappComposer.php:218-236`):
name, first_name, funnel, whatsapp_group_link, login_link, event_title, event_date, event_time,
event_location, register_link, ticket_link, video_link, watched_minutes, completion_percent. The
flow engine has three (`AdvanceFlowRun.php:767-771`) plus the run's `variables` bag.
`{{project_name}}`, `{{price}}`, `{{downpayment}}` and `{{consultant}}` are **not tokens** — and the
funnel engine resolves an unknown token to the **empty string** (`substitute()` :622-628), so a
spec-written template ships blanks to customers with nothing failing. (The flow engine leaves it
literal — `strtr` :781. Two opposite behaviours; do not conflate them.) `{{price}}` must come from
`Project::canonicalPriceFrom()/canonicalPriceTo()` (:270/:280) or `canonicalFacts()`, never the raw
`projects.price_*` columns; likewise `{{project_name}}` must be `canonicalName()` (:231).
`{{downpayment}}` has **no source column anywhere**.

### §5.8 Content library / knowledge — **PARTIAL**

**Knowledge is siloed twice and neither silo is shared:** `ai_call_kb_entries` are per
`AiCallProfile` and shipped to a per-profile Retell KB that is recreated each sync and the old one
deleted (`RetellAgentSync.php:96-97, :428`) — PETA never retrieves from them; the WhatsApp global
profile's `documents` are Media files attached as **raw multimodal attachments** to a reply
(`GenerateAiReply.php:447-459`). Nothing bridges them, and the only "system-wide" surface is
read-only (`CopilotController::knowledge()` :645).

**But retrieval is NOT a greenfield build.** `catalog_doc_pages`
(`database/migrations/2026_08_14_700002_create_catalog_doc_pages_table.php`) is a per-**page** index
over a project's official PDFs with a MySQL FULLTEXT **ngram** index, and
`App\Actions\AnswerHkDocQuestion` is a working retrieve-then-read pipeline over it (keyword
expansion → `MATCH … AGAINST` :117-119 → LIKE fallback → answer) under registered prompt keys
`PROMPT_HK_DOC_KEYWORDS` / `PROMPT_HK_DOC_ANSWER`. It lives on the `catalogue` connection.
**Extend this rather than starting a knowledge store.** There are **no embeddings and no vector
store** anywhere in the repo (`TranscriptEvidenceRetriever.php:10` states the house position
explicitly), so "the AI searches the knowledge base" means lexical retrieval or Retell's own opaque
per-agent KB — say which.

**Project scoping:** `ai_call_profiles`, `ai_call_kb_entries`, `whatsapp_ai_profiles`,
`whatsapp_flows` and `funnel_automation_messages` carry **no `project_id`**. But project-scoped
*content* exists at scale — 36 migrations add a `project_id`, including `catalog_doc_pages`,
`catalog_ai_contents`, `catalog_media`, `flg_brochures` (`src/FacebookLeadGenerator/Brochure.php:64`),
`whatsapp_cta_links` and `event_funnels`.

**A unified library screen would today read four disconnected stores** (`AiLearningResource`, Media
collections `whatsapp_flow` / `funnel_whatsapp` / `whatsapp_ai`, `flg_brochures`,
`ai_call_kb_entries`) with no shared category, owner or scope.

### §5.9 First-property signal — **PARTIAL**

| Spec field | Reality |
|---|---|
| `selfReportedFirstProperty` | **EXISTS.** `leads.properties_owned`, unsignedTinyInteger, nullable, **indexed** (`src/Lead/Lead.php:83`; `2026_07_21_000001:27`). **NULL ≠ 0** — NULL means never asked, 0 means owns none (:107-110). Live: 8,881 at 0, 277 at 1, 183 at 2, 64 at 3, 45 at 5, 2,544 NULL, of 11,994. ⚠️ **The existing filter cannot select it**: `properties_min` is guarded `if ($n >= 1)` (`LeadQueryRequest.php:120-124`), so the 8,881 first-property leads are unreachable from the Leads screen. The console needs an equality mode or a new filter. Writer is `LeadRepository::setPropertiesOwned()` (:179) — there is no `LeadRepository::update()` — whose only caller is `BulkMemberImportAction.php:310`, so it is empty for ad-sourced leads. |
| `systemObservedViewings` | **DERIVABLE**, keyed on `leads.id` only: `Appointment::where('lead_id')->whereIn('type', [SHOWROOM_VISIT, SITE_VISIT])->where('outcome', OUTCOME_ATTENDED)`. Use `outcome = ATTENDED`, not "a row exists" — a booked no-show is not a viewing. `appointments.lead_id` is indexed; batch with `whereIn`. Merges correctly after an identity merge (`IdentityChildMap.php:83` classifies it `MERGE_REPOINT`). **Do not key on phone** (`ContactKeyHistory.php:11-16` forbids it); resolution is `Src\Lead\Services\LeadLinker`'s job. Returns 0 for every lead today (4 appointments, 0 outcomes). |
| `buyingJourneyStage` | **GAP.** No column, constant or extraction field means this. The closest *stored* fact is `consultation_requests.timeline` — a real enum column (`TIMELINE_ASAP` / `1_3_MONTHS` / `3_6_MONTHS` / `UNDECIDED`, `src/Appointment/ConsultationRequest.php:80-90`) alongside `budget` and `goal` (:49-77), with 174 live rows. It is per-enquiry, not per-lead, so it is not a drop-in — but it is a far better precedent than the unindexed per-campaign call JSON. The AI's `ownership` answer (none/first/second/three_or_more, on two profiles) is **never promoted** into `leads.properties_owned`, so the two sources can disagree and the console must declare which wins. |

### §5.10 Agent allocation — **PARTIAL (extend, do not invent)**

**A complete routing engine already exists** in `src/LeadDistribution/`: lead tiers, salesperson
tiers with weekly entitlement allocations, assignment history, three settings-driven recycle rules
and a self-service pull. §5.10 must extend it.

**Reuse:** `LeadDistributionService::assign()` (`app/Services/LeadDistribution/LeadDistributionService.php:40`)
— its docblock says every assignment MUST go through it; `LeadRequestService::requestFor()` (:43),
whose own docblock (:22-24) names itself as **the seam a future attribute-based matcher plugs into**;
`SalespersonTierScorer` (:79, :93); `DistributionConfig::forPage()`
(`src/LeadDistribution/Support/DistributionConfig.php:20`) which returns tiers + rules + live pool
count already shaped for Inertia; `PoolScope`; and — the one the spec's screen most resembles —
**`Manage\LeadDistribution\WeeklyTiersController` + `Pages/Manage/LeadDistribution/Weekly.vue`**
(`routes/web.php:1195-1196`): an agents × week × score × tier table with an admin override. Do not
redraw it.

**Blocked / missing:**
- **Routing by lead attribute does not exist.** The editable rules
  (`DistributionSettings`, `src/LeadDistribution/Support/DistributionSettings.php:12`) are **recycle
  triggers** — "AI could not book within N days", "no appointment in N days", "no booking in N days".
  They decide when a lead is taken *back*, never who it goes *to*. Matching today is by **project
  only**.
- **No per-agent close rate anywhere.** The only `close_rate` in the repo is per project
  (`SalesProjectsController.php:2498`). Deriving it per agent means joining
  `engagement_assignments` × `engagements` and deciding whether "owns" means the closer role, the
  appointment role, or any role.
- **`salesperson_tier_weeks` is empty (0 rows) and the cause is not a missing cron.** The command
  *is* scheduled (`app/Console/Kernel.php:337`, weekly Mon 00:30). `SalespersonTierScorer::agents()`
  (:66) filters on the `sales-agent` role and **no user holds it** — live `model_has_roles` is
  super-admin 11, admin 1, marketing 1, member 417, non-member 11,597. **Zero `sales-agent`, zero
  `sales-leader`.** Every agent renders "—".
- **Appointments have no assignee column.** Ownership is `created_by`, and the whole app treats it
  that way (`AppointmentsController.php:62/109/139` all `abort_if created_by !== viewer`; the tier
  scorer counts `Appointment::where('created_by', …)` `SalespersonTierScorer.php:81`). A leader
  reassigning an appointment is unrepresentable. `created_by` and `scheduled_at` are **not indexed**
  (live index list: id, uuid, lead_id, project_id, type, status, engagement_id, outcome).
- **No agent specialisation / language / availability** anywhere on `User`, `UserProfile` or `Admin`.
- **Appointments have no list route at all** — `routes/web.php:970-977` exposes only store / update /
  `{id}/outcome` / `{id}` destroy under `VIEW_APPOINTMENTS` and `MANAGE_APPOINTMENTS`. Any console
  list is new construction and should reuse `VIEW_APPOINTMENTS` rather than mint a rule.
- **`appointments.engagement_id` is a dead path**: NULL on all 4 live rows, absent from the
  repository's `data_only` whitelists (`AppointmentRepository.php:26-36, :53-63`), populated once by
  a committed backfill (`2026_07_14_100004`). Join to an engagement on `(lead_id, project_id)`.

### §5.11 AI document ingestion — **BUILDABLE NOW**

Three independent, working parsers already exist, so no new dependency is needed:
- `Src\AiLearning\Support\ResourceText::forResource()` (`src/AiLearning/Support/ResourceText.php:82`)
  — pdf / pptx / docx / txt / md / csv, dispatching at :109-115 (there is **no html arm**; HTML is
  read only for a `KIND_SITE` resource via `fromSite()` :88-90). Parses PDF **bytes** so it works
  against GCS. `LIMIT = 8000` chars (:41), `MAX_BYTES = 25MB` (:60), `MAX_ENTRY_BYTES = 32MB`
  zip-bomb guard (:73). Every method fails soft.
- `App\Services\FacebookLeadGenerator\BrochureExtractionService` (:171 `pdftotext -layout`, :45
  `pdfimages`) — **project-scoped**, with a real ingestion state machine on `flg_brochures`
  (`src/FacebookLeadGenerator/Brochure.php:21-37`) and three jobs writing `flg_brochures.analysis`.
  Poppler is installed (`/usr/bin/pdftotext`).
- `Src\Common\Pdf\PdfTextExtractor` (:37) — positioned text, opens encrypted files, refuses
  unsupported profiles **by name** rather than returning empty.

Summarisation pattern: `app/Jobs/AiLearning/SummariseResource.php:33` (an `AiJob` under registered
key `AiRequest::PROMPT_AI_RESOURCE_SUMMARY`, generated **once** at upload so running cost is zero).

**GAP: no OCR.** `ResourceText`'s own docblock (:25-29): a PDF whose pages are scanned images yields
nothing, and OCR is "a far larger dependency than a parser". Malaysian developer collateral is
routinely scanned, so this bites in practice — the screen must report an empty extraction as
"scanned, no text layer", not as a successful ingest. Also: **any new AI behaviour needs a registered
`AiRequest::PROMPT_*` key** (`src/Ai/AiRequest.php:50-133` + `config/ai_prompts.php`); none exists for
this suite.

---

## 5. Gap register

Ordered by how much it blocks.

| # | Gap | Size | Blocks | Suggested resolution |
|---|---|---|---|---|
| 1 | **Nothing writes an `appointments` row except a human.** `AppointmentRepository::create()` has one caller (`AppointmentsController.php:43`). The AI's `appointment_booked` is a JSON answer, on 2 of 16 live rows, both `lead_id` NULL | service | §5.1, §5.2, §5.6 (cost-per-appointment), §5.10, and the product's core claim | A job that promotes a settled call's `appointment_booked` + slot into an `appointments` row via `AppointmentRepository::create()`. Consider routing through `consultation_requests` first — it already models the pre-appointment record, has `assigned_admin_id` and `appointment_at`, and its `preferred_slot` bands (`ConsultationRequest.php:30-46`) match AI profile #1's `preferred_slot` enum **one-for-one** |
| 2 | **`sales-leader` and `group-super-admin` hold neither console permission.** Live: rows 83/84 granted to `super-admin` + legacy `admin` only. And **no migration creates them** — deploys run `migrate`, never `db:seed` (`scripts/deploy-update.sh:625`) | column | Every screen, on every install including this one for the target persona | Add `VIEW_APPOINTMENT_ENGINE` to `$leader` and `$groupSuperAdmin` (`app/Actions/SeedCommonRolesAction.php:104, :116`) **and** a migration cloned from `2026_08_21_100002_grant_agent_app_lead_search_permission.php` |
| 3 | **Suite nested inside `features.projects_enabled`** (`routes/web.php:1703` wrapping `:1742`); `.env.example:438` ships it false while the Inertia share exposes only `appointment_engine` | service | Every screen on any non-dev install: nav renders, links 404 | Lift the block out of the `projects` `if`, or gate the Inertia share on both flags |
| 4 | **No `LeadVisibility` on `AiCallsController::index`** (`:40-79`, `view-calls` only); none on the Traffics page either (no `GroupScope`, no `group_id` on `meta_ad_insights`) | service | §5.5, §5.6 for an *agent leader* — the console's whole premise | Add `whereHas('lead', fn ($l) => LeadVisibility::apply($l, $user))` to the list **and every stat**; for campaigns, scope through `meta_campaigns.group_id` (`CampaignMappingController.php:46`) or add a column |
| 5 | **No due/scheduled timestamp on either send ledger** (`2026_07_29_100001:50-70`, `2026_07_01_000020:51-70`, and none of the six ALTERs adds one) | column | §5.2 "follow-ups sent on schedule (%)" — see §7 | Nullable `due_at` written at claim time by both scanners, which already compute it (`SessionDueWindow::dueAt()` :172 exists). Interim: `sent_at − created_at` is an honest **dispatch latency**, since `created_at` is the claim moment |
| 6 | **No business outcome column on `ai_voice_calls`**, and extraction field names differ per campaign (`goal`/`buy_intent`/`buy_purpose`; `budget` vs `budget_range`; `timeline` on one profile with zero calls; `interest_level` on two scales) | column + service | §5.3 qualification columns/filters, §5.5 outcome column, every funnel metric | Add `outcome` + `OUTCOMES` modelled on `Appointment`'s status/outcome split, written by `AiCallAftermath` from a per-profile key map. Until then render per-profile from each call's frozen `settings_snapshot['extraction']` |
| 7 | **No unified contact ledger; `activity_logs` has no outbound type** (`src/Common/ActivityLog.php:23-103`) | table | §5.2 "N of N worked, 0 dropped" as one provable query | Add contact-attempt `TYPE_*` constants written at the five send sites — `activity_logs` already has `lead_id` + `occurred_at` + the index, so **no schema change** |
| 8 | **No lead-grain AI-call aggregate** (attempt counts, first-call timing, answered/connected/booked rates) and no `(lead_id, source[, created_at])` composite index | service + column | §5.1 tiles, §5.2 call metrics, §5.3 per-lead columns | Copy `EventsController::aiCallsForLeads()` (:1006 — one grouped query, `is_refusal` precomputed) and widen it; add the composite index before shipping per-row columns |
| 9 | **`ai_voice_calls.analysis` is unindexed JSON** with no generated columns (`2026_08_18_150000:57`) | column | Any list filter or count on budget / intent / interest / appointment_booked — a network-bound full scan on remote Cloud SQL | Promote the 2–3 fields the console depends on to real columns (start with `ownership` → `leads.properties_owned` and `appointment_booked`) |
| 10 | **`lead_funnels.campaign_id/adset_id/ad_id` are not indexed** (`2026_06_18_000002:31-33`) | column | Every our-leads / revenue / CPL / ROAS figure, and appointments-per-campaign the moment it is added as a fourth correlated subquery | One index on `campaign_id`. (`appointments.lead_id` is already indexed — only this half is missing) |
| 11 | **No appointment ↔ AI call ↔ WhatsApp link.** No `ai_voice_call_id` on `appointments`, no `conversation_id`; the only WhatsApp trace is `ConsultationRequest.whatsapp_opened_at` (:110) | column | Attribution for §5.6 cost-per-appointment and §6 "commission saved" | Add `ai_voice_call_id` (nullable) when gap #1 is built; the WhatsApp side already has the pattern (`SendAiCallFollowUp` stamps `meta.ai_voice_call`) |
| 12 | **No ad → WhatsApp attribution.** Nothing parses Meta's `referral` object (no `referral`/`ctwa_clid`/`source_id` anywhere in `src/Whatsapp/`, `app/Jobs/Whatsapp/`, `wa-bridge/src`); `whatsapp_cta_links` has no ad column | integration | The console's funnel head — Meta ads → WhatsApp leads, and all cost-per-appointment maths on that path | Parse `referral` in the Cloud webhook into a capture row. **`messenger_captures` is the working model** (`campaign_id/adset_id/ad_id` + `lead_id` + `conversation_id`, unique per contact) |
| 13 | **No workflow object that can both branch and call.** Two engines, neither complete (§5.7 table) | service | §5.7 entirely | Extend `WhatsappFlowStep` with call/SMS/email types (copy `ai_call_profile_id`); add `pos_x`/`pos_y` **excluded from `canonicalSteps()`**, never in `meta` |
| 14 | **Appointments carry no assignee, no source, no duration, no reminder state, no list route, and no visibility scoping** (the calendar scopes by `created_by` with a Super-Admin-only "all", `CalendarController.php:126-127`) | column + service | §5.1 queues, §5.10, any leader team view | Decide the scoping rule first (LeadVisibility through `leads`, or `Team::memberUserIds` against `created_by`), then add `assigned_admin_id` + `source` |
| 15 | **No retry ladder, no promised-callback record.** No scheduled re-dial command exists; `PlaceAiVoiceCall` places exactly one call | service | §5.2 "retries honoured", "promised callbacks honoured" | Either build the ladder (a column + a job) or ship "call attempts per lead" under that honest name |
| 16 | **No shared knowledge store; no embeddings** | table + integration | §5.8 | Extend `catalog_doc_pages` + `AnswerHkDocQuestion` (already page-grain, project-scoped, FULLTEXT ngram) rather than starting one |
| 17 | **A quiet-hours deferral leaves no trace** — `SendFunnelAiCall` re-dispatches itself and the ledger row stays QUEUED (`:101-112`) | column | §5.2 after-hours honesty — a politely deferred call is indistinguishable from a stuck queue | `deferred_until` on the send ledger. Interim: "QUEUED with an old `created_at`" isolates the set |
| 18 | **No realtime transport on this box.** `BROADCAST_DRIVER=log` (`.env:38`), no `REVERB_*` so `echo.js:13-15` never constructs Echo, `WHATSAPP_REALTIME=false` (`.env:138`), no reverb service; `routes/channels.php` has 5 channels (none for leads/calls/appointments) and nothing in `src/VoiceAgent` implements `ShouldBroadcast` | integration | Any screen described as "live" | Use `useRefreshOnFocus.js:39` by default and the 6 s partial `router.reload` (`Inbox.vue:1581-1589`) only for an active-calling queue. Real push needs Reverb + `a2enmod proxy_wstunnel` + a GCP VPC rule not editable from inside the VM (`docs/modules_handbook/production-setup/reverb.md:11-15`) |
| 19 | **No `properties_owned == 0` filter** — `properties_min` is guarded `>= 1` (`LeadQueryRequest.php:120-124`) | column | §5.9/§5.10 routing on the first-property signal — 8,881 leads unreachable | Add an equality mode to the existing filter |
| 20 | **No OCR** (`ResourceText.php:25-29`) | integration | §5.11 for scanned collateral | Report "no text layer" honestly; OCR is a separate decision |
| 21 | **Exports missing** for AI calls and campaigns; **no `soon-click` listener** in `ManageLayout.vue:395-406` so the ten placeholder rows are inert; **no handbook folder** `docs/modules_handbook/manage/appointment-engine/`; **suite scaffold is uncommitted** and a parallel session is editing the same seven files | service | Polish and handover | Standard §14 export pattern; add the listener or accept tooltip-only; write the handbook when the first real screen ships; **coordinate before touching the scaffold files** |

---

## 6. Where the spec conflicts with PETA, and what wins

The spec's §0 is binding: **the backend wins.** Every conflict and its resolution:

1. **Pipeline stage enum.** Spec guesses `New → AI called → Answered → Appointment → Showed up →
   Closed → Lost`. **PETA wins:** `Engagement::STATUSES` (`src/Engagement/Engagement.php:62-70`) —
   1 New/brand, 2 Contacting/amber, 3 Appointment Set/indigo, 4 With Closer/violet, 6 Booked/teal,
   8 Converted/green, 9 Lost/rose; legacy rows rendered through `ALL_STATUSES` (:76-86) and grouped
   by `COLUMN_STATUS` (:90-93). Mapping: *AI called* and *Answered* **have no counterpart** (an AI
   call is an event in `ai_voice_calls`, not a state on the deal — nothing writes an engagement
   status when a call settles); *Appointment* = `STATUS_APPOINTMENT_SET`; *Showed up* =
   `appointments.outcome = OUTCOME_ATTENDED`, **a different table**; *Closed* is two distinct pieces
   of news PETA keeps apart on purpose — `STATUS_BOOKED` (unit held) vs `STATUS_COMPLETED` (money in,
   :57-61). If the console needs the two AI stages, **derive them in the console** from the latest
   `ai_voice_calls` row; do not add statuses to a model many surfaces read.
2. **Four status vocabularies, and the console must pick one.** `leads.status` (5, a roll-up),
   `engagements.status` (7 selectable / 9 stored — **the answer**), `lead_funnels.follow_up_status`
   (10, per registration), `leads.distribution_status` (3, the pool axis), plus
   `appointments.status` + `outcome`. Nothing in PETA reconciles them. Name the other three
   explicitly on screen or two views will disagree about where a lead is.
3. **Design tokens.** Spec supplies ink `#0E1B33` and blue `#1E6FE0`. **PETA wins**
   (`CLAUDE.md` §13, GUIDELINES Primary Directive 1). PETA is Tailwind v4 **CSS-first — there is no
   `tailwind.config.js`**; tokens live in the `@theme` block at `resources/css/app.css:15-39`:
   `--color-navy-900 #111e35` (:32), `--color-navy-950 #0b1626` (:33), `--color-brand-600 #2563eb`
   (:24), `--color-brand-500 #3366f6` (:23), the full `brand-50…900` ramp (:18-27), plus
   `--color-canvas #f4f5f8` and `--color-hairline #e6e8ee` (:37-38). Font is Inter (:16). The
   conflict is cosmetic — the deltas are a few points per channel — and the already-shipped console
   page has resolved it correctly (`Console.vue:100` `bg-navy-900`, `:104` `text-brand-600`).
4. **Logo.** Spec supplies its own. **PETA wins:** the sidebar mark is fixed for every suite at
   `resources/js/Layouts/AppShell.vue:132-136` (`/images/logo/icon-non-bg.png` + "Property" +
   brand-400 "Lab"); the only sanctioned customisation is the `brandSubtitle` caption (:31, :135).
   A second logo inside one app reads as a different product.
5. **"Endpoints" / typed data-access layer.** **PETA wins:** there is no REST API for pages. An
   endpoint here is a controller method returning `Inertia::render` with props, plus the handful of
   existing JSON side-doors. Honour the spec's *intent* via presenters + typed props (§2).
6. **The appointment-setter's 20 % cut.** **PETA wins, and the number is different per closing
   mode** (live `closing_mode_roles`): "Non Webinar Closing" → `appointment` **15 %**; "Video Sales
   Closing" → **10 %**; "Webinar Closing" has **no `appointment` role at all** (it uses
   `webinar_closer`), so for a webinar-closed deal the structural saving is **zero, not 20 %**. Read
   it as `ClosingMode::poolMap()['appointment']` (`src/Engagement/ClosingMode.php:141`) via
   `Engagement::resolvedClosingModeId()`. Also note the commission total is **derived, never stored**
   (`Booking::commissionAt()` :290) and the split is `engagement_assignments.role_share` × the mode's
   pools (`Engagement::commissionBreakdown()` :299) — `Booking::CLOSING_ROLE_SETS` and
   `booking_commission_splits` **no longer exist** (`2026_08_03_100001:31`). And live, a human still
   holds the `appointment` role on 80 engagement assignments across 5 admins, so "commission saved"
   is a **forward-looking** measure, not a historical one.
7. **Field names the spec invents for things PETA already names.**
   `{{lead_name}}` → the canonical funnel token is **`{{name}}`** (`FunnelWhatsappComposer.php:219`);
   `lead_name` exists only as an alias on the AI-call path (`SendFunnelAiCall.php:129`).
   `{{slot_time}}` → **`{{event_time}}`** (:226/:249).
   `PipelineStage` → `Engagement`. `CallRecord` → `AiVoiceCall`. `MessageThread` → `WhatsappConversation`
   (and beware `Src\Conversation\*`, which in PETA means an **AI-analysed sales conversation** whose
   entire source vocabulary is ZOOM / PHONE_CALL / SHOWROOM_F2F —
   `src/Conversation/ConversationSourceType.php:16-18`; WhatsApp is not in it).
   `Workflow` / `Node` / `Script` / `KnowledgeItem` / `WhatsAppSequence` → **no such classes exist**;
   see §5.7 and §5.8 for what does.
8. **`Campaign.platform`.** Spec assumes multiple ad platforms. **PETA wins:** Meta is the only one
   (no Google/TikTok anywhere in `src/`, `app/`, `config/`). Hardcode `'Meta'`; a real column implies
   a second integration.
9. **Pause/resume a campaign.** Spec assumes the control works. **PETA wins:** read-only against
   Meta's ad objects. Per the spec's own rule, render the control **disabled**.
10. **Permission naming collision, inherited not created.** `VIEW_APPOINTMENT_ENGINE` (the suite) vs
    `VIEW_APPOINTMENTS` (the calendar-side record) differ by one letter over the same noun;
    `src/Auth/Permission.php:174-179` already warns about it. Keep the suite gate for suite pages and
    reuse `VIEW_APPOINTMENTS` for anything reading the `appointments` table.

---

## 7. Honesty constraints carried into the build

**The rule (spec §1/§6, non-negotiable):** no baseline, no counterfactual, no "recovered X leads",
no "saved N hours", no percentage whose denominator is not a record. Every figure traces to a row.
Where a figure cannot be computed, the screen says so — it does not estimate.

**Per Coverage (§5.2) metric, what the logs can honestly support:**

| Metric | Verdict | Wording it may ship under |
|---|---|---|
| **Leads worked / dropped** | ✅ with a caveat | Computable as a UNION of five ledgers. **Name the channels counted.** Exclude our own refusal rows (`REFUSAL_REASONS`) — a quiet-hours block is *us* refusing, not the lead not answering — and use the NULL-safe PHP predicate, not the SQL `NOT IN` |
| **Time to first contact** | ✅ | `MIN()` across the ledgers minus `lead_funnels.registered_at` (prefer it over `leads.created_at`: a returning lead's person-record predates the ad by months). Say which clock |
| **Follow-ups sent on schedule (%)** | ❌ **BLOCKED — must not ship as a percentage** | Neither send ledger stores the due moment (gap #5). Reconstructing it from `rule.offset` is lossy three ways: the offset is mutable and soft-deletable so history reads against today's value; the legitimate tolerance is the 5-minute tick **plus `SessionDueWindow::CATCH_UP_MINUTES = 60`** (`src/Event/Support/SessionDueWindow.php:29`); and an AI call **deliberately defers** out of quiet hours (`SendFunnelAiCall.php:101-112`), which would score as late. **What may ship instead:** (a) "time from queued to sent" from `sent_at − created_at`, since `created_at` is the claim moment; (b) the **human** half honestly — `lead_action_items.scheduled_for` is a real indexed due date (`2026_08_06_400001:34`) with existing OVERDUE/TODAY/UPCOMING/UNSCHEDULED buckets (`SalesWorkQueue.php:294-297`) and an existing SLA policy (`ChaseFollowUps.php`: 48 h, escalate at 5 days) |
| **Retries honoured** | ❌ **BLOCKED** | No retry ladder exists anywhere — no scheduled re-dial, and `PlaceAiVoiceCall` places exactly one call. The only repeat is lead-initiated (`AI_CALL_AGAIN` → `dynamic_variables.is_repeat_call = 'yes'`). Ship **"call attempts per lead"** and **"lead-requested callbacks"**, never "retries honoured" |
| **Promised callbacks honoured** | ❌ **BLOCKED** | Nothing records a promised time and no job reads one back. No profile defines a callback field with that meaning. Omit the metric |
| **After-hours contacts** | ⚠️ ships only with its window named | Two definitions already exist: business hours **9–18** (`Messages/DashboardController.php:35-36`) and the AI call window **9–21** (`PlaceAiVoiceCall.php:34-35`). Pick one and print it. Timestamps are KL wall-clock **except `call_recordings.called_at`, which is UTC** (`2026_06_04_000001:41`) — convert it or be 8 hours wrong |
| **Nobody called at night** | ✅ | Two countable records: FAILED rows with `disconnection_reason = 'quiet_hours'`, and `FunnelAutomationSend::SKIP_QUIET_HOURS` (:45) |
| **Appointments / show rate** | ⚠️ | `outcome` NULL is a **third state** — "not recorded" — not a no-show (`Appointment.php:103`). Live: 4 appointments, **0 outcomes**. Render "not recorded", never 0 % |
| **Appointments booked (any figure)** | ⚠️ | Must merge `appointments` **and** `zoom_meetings` (384 rows vs 4). Reading one table understates the business ~100× |
| **AI vs human message split** | ⚠️ | `MessagePresenter` exposes 2 of at least 5 provenance markers; ~3,900 automated sends currently look human. Count `meta.funnel_whatsapp` and `meta.broadcast` too, and note that ~960 outbound rows are phone-app carbons that structurally **cannot** carry a sender |
| **Cost per call / per day** | ✅ with a scope fix | `cost_usd` is **Retell's charge only — Twilio bills separately** (`AiCalls/Index.vue:194`). Scope to `source = SOURCE_LEAD`; the existing budget fuse (`PlaceAiVoiceCall.php:151`) does **not**, so it includes admin test calls |
| **Anything labelled "live"** | ❌ | There is no realtime transport (gap #18). Say "refreshed when you return to the tab" or "updates every 6 seconds" |
| **`is_successful`** | ❌ | Retell's own coarse verdict (6 true / 9 false / 1 null). Never label it "appointment booked" |
| **Untagged / unattributed volume** | ⚠️ | 576 of 12,157 `lead_funnels` rows carry a campaign id. Follow `FunnelDashboardService::campaignBreakdown()`'s example and report `untagged_leads` explicitly (:269-272), or a leader reads a working campaign as a dead one |

**One framing to carry through the whole console:** on this install the *schema* mostly exists and
the *data* does not — 4 appointments, 0 outcomes, 16 AI calls, 0 tier weeks, 0 users holding
`sales-agent`, 0 tied Meta campaigns. Several screens will be structurally correct and visually
empty. That is an honest state and must be rendered as "not recorded yet", never as zero performance.

---

## 8. Recommended build order

Reordered from the spec's §9 to put buildable work first and name each blocker's owner.

**Phase 0 — unblock (hours, not days; nothing ships without these)**
1. Fix the double feature flag (gap #3) — one indentation level in `routes/web.php`.
2. Permission migration + role grants so `sales-leader` can open the console (gap #2).
3. Add `LeadVisibility` to `AiCallsController::index` and its counts (gap #4). Ship this even if the
   console never reuses that page — it is a live disclosure hole.
4. Coordinate with the parallel session on the seven uncommitted scaffold files, then commit.

**Phase 1 — buildable now, highest value per line**
5. **§5.3 Leads** — extend `LeadsController::index` with AI-call columns (copy
   `EventsController::aiCallsForLeads`) and a `properties_owned == 0` filter mode; add the lead
   detail as a **tab on the existing Show page**.
6. **§5.4 WhatsApp review** — mount `preview` + `ConversationThread`; add
   `chat_type = CHAT_INDIVIDUAL` and channel scoping to any new query; extend `MessagePresenter`
   with the missing provenance keys so criterion 7 survives counting.
7. **§5.11 Document ingestion** — `ResourceText` + `SummariseResource`; register one new
   `AiRequest::PROMPT_*` key; surface "no text layer" for scans.
8. **§5.6 Campaigns** — `AdPerformanceService::rollup()` behind `GroupScope`; pause control rendered
   **disabled**; "Meta leads" beside "our leads" with untagged volume named.

**Phase 2 — needs one small backend change each**
9. Index `lead_funnels.campaign_id`; add `(lead_id, source, created_at)` on `ai_voice_calls`; index
   `appointments.created_by` and `scheduled_at` (gaps #8, #10). Cheap, and every later screen depends
   on them.
10. **§5.1 Dashboard** — reuse `pipelineView()` + the lead count props + the unreplied/review
    predicates + `ReapAiFailed`'s predicate; today's appointments merges both tables.
11. **§5.2 Coverage** — ship only the four honest metrics from §7, each with its scope printed.
12. **§5.9 First-property** — `properties_owned` column + filter; a job promoting the AI's
    `ownership` answer into it (declaring which source wins); `systemObservedViewings` from
    `appointments`.
13. **§5.5 AI calling** — decide re-host vs link; add profile/outcome filters, an export, and a
    detail route.

**Phase 3 — blocked until the backend lands**
14. **The AI→appointment write path (gap #1).** The single highest-value backend task in the
    programme: without it §5.1's funnel, §5.2's conversion, §5.6's cost-per-appointment, §5.10's
    tier scoring and the product's core claim are all uncomputable. Consider `consultation_requests`
    as the landing record.
15. `ai_voice_calls.outcome` + the per-profile extraction key map (gap #6), then promote 2–3 fields
    to real columns (gap #9) so §5.3's qualification filters become affordable.
16. `due_at` on both send ledgers (gap #5) — only then may "follow-ups on schedule" ship.
17. **§5.10 Agent allocation** — extend `WeeklyTiersController` and plug attribute routing into
    `LeadRequestService`'s declared seam. Blocked in practice until someone holds the `sales-agent`
    role and the weekly scorer produces rows.
18. Ad → WhatsApp attribution (gap #12), modelled on `messenger_captures`.
19. **§5.7 Automation** — read-only canvas first (`FlowPresenter::diagram` + `FlowGraph.vue`); the
    editable multi-channel canvas only after the node-type decision in gap #13.
20. **§5.8 Content library** — extend `catalog_doc_pages` / `AnswerHkDocQuestion` rather than
    building a knowledge store.

**Throughout:** a screen goes live only when every figure on it traces to a record (spec §0), and the
module handbook (`docs/modules_handbook/manage/appointment-engine/readMe.md`, with its **Nav:** line)
is written as the first real screen ships.

---

## 9. Tenancy — how PETA scopes data, and what §4A costs

*Added 2026-08-29 for spec v1.0 §4A, which requires this document to record how
PETA scopes data and confirm that every query in this console inherits it.*

### 9.1 PETA has a tenancy unit

It is **`Src\People\Group`** (`src/People/Group.php:16`), and its own docblock
names the role exactly:

> *"An agency-level group (**whitelabel tenancy unit**). A group owns its staff
> (`admins.group_id`), teams, and — **via ingestion stamping** — its leads, FLG
> projects, Meta integrations and WhatsApp channels. Platform staff carry no
> group."*

So §4A's instruction — *"bind 'tenant' to PETA's existing org/workspace entity…
do not invent a parallel tenancy scheme"* — resolves to `Group`. Nothing new is
needed to name a tenant. **A user with no group is platform staff and sees
everything**, which is the correct shape for a whitelabel product.

### 9.2 But isolation is opt-in, not structural

Measured against the working tree:

| | |
|---|---|
| Tables carrying `group_id` | **13** |
| Files calling `GroupScope` | **13** (plus the helper itself) |
| Manage controllers | **170** |
| Tenancy package in `composer.json` | none |

`Src\Auth\Support\GroupScope::apply()` (`src/Auth/Support/GroupScope.php:26`) is
a **static helper each query chooses to call**. It is not an Eloquent global
scope and there is no model trait applying it. Its own docblock is careful about
this: *"This is a tenancy partition, not a permission level."*

The 13 callers cluster almost entirely in one module:

```
FacebookLeadGenerator  8   (leads, campaigns, projects, creatives, brochures, owners…)
Engagements            3   (sales projects, bookings import, referrals)
Marketing              1   (campaign mapping)
Actions / Requests     2
```

Everything else reads unscoped. That is not a latent risk — it is a live one, and
this programme has already met an instance of it: `AiCallsController::index`
returned every lead's AI calls, transcripts, numbers and costs to any holder of
`view-calls`, at any lead level, until it was fixed on 2026-08-29 (gap #4).

### 9.3 The tables this console depends on

| Table | `group_id`? | How a tenant boundary would be reached |
|---|---|---|
| `leads` | ✅ | directly |
| `engagements` | ✅ | directly |
| `whatsapp_conversations` | ✅ | directly |
| `whatsapp_channels` | ✅ | directly |
| `meta_campaigns` | ✅ | directly — but **0 of 275 rows are tied to a project or funnel** |
| `facebook_integrations` | ✅ | directly |
| `admins`, `teams`, `projects` | ✅ | directly |
| **`ai_voice_calls`** | ❌ | only via `lead_id` → `leads.group_id`; a call with `lead_id` NULL has **no tenant at all** |
| **`ai_voice_call_turns`** | ❌ | via the call, via the lead |
| **`appointments`** | ❌ | via `lead_id`, and its calendar reads scope by `created_by` instead |
| **`whatsapp_messages`** | ❌ | via conversation → channel |
| **`meta_ad_insights`** | ❌ | via `meta_campaigns.group_id` — which is why gap #4 lists the Traffics page |
| **`ai_call_profiles`**, `ai_call_kb_entries` | ❌ | **not tenant-scoped in any way** — the AI's scripts and knowledge are global |

That last row matters more than it looks. In a multi-tenant SaaS every tenant
runs *their own* calling script against *their own* knowledge base. PETA models
a campaign profile as a global object, and `RetellAgentSync` maps each one 1:1
onto a Retell agent in **one shared Retell account**. Per-tenant AI content is
therefore not merely unscoped — it is un-modelled.

### 9.4 What this means for acceptance criterion 1a

> *"All data is tenant-scoped server-side… Cross-tenant data exposure is a
> release blocker."*

**This console cannot satisfy that by being built.** Three of the tables it reads
most (`ai_voice_calls`, `whatsapp_messages`, `meta_ad_insights`) have no tenant
column, one of its core objects (`ai_call_profiles`) has no tenant concept at
all, and 157 of 170 Manage controllers do not filter by group even where the
column exists. Adding `GroupScope` to this console's own queries would close this
console's doors in a building whose other 157 doors stay open.

**Three options, in ascending cost. This is Open decision 1 in `BUILD-PROGRESS.md`.**

**(a) Accept today's model.** Ship as a single-agency product on PETA's existing
group + lead-visibility scoping. Drop acceptance 1a. Honest, cheap, and true to
what PETA is today — but it cannot be sold to a second agency as SaaS.

**(b) Scope this console only.** Every query in the suite goes through
`GroupScope` / `LeadVisibility`. Better than nothing and worth doing regardless,
but it does not deliver 1a: a leader of agency B still reaches agency A's data
through any of the other 157 controllers.

**(c) Structural isolation.** A global scope (or a `BelongsToGroup` trait)
applied at the model, `group_id` added to the tables above, `ai_call_profiles`
given a tenant and the Retell account made per-tenant, and an audit that fails
CI when a group-owned table is queried unscoped. This is the only option that
satisfies 1a. It is a programme of its own across live software, and the Retell
part alone changes how every campaign is provisioned.

**Recommendation.** Do **(b)** now — it is the right hygiene either way and it is
what this console's slices already do by extending scoped surfaces. Decide (a) vs
(c) as a business question: (c) is only worth its cost if a second agency is
actually going to be sold. Until that decision, `BUILD-PROGRESS.md` keeps DoD
box 2 unchecked on every slice rather than quietly redefining what "done" means.
