# Appointment Engine — the bridges

**Module:** [AI Appointment System](/docs/modules_handbook/manage/appointment-engine/readMe.md)

How an **AE lead**, an **AI call**, an **appointment**, a **CRM lead/engagement**, a **WhatsApp conversation** and a **Zoom meeting** are tied to one another — which id joins what, who writes it, when, and what it means when one is null.

This is the document to read before changing anything that crosses the module boundary: the AE is a separate product with its own **lead** book, *bridged* to the CRM rather than merged into it — and, since the 2026-09-09 tidy, that lead book is the *only* engine table still bridged rather than merged.

> Companion docs: [data-model.md](/docs/modules_handbook/manage/appointment-engine/data-model.md) for the columns themselves; [runner.md](/docs/modules_handbook/manage/appointment-engine/runner.md) for the engine that writes them.

> **⚠️ 2026-09-09 — several bridges below dissolved into merges.** The [changelog entry](/docs/modules_handbook/manage/appointment-engine/changelog.md#2026-09-09--the-database-tidy-the-engines-duplicate-tables-merge-into-the-master-ones) is authoritative; in this document's numbering: **#2** (`ae_projects.project_id`) is gone — a deal IS the CRM `projects` row and every `ae_project_id` is now `project_id`; **#5** is now `appointments.ai_voice_call_id` → the shared `ai_voice_calls.id`; **#11** (`ae_calls.provider_call_id`) and **§6's two webhook doors** collapsed into the platform's one Retell receiver — the engine's rows on `ai_voice_calls` carry `ae_lead_id`, and `AiCallAftermath` hands them to `Services\EngineCallHooks`, which wakes the parked run; **§12** (two AI-call ledgers merged for reading) is moot — there is one ledger; **§13** `LeadEraser` hard-deletes the engine's rows on that shared ledger; **§14** `ae:link-projects` no longer exists. Bridges #1, #3, #4, #6–#10 and #12–#16 read as written, with `aeProject` → `crmProject` and `ae_call_id` → `ai_voice_call_id` wherever they appear.

The Appointment Engine (AE) began as a deliberately **separate product** sharing the codebase: its own `ae_*` tables, its own lead book, its own funnel vocabulary. `database/migrations/2026_08_29_160000_create_appointment_engine_tables.php:7-24` states the reasoning ("a row here has no relationship to a row in the CRM even when both describe the same human being"). Between **2026-09-06 and 2026-09-08** that stance was reversed in five owner-approved phases, each adding a *nullable* pointer column rather than merging a table. This section is the complete map of those pointers.

Two rules hold everywhere and explain almost every design choice below:

1. **Nullable and mostly fail-soft.** The *identity* bridge writers are wrapped in `try/catch` + `report($e)` and return silently — `CrmIdentity:69-91`, `CrmProject:35-51,69-79`, all three `CrmPipeline` methods, `ShadowFlow:39-88`, `ChatTakeover::begin:36-88`, `ZoomMeetings::ensure:60-92`. **The booking writers are not**: `AiCall::bookIfPromised()` (`Handlers/AiCall.php:394-401`) runs `Appointment::firstOrCreate` bare, so a failure there throws out of the workflow step and the run fails rather than continuing quietly. Know which kind you are touching. `src/AppointmentEngine/Services/CrmIdentity.php:22-24`: *"a lead the linker cannot place stays unlinked (recoverable — `ae:link-leads` retries) rather than mis-merged (unrecoverable)."* A bridge failure must never break intake, a workflow step, or a page render.
2. **Idempotent + retried on touch.** `ensureLinked()` / `ensureLinked()` for projects are called again on every relevant page view and every trigger sweep, so a row born before a bridge existed converges the next time anything looks at it.

---

### The convergence at a glance

```
   INTAKE                     AE's own book                    THE PLATFORM
 ─────────────             ────────────────────            ────────────────────────

 Sheet / CTWA / Meta ──┐
 keyword / CSV / manual│
                       ▼
                 ┌───────────┐   lead_id (CrmIdentity)   ┌──────────────┐
                 │ ae_leads  │──────────────────────────►│  leads (CRM) │──► users
                 │           │                           └──────┬───────┘    /profiles
                 │  phone    │                                  │
                 │  wa_contact_id ──────────┐                   │            ┌──────────────┐
                 └─────┬─────┘              │                   ├───────────►│ engagements  │
                       │ ae_project_id      │                   │ (lead_id,  │  status,     │
                       ▼                    │                   │  project_id│  assignments │
                 ┌───────────┐  project_id  │                   │  PAIR — no └──────┬───────┘
                 │ae_projects│──────────────┼──────────────────►│  column)          │ closer role
                 └───────────┘  (CrmProject)│            ┌──────┴───────┐           │ ⇅
                       │                    │            │ projects(CRM)│    assigned_admin_id
                       │ workflows          │            └──────────────┘
                       ▼                    │
                 ┌───────────┐              │  phone_e164 / contact_id
                 │ae_workflows│             └──────────────┐
                 │   .plan   │                             ▼
                 └─────┬─────┘  settings->ae_workflow  ┌─────────────────────┐
                       ├────────────────────────────►  │ whatsapp_flows      │ (SHADOW,
                       │        (ShadowFlow)           │  hidden from Flows) │
                       │                               └──────────┬──────────┘
   ae_workflow_runs ◄──┘                                          │ startRun
   (ae_lead_id)                                                   ▼
        │  ┌──────────────┐  provider_call_id   ┌────────────────────────────┐
        ├─►│  ae_calls    │◄───── Retell ──────►│ whatsapp_flow_runs         │
        │  └──────┬───────┘  (RetellWebhookBridge)│  meta->ae->{run,lead,...} │
        │         │ ae_call_id                   └──────────┬─────────────────┘
        │         │                                         │ wa_flow_run_id
        │         ▼                                         ▼
        │  ┌────────────────────────────────────────────────────────────┐
        └─►│                    appointments  (THE book)                │
   resume  │  ae_lead_id · ae_call_id · wa_flow_run_id · lead_id ·      │
   WAIT_*  │  project_id · engagement_id · group_id · assigned_admin_id │
           │  source · type · status · outcome · zoom_meeting_id        │
           └───────────────────────┬────────────────────────────────────┘
                                   │ zoom_meeting_id
                                   ▼
                        zoom_consultations.lead_id  (IdentifyLiveMeeting)
```

---

### Every bridge, in one table

| # | Join key | Joins | Written by | When | Null means |
|---|----------|-------|-----------|------|-----------|
| 1 | `ae_leads.lead_id` | AE lead → CRM `leads.id` (the person) | `CrmIdentity::ensureLinked()` `src/AppointmentEngine/Services/CrmIdentity.php:66` (via `LeadLinker`) | every intake path, every AE lead Show, `ae:link-leads` | identity unresolved or **refused**; host-channel history + pipeline stay invisible |
| 2 | `ae_projects.project_id` | AE deal → CRM `projects.id` | `CrmProject::ensureLinked()` `src/AppointmentEngine/Services/CrmProject.php:29`; `renamed()` `:63` | project create/update/show/sales tab, `ae:link-projects` | no commission/prices/pipeline; Sales tab refuses |
| 3 | *(no column)* `(lead_id, project_id)` pair | AE lead → `engagements` | `CrmPipeline::ensureOpen()` `src/AppointmentEngine/Services/CrmPipeline.php:53` calling `EngagementRepository::open()` | tail of every `CrmIdentity::ensureLinked()` (`CrmIdentity.php:100`), `ae:link-pipelines` | no Team cell, no status roll-up; `CrmPipeline::card()` returns null and tables fall back to the single-agent text |
| 4 | `appointments.ae_lead_id` | booking → AE lead | `AiCall::bookIfPromised()` `Handlers/AiCall.php:394`; `RecordWhatsappBooking:88`; **and the CRM's own** `AppointmentsController::store` `:29` | booking created | not an engine booking → invisible in the whole AE suite (`ShowroomController.php:544` filters `whereNotNull`) |
| 5 | `appointments.ae_call_id` | booking → `ae_calls.id` | `AiCall::bookIfPromised()` (the `firstOrCreate` **key**) `AiCall.php:394-395` | AI phone call captured a time | booked by chat, not by call — this is exactly how `via` is derived (`AppointmentRow.php:57`) |
| 6 | `appointments.wa_flow_run_id` | booking → `whatsapp_flow_runs.id` | `RecordWhatsappBooking:79-81` (the `firstOrCreate` **key**) | chat brain emitted `[[BOOKED]]` while a run was active | token fired with no run → keys fall back to `(ae_lead_id, scheduled_at, source)` |
| 7 | `appointments.lead_id` | booking → CRM person | copied from `$lead->lead_id` at **create only** (`AiCall.php:396`, `RecordWhatsappBooking:89`) | booking created | booking never appears on the CRM lead page (which keys on `lead_id`, `Manage/Leads/LeadsController.php:1005`) |
| 8 | `appointments.project_id` | booking → CRM project | copied from `$lead->aeProject?->project_id` at **create only** | booking created | calendars cannot name the project |
| 9 | `appointments.engagement_id` | booking → engagement | **nothing in the AE writes it**; only `LeadRepository`'s merge re-parents it (`src/Lead/Repositories/LeadRepository.php:1554`) | — | every AE booking is absent from `Engagement::appointments()` (`src/Engagement/Engagement.php:248`) |
| 10 | `appointments.assigned_admin_id` | booking → closer | `CloserRotation::offer()` `src/AppointmentEngine/Services/CloserRotation.php:211`; back-fill from the CRM in `EngagementRepository::syncAppointmentEngineAgent()` `:454` | closer offered / CRM closer assigned | unowned booking (Dashboard's "who owns them yet") |
| 11 | `ae_calls.provider_call_id` | AE call → Retell `call_id` (UNIQUE) | `VoiceCaller::place()` `src/AppointmentEngine/Services/VoiceCaller.php:63` | Retell accepted the dial | refused/failed placement — the phone never rang |
| 12 | `ae_leads.wa_contact_id` | AE lead → `whatsapp_contacts.id` | `Triggers::lead()` `src/AppointmentEngine/Runner/Triggers.php:599` | WhatsApp/CTWA intake where the contact has **no** `phone_e164` | ordinary lead — WhatsApp is matched by `phone` instead |
| 13 | `whatsapp_flows.settings->ae_workflow` | AE workflow → its hidden host flow | `ShadowFlow::sync()` `src/AppointmentEngine/Services/ShadowFlow.php:37` | every plan compile + publish flip | workflow has no chat section → no takeover possible |
| 14 | `whatsapp_flow_runs.meta->ae->{run,lead,workflow\|node}` | host chat run → AE run/lead | `ChatTakeover::begin()` `ChatTakeover.php:77`; `WhatsappAiTakeover` `Handlers/WhatsappAiTakeover.php:101` | takeover started | booking listener falls back to the phone join |
| 15 | `appointments.zoom_meeting_id` / `meeting_link` | booking → Zoom meeting | `ZoomMeetings::ensure()` `src/AppointmentEngine/Services/ZoomMeetings.php:79-82`; `ZoomInvite:87` | AI call booking, `end.booked` step, Zoom-invite step | Zoom booking with **no** link = the visible degraded state `Booking::zoomLinkPending()` |
| 16 | `ai_call_kb_entries.title LIKE 'Workflow step {uuid}%'` | AE workflow node → CRM AI profile KB | `ProfileKnowledge::push()/retract()` `src/AppointmentEngine/Services/ProfileKnowledge.php:37,73` | plan compile (`PlanCompiler.php:55,213`) | the brain answers without this deal's facts |

---

### 1. `ae_leads.lead_id` — the person bridge

`ae_leads` stays the engine's working book; `lead_id` points at THE person record every host channel already hangs off. Migration: `database/migrations/2026_09_06_150000_add_lead_id_to_ae_leads_table.php`.

**All resolution is delegated to `Src\Lead\Services\LeadLinker`** — the census-built gate whose guards (email is the primary key, an unproven phone may never *resolve* a person, a number owned by someone else is never grafted) exist so no module re-invents them wrongly (`CrmIdentity.php:15-20`).

`CrmIdentity::request()` (`:122`) builds a `LinkRequest` with `source: LeadLinker::SOURCE_APPOINTMENT_ENGINE` (`= 'appointment_engine'`, `src/Lead/Services/LeadLinker.php:153`) and a **phone-trust rung**:

| AE source (`Lead::SOURCE_*`) | Base trust | Resolves a person? | Why |
|---|---|---|---|
| `ctwa` | `TRUST_NETWORK_VERIFIED` | yes | the number IS the `phone_e164` the message arrived from |
| `whatsapp` | `TRUST_NETWORK_VERIFIED` | yes | same |
| `import` | `TRUST_TYPED` | yes | an admin uploaded the file |
| `manual` | `TRUST_TYPED` | yes | an admin typed it |
| `sheet` | `TRUST_UNVERIFIED` | **no** | the sheet's rows are a live public feed |
| `meta` | `TRUST_UNVERIFIED` | **no** | typed by the public into an unverified form |

The `sheet` rung is the load-bearing one and carries its own reasoning (`CrmIdentity.php:35-41`): a malicious row carrying *victim's phone + attacker's email* would otherwise graft the attacker's email onto an email-less account — "the exact takeover the ladder exists to stop. Adversarial review, 2026-09-06."

**The per-lead upgrade.** `effectiveTrust()` (`:143`) promotes an unverified source to `TRUST_NETWORK_VERIFIED` once `ae_calls` holds a row for this lead with `lead_spoke = true` **and `to_number = $lead->phone`**. The `to_number` clause is deliberate: an answered call proves control of the number that was *dialled*, and an admin may have edited the phone since — the old proof must not ride along onto the new number.

**Order inside `ensureLinked()`** (`:66-101`) — order matters:
1. Only when `lead_id === null` **and** `filled($lead->phone)`.
2. `link()` (create-if-missing) or `linkExisting()` (reuse only) per `$createMissing`.
3. If the linker resolved to **staff** (`$result->lead === null`, `$result->user !== null`) **and there is no conflict**, fall back to `CrmLead::where('user_id', $result->user->id)->first()` — an admin holding a staff-facet lead row still links. A **conflicted** resolution (email says one person, phone another) is left unlinked on purpose.
4. `forceFill(['lead_id' => …])->save()`.
5. **Unconditionally** (even when already linked, even when the link just failed): `CrmPipeline::ensureOpen($lead)` — because the *project* bridge may have appeared after the identity did.

**`relink()`** (`:169`) nulls `lead_id` and re-resolves. Called by `LeadsController::update` (`app/Http/Controllers/Manage/AppointmentEngine/LeadsController.php:626`) whenever phone **or** email changed, because "a changed phone/email may describe a DIFFERENT person — the stale link would quietly hang another human's history on this row."

**Call sites:** `Triggers::lead()` on both the reuse path (`Triggers.php:590`) and the create path (`:615`, outside the `DB::transaction` so a linker failure cannot unwind the lead); `LeadsController` store `:166`, import `:248`, show `:387`, update `:626-628`; `ae:link-leads`.

**Consumers.** `LeadEngagement::mapForLeads()` (`src/AppointmentEngine/Support/LeadEngagement.php:47`) reads the whole host ENGAGEMENTS band through this bridge — WhatsApp in/out counts, phone-call seconds, Zoom minutes, F2F seconds — all keyed by `leads.user_id`/`leads.id`. An unbridged AE lead "simply has no host history to show and gets zeros — never a hidden error" (`:38-40`). The AI-caller column is the one exception: it reads `ae_calls` directly, because "in the Appointment Engine the AI caller IS the engine" (`:26-31`).

**The biggest consumer is the Show page itself.** `LeadsController::show()` (`:375-415`) renders the **CRM's own lead Show page at the AE URL** whenever the lead is bridged *and* the viewer passes `Permission::viewLeadsAny()` + `LeadVisibility::allows()`, passing `suite='appointment-engine'`, a `backUrl`, and an `aeContext` chip (project name/uuid + `Lead::STAGES[...]['name']`). An unbridged (or unauthorised) lead falls through to the engine's native page.

---

### 2. `ae_projects.project_id` — the deal bridge

Migration `2026_09_06_210000_add_project_id_to_ae_projects_table.php`. `ae_projects` keeps the per-deal engine state (brain, workflow, sheet, funnel); the CRM `projects` row owns commission rate, prices, catalogue link, and the whole engagements/bookings pipeline.

`CrmProject::ensureLinked()` (`CrmProject.php:29`):
1. No-op if `project_id !== null` **or** `blank($project->name)`.
2. Match by `group_id` **and** `LOWER(TRIM(name))`, `orderBy('id')->first()`.
3. Miss → **mint** through `SalesProjectRepository::create(['project' => ['group_id' =>…, 'name' =>…]])`, which stamps `origin = ORIGIN_CUSTOM`, `status = STATUS_ACTIVE` (`src/Engagement/Repositories/SalesProjectRepository.php:47-48`) and leaves commission blank for a human.
4. `forceFill(['project_id' => $crm->id])->save()`.

`CrmProject::renamed()` (`:63`) keeps one concept on one name: an unlinked deal gets its first link attempt; a linked one has the CRM project renamed through `SalesProjectRepository::update()`.

Call sites: `ProjectsController@store:196` (at birth), `@show:220` (an unlinked deal gets one more chance on **every view**, because the CRM project may have appeared since), `@update:460` (`renamed`), `@sales:570`.

Two things ride on it beyond the pipeline:
- **`ProjectsController::applyBusinessFields()`** (`:521-550`) writes `commission_rate`, `commission_basis`, `vp_at` and — only when `! $crm->isCatalogueLinked()` — `price_from`/`price_to` onto the CRM project, gated on `Permission::MANAGE_PROJECTS`.
- **`ProjectsController::sales()`** (`:564-591`) renders the CRM's **own** `SalesProjectsController@show` at the AE URL (`suite`, `selfUrl`, `backUrl`), gated on `Permission::VIEW_PROJECTS`. Unlinked → a warning and a redirect back.

---

### 3. The engagement bridge — a *pair*, not a column

There is **no** `ae_leads.engagement_id`. An `Engagement` is THE record of one `(CRM lead, CRM project)` deal, so the engine never grows a second team concept; the bridge is the pair `(ae_leads.lead_id, ae_projects.project_id)` resolved in `CrmPipeline::engagementFor()` (`CrmPipeline.php:182`).

**`ensureOpen()`** (`:53`): returns early if either half is null; then `Engagement::withTrashed()->where(lead_id)->where(project_id)->exists()` — **`withTrashed` deliberately**: "a trashed engagement means an admin REMOVED this lead from the project's pipeline — recreating it here would undo that decision silently, forever." Otherwise `EngagementRepository::open()` — the CRM's own opener, so NEW status, standing default team and lead-visibility sync all come for free.

**`syncCloser()`** (`:93`): maps the engine's single "agent" onto the pipeline role `CrmPipeline::AGENT_ROLE = 'closer'` (`:45`), an admin-defined seeded key — if an agency deletes it, `assign()`'s whitelist drops the write quietly. Only the `closer` key travels; absent keys are untouched by design.

**`advanceStatus($lead, $target, $remark)`** (`:140`) — MONOTONIC and deferential, guards in this exact order:
1. `engagementFor()` null → return.
2. `$current` in `[BOOKED(6), FOLLOWING_UP(7), COMPLETED(8), LOST(9)]` → return. *(People's territory.)*
3. If `$target === LOST(9)`: only allowed while `$current` is `NEW(1)` or `CONTACTING(2)`.
4. Else if `$target <= $current` → return.
5. `ChangeEngagementStatus::handle()` — the same door the pipeline table uses, so `lost_at`/`won_at` semantics and the `SyncLeadStatusFromEngagements` roll-up onto `leads.status` come for free.

Who calls it and with what:

| Caller | Target |
|---|---|
| `AiCall.php:238` (a dial was placed) | `STATUS_CONTACTING` (2) |
| `WhatsappSender.php:111` (a workflow message went out) | `STATUS_CONTACTING` (2) |
| `WhatsappAiTakeover.php:118` (chat dispatched) | `STATUS_CONTACTING` (2) |
| `AiCall.php:407` (call booking written) | `STATUS_APPOINTMENT_SET` (3) |
| `RecordWhatsappBooking.php:110` (chat booking written) | `STATUS_APPOINTMENT_SET` (3) |
| `EndStop.php:23` (`end.stop` with `mark_lost`) | `STATUS_LOST` (9) + remark |
| `ae:link-pipelines` | 3 if any appointment exists, else 2 if `first_contacted_at` — never LOST (`LinkPipelines.php:51-60`) |

**The reverse direction closes the loop.** `EngagementRepository::syncAppointmentEngineAgent()` (`src/Engagement/Repositories/EngagementRepository.php:432-460`) fires on every `assign()` that names the `closer` key: it finds `EngineLead::where('lead_id', $engagement->lead_id)` whose `aeProject.project_id` matches, writes `ae_leads.assigned_admin_id`, and fills that lead's **still-unassigned** appointments (`whereNull('assigned_admin_id')`) with the closer. It never overwrites a pick a person already made, and — because `CrmPipeline::syncCloser()` calls `assign()` with the same holder — the re-entry is a no-op, so there is no loop.

**`scopeIncompleteTeam()`** (`:216`) is the old Allocation queue expressed in SQL: leads with no live engagement, **or** one whose resolved closing mode still has a role nobody holds. It mirrors `Engagement::resolvedClosingModeId()`'s default inference (payment default when `purchase_history_id` is set, else manual default). Both `Leads ?team=incomplete` (`LeadQueryRequest.php:46`) and the Dashboard's needs-you count (`DashboardController.php:577`) read it from here so they can never disagree.

---

### 4. The 2026-09-06 appointment-book merge

Before the merge the engine kept `ae_appointments`. Since it, **every AE booking is a row in the CRM `appointments` table**. Migration `2026_09_06_170000_add_ae_columns_to_appointments_table.php` adds, all nullable:

| Column | Type | Purpose |
|---|---|---|
| `group_id` | bigint unsigned, indexed | the agency — what `AeScope` filters on |
| `ae_lead_id` | bigint unsigned, indexed | **the marker that makes a row an AE row** |
| `ae_call_id` | bigint unsigned, indexed | which AI call earned it |
| `wa_flow_run_id` | bigint unsigned, indexed | which chat run earned it; also the capture's idempotency key |
| `assigned_admin_id` | bigint unsigned, indexed | the closer |
| `source` | varchar(10) | `'ai'` / `'human'` |
| `zoom_meeting_id` | varchar(30) | the provisioned Zoom meeting |
| `meeting_link` | varchar(500) | its join URL |

**The old `ae_appointments` table still exists and is no longer read or written by any code path** — there is no `AeAppointment` model anywhere in `src/`. It is still *referenced* by its own migrations, which necessarily keep creating and altering it (`2026_08_29_200000_create_ae_appointments_table.php:27,53`; `2026_09_02_100000_add_channel_to_ae_appointments_table.php:29,39`), and by three stale docblocks (`RecordWhatsappBooking.php:20`, `WhatsappAiTakeover.php:28`, `2026_08_30_120000_create_ae_visits_table.php:10`). Dropping it would need its own migration; nothing reads it in the meantime.

**What still distinguishes an AE row:** `ae_lead_id IS NOT NULL`, and nothing else. `ShowroomController::appointmentQuery()` (`app/Http/Controllers/Manage/AppointmentEngine/ShowroomController.php:533-545`) is the whole rule:

```php
return AeScope::apply($query->whereNotNull('appointments.ae_lead_id'), $viewer, 'appointments.group_id');
```

with the comment *"Since the merge the book is `appointments`; this suite reads only the rows the engine earned (ae_lead_id set) — the CRM page shows the whole book."*

**The dialect translation lives in exactly one place: `Src\AppointmentEngine\Support\Booking`** (`src/AppointmentEngine/Support/Booking.php`). "A second copy of this mapping is how the same appointment ends up read two ways, so every AE reader and writer goes through these helpers" (`:11-14`).

| AE vocabulary | Constant | CRM equivalent |
|---|---|---|
| `Booking::CHANNEL_SHOWROOM = 'showroom'` (amber) | `typeFor()` `:44` | `Appointment::TYPE_SHOWROOM_VISIT = 1` |
| `Booking::CHANNEL_ZOOM = 'zoom'` (sky) | `typeFor()` | `Appointment::TYPE_VIDEO_CALL = 2` |
| `channelFor(?int $type)` `:58` | — | `2 → zoom`; **every other type reads as showroom** ("its product only distinguishes 'they come to us' from 'we meet online'") |
| `OUTCOME_ATTENDED = 'attended'` | `outcomeFor()` `:74` | `Appointment::OUTCOME_ATTENDED = 1` |
| `OUTCOME_NO_SHOW = 'no_show'` | `outcomeFor()` | `Appointment::OUTCOME_NO_SHOW = 2` |
| `OUTCOME_CANCELLED = 'cancelled'` | — | **a STATUS, not an outcome**: `Appointment::STATUS_CANCELLED = 7`, handled by callers |
| `aeOutcome()` `:89` | — | cancelled-status wins; then `1→attended`, `2→no_show`, else `null` |
| `SOURCE_AI = 'ai'` / `SOURCE_HUMAN = 'human'` | — | `appointments.source` |

`Booking::zoomLinkPending()` (`:117`) names the one degraded state an agent must fix by hand: `channelFor(type) === zoom && meeting_link === null`.

**Outcome recording is standardised through one function.** `AppointmentRepository::recordOutcome()` (`src/Appointment/Repositories/AppointmentRepository.php:112-153`) owns both the rule and the engine cascade:
- Throws `InvalidArgumentException` if a non-null outcome is set on a `STATUS_CANCELLED` row — *enforced here, not in each Form Request, so no future caller can forget it*.
- After the write, if `ae_lead_id !== null`: `OUTCOME_ATTENDED` moves the AE lead to `Lead::STAGE_SHOWED_UP (5)`; **any** non-null outcome calls `WorkflowRunner::resume($aeLead, WorkflowRun::WAIT_OUTCOME)`. Fail-soft.
- "The cascade rides on the FACT, not on which surface recorded it" — the AE Showroom list, the CRM lead tab and the calendar modal all go through it (`ShowroomController.php:376-379,402,418`).

Cancelling is handled separately in `ShowroomController::record()` (`:396-410`): it first clears any outcome via `recordOutcome($appointment, null)`, then flips `status` between `STATUS_CANCELLED (7)` and `STATUS_SCHEDULED (1)` in its own transaction, "so the two facts can never conflict."

**The row shape.** `AppointmentRow::make()` (`src/AppointmentEngine/Support/AppointmentRow.php:24`) is the single shape for the global Appointments list and a project's Appointments tab. Its two derived provenance fields are the merge's payoff:
```php
'channel'  => Booking::channelFor($appointment->type),
'via'      => $appointment->source === Booking::SOURCE_AI
                ? ($appointment->ae_call_id !== null ? 'call' : 'chat')
                : null,
```
`'engagement' => CrmPipeline::card($engagement)` is null while the bridges have not produced an engagement, and the table falls back to the single-agent text.

**The reverse bridge.** The CRM's own `AppointmentsController::store()` (`app/Http/Controllers/Manage/Appointment/AppointmentsController.php:29-30`) stamps `ae_lead_id` by looking the AE lead up from the CRM lead:
```php
$data['appointment']['ae_lead_id'] = AeLead::where('lead_id', $lead->id)->value('id');
```
with the comment *"The engine's book only shows rows carrying its own lead id — bridge it here so an appointment added from any calendar appears there too."*

---

### 5. `appointments.ae_call_id` — the AI-call path, end to end

1. `AiCall::execute()` (`Handlers/AiCall.php:53`) creates an `ae_calls` row for **every** attempt, including refusals — "EVERY REFUSAL IS A ROW … so 'the AI never called me' can always be answered from the ledger" (`:29-32`). Refusal is a single ordered `match(true)` (`:198-205`): `REFUSAL_NOT_CONNECTED` (no verified connection) → `REFUSAL_NOT_CONNECTED` (profile missing / not callable) → `REFUSAL_BLOCKED` → `REFUSAL_QUIET_HOURS` → `REFUSAL_BUDGET`.
2. `VoiceCaller::place()` posts to `https://api.retellai.com/v2/create-phone-call` on the **agency's own** Retell key, and stamps `provider_call_id` + `STATUS_PENDING` (`VoiceCaller.php:63`). The payload also carries `metadata: {ae_call_uuid, ae_group_id}` — "the second key the webhook can match on if the call id is somehow missing."
3. The run **parks** on `WorkflowRun::WAIT_CALL` with a nudge at `max_call_minutes + 5`.
4. On re-entry with `lead_spoke`, `bookIfPromised()` (`:348`) reads `analysis.custom_analysis_data`, takes the first non-empty of `AiCall::APPOINTMENT_KEYS` (`= AppointmentObjective::TIME_ALIASES = ['appointment_time', 'appointment_datetime', 'appointment']`), parses it with `AppointmentObjective::parseWhen()`, and **logs every miss** on the run (`no time captured` / `could not read` / `already past`) — "a booking that silently never happens is the worst failure this product has."
5. Channel precedence (`:386-389`): the **lead's** extracted choice (`AppointmentObjective::channelFrom()`, which accepts only exactly `zoom`/`showroom`) → the brain's own goal `default_meeting_type` → the step's `default_meeting_type` → `CHANNEL_SHOWROOM`.
6. The write (`:394-401`) — `ae_call_id` is the **firstOrCreate key**, so a replayed webhook cannot duplicate:
```php
Appointment::firstOrCreate(
  ['ae_call_id' => $call->id],
  ['group_id', 'ae_lead_id', 'lead_id' => $lead->lead_id,
   'project_id' => $lead->aeProject?->project_id, 'assigned_admin_id',
   'source' => 'ai', 'scheduled_at', 'type' => Booking::typeFor($channel),
   'status' => Appointment::STATUS_SCHEDULED]);
```
7. `ZoomMeetings::ensure()` then `CrmPipeline::advanceStatus(APPOINTMENT_SET)`.

`ConditionCallOutcome` (`Handlers/ConditionCallOutcome.php:44`) reads the booking back by `Appointment::where('ae_call_id', $call->id)->exists()` — or, when the node's `booked_means === 'outcome'`, by `Call::OUTCOME_APPOINTMENT`.

---

### 6. `ae_calls.provider_call_id` — the Retell bridge, and why there are two webhook doors

`ae_calls.provider_call_id` is **UNIQUE** (`2026_08_29_170000_create_ae_calls_tables.php:40`). Two controllers can settle an AE call:

**Door A — the AE's own endpoint, `POST /webhooks/ae/retell`** (`app/Http/Controllers/Webhooks/AppointmentEngineRetellController.php`). Retell signs with the API key and **the key is per agency**, so the order is inverted on purpose (`:19-24`): find the row by `call_id` from the *unverified* body → read that row's agency `Connection` → *then* verify the signature. An unknown call id is a `204` with no work; a bad signature is `401`.
Event handling (`:45-71`):
- `call_started` → `STATUS_IN_PROGRESS` + `started_at`, only if still `STATUS_PENDING`.
- `call_ended` → `settle()`, then wake **only if** `! $row->lead_spoke || $row->analyzed_at !== null`. The comment is the reason: *"A spoke call's booking lives in the analysis… Waking now would advance the run past the call step with an empty analysis and the booking would be lost forever."*
- `call_analyzed` → `analyse()` then wake.

**Door B — the host CRM endpoint** (`app/Http/Controllers/Webhooks/RetellWebhookController.php:104-113`). When `AiVoiceCall::where('provider_call_id', …)` misses, it offers the event to `RetellWebhookBridge::handle($event, $call)` before declaring it unknown. `src/AppointmentEngine/Services/RetellWebhookBridge.php:9-18` explains why this exists: the AE dials on the agency's account into `ae_calls`, so the host controller "dropped them as 'unknown call'. The parked workflow run then only settled via the 15-minute reconcile backstop: the booking always happened, a quarter of an hour after the lead hung up."
The bridge returns `true` = consumed (any event, including `call_started` and future events), `false` = not ours. It swallows write failures on purpose: *"A 5xx here would make Retell retry an event the reconcile backstop already covers."*

**The backstop.** `AiCall::reconcile()` (`:326-341`) calls `VoiceCaller::fetch()` (`GET /v2/get-call/{id}`) and settles from the payload when `call_status === 'ended'`. `RetellCallMapper::settle()` (`RetellCallMapper.php:34`) itself folds in `call_analysis` when present, "so dropping this here would lose bookings on every reconcile-after-the-fact path."

`RetellCallMapper` also carries three bug-scars worth knowing: `setTimezone(config('app.timezone'))` on `createFromTimestampMs` (unshifted it lands 8h behind this Asia/KL database); `abs()` on `diffInSeconds` (Carbon 3's diff is signed and `max(0, …)` silently zeroed every duration); and `turns()->delete()` before re-inserting, so Retell's retries are idempotent.

---

### 7. The WhatsApp bridges

There are **four** distinct WhatsApp joins.

**(a) `ae_leads.phone` ↔ `whatsapp_contacts.phone_e164`** — the default. `WhatsappSender::send()` (`src/AppointmentEngine/Runner/WhatsappSender.php:60-62`) uses `WhatsappContact::firstOrCreate(['phone_e164' => $lead->phone], …)` and `WhatsappRepository::conversationFor()`, "so a workflow message is indistinguishable from one an agent typed, and lands in the same thread."

**(b) `ae_leads.wa_contact_id`** — the identity anchor when the number is hidden behind a WhatsApp username. Added 2026-09-07 alongside making `ae_leads.phone` nullable (`2026_09_07_190000_make_ae_leads_phone_nullable_add_wa_contact.php`). `Triggers::lead()` stores `wa_contact_id` on **every** WhatsApp/CTWA intake, phone or not (`Triggers.php:100` sets it unconditionally; CTWA `:121` and keyword `:142` pass it; `Lead::create` stores it at `:599`). The phone/contact fork lives only in the **dedupe lookup** (`:566-569`) — by `phone` when there is one, **else by `wa_contact_id`**. Most readers carry the same fork: `WhatsappSender:47-57`, `ChatTakeover::leadFlowRuns():183-192`, `LeadsController::chat():721-726`, `InboxController::aeAutomation():1441-1448`. `AiCall::execute()` (`:57-69`) records a `REFUSAL_NO_PHONE` row and steps to `default` — "the chat half of the flow is how this person gets worked."

> ⚠️ **A reader that does NOT carry the fork.** `RecordWhatsappBooking`'s fallback correlation matches the AE lead by phone only — `Lead::where('phone', $phone)` (`app/Listeners/AppointmentEngine/RecordWhatsappBooking.php:50-52`), with no `wa_contact_id` branch. The primary correlation (the run uuid on `whatsapp_flow_runs.meta->ae.run`) covers the normal path, but a **hidden-number lead whose chat books with the run meta missing has no fallback** and the booking is recorded nowhere. Worth fixing when someone is next in that listener.

**(c) `whatsapp_flows.settings->ae_workflow` — the SHADOW flow.** Every AE workflow with chat enabled carries **one hidden host flow**: zero steps, `TYPE_TIME_BASED (1)`, `TRIGGER_CAMPAIGN (3)`, `ai_profile_id` from the plan, `objective` from the plan, `settings: {ae_workflow: <workflow uuid>}`, named `AE · {workflow name}` (`ShadowFlow.php:66-80`). It exists because "the runtime that makes a takeover trackable and stoppable is the host's OWN flow-run machinery" — `RUNNING`/`ended_reason`, the inbox handoff, `ENDED_OBJECTIVE` when a booking lands, the idle reaper.
- `sync()` runs on **every plan compile** (`PlanCompiler.php:206`) and **every publish flip** (`WorkflowsController.php:385,406`). `ACTIVE` only when the workflow is live **and** `chat.enabled` **and** `chat.profile_id` is set **and** a `channel_id` can be found in the plan; otherwise it goes `INACTIVE` — **never deleted**, because "its run history is the Chats tab's memory."
- Admins never see it: `FlowsController.php:73,87` filters `whereNull('settings->ae_workflow')` on both the index and its status counts.
- The Dashboard's chat stats read it back the other way: `WhatsappFlowRun::whereHas('flow', fn ($q) => $q->whereNotNull('settings->ae_workflow'))` (`DashboardController.php:396`), reporting `live_now` and `ended_30d` grouped by `ended_reason`.

**(d) `whatsapp_flow_runs.meta->ae` — the correlation bag.** Two writers:
- `ChatTakeover::begin()` (`ChatTakeover.php:74-78`) — `{started_by: 'ae_workflow', capture: 'booking', ae: {run, lead, workflow}}` (all uuids), plus variables `{name, project}`. It is called by `WhatsappSender` right after the first send (`WhatsappSender.php:105`).
- `WhatsappAiTakeover` (`Handlers/WhatsappAiTakeover.php:98-103`) — the same bag with `node` instead of `workflow`, passed through `StartProactiveFlow` which merges it into `startRun` (`app/Jobs/Whatsapp/StartProactiveFlow.php:208`).

`ChatTakeover::begin()` has three ordered refusals, each with a reason:
1. `chat.enabled` false, or no shadow, or the shadow is not `ACTIVE` → nothing.
2. **Occupied**: any `RUNNING`, `TYPE_TIME_BASED` run already on this conversation — the host allows one drip per conversation, so "ours already running → nothing to do; someone else's → theirs."
3. **Silenced**: any run of *this* shadow on this conversation ended `ENDED_STOPPED` or `ENDED_HANDOFF` — "A person's explicit stop STAYS stopped: once an admin ended the AI on this conversation … later sequence sends must not quietly re-arm it behind their back."

Then `startRun()` followed **immediately** by `completeDrip($run)`: the shadow has zero steps, so the run must jump straight to the AI phase — and `drip_completed_at IS NOT NULL` is precisely what `WhatsappAiConfig::activeFlowConfig()` (`src/Whatsapp/Services/WhatsappAiConfig.php:103-107`) and `WhatsappFlowRepository::completeObjective()` (`:490-495`) select on.

**Stopping** is split into the two halves the inbox card exposes (`ChatTakeover.php:101,142,163`):

| Method | Acts on | Leaves alone |
|---|---|---|
| `stopSequence()` | open `ae_workflow_runs` → `STATUS_STOPPED` with `waiting_for`/`resume_at` nulled + a log line; **plus** host runs with `drip_completed_at IS NULL` → `endRun(ENDED_STOPPED)` | any run already in the AI phase |
| `stopChat()` | host runs with `drip_completed_at IS NOT NULL` → `endRun(ENDED_STOPPED)` | the scheduled sequence |
| `stopForLead()` | both | — |

---

### 8. `[[BOOKED]]` — a WhatsApp chat becoming an appointment, end to end

This is the chat-side twin of `ae_call_id`, and it is deliberately built as a **host-generic contract**: `src/Whatsapp` never learns the string "AppointmentEngine".

**Step 1 — the prompt asks for the token.** Only when the *run's* meta says so. `WhatsappAiConfig::activeFlowConfig()` reads `$effective['capture'] = $run->meta['capture']` (`:130`) — "Rides the RUN's meta, never the flow — whoever starts the run declares what it wants captured." Both AE starters pass `capture: 'booking'`. `systemPrompt()` (`:237-258`) then appends the booking-capture block, which:
- injects **today's date and the current time** in `app.user_timezone` — because "the model has no clock: without TODAY spelled out it resolves 'this Saturday' against nothing — and the first live test showed it copying the example's literal date instead";
- generates the example from *tomorrow*, "which can never be in the past";
- instructs it to ask Zoom vs showroom unless already clear;
- specifies the grammar `[[BOOKED: YYYY-MM-DD HH:MM | zoom]]` on its own line;
- states the token **replaces** `[[OBJECTIVE_MET]]`, and that neither token may ever be mentioned to the customer.

**Step 2 — the reply is parsed before it is sent.** `GenerateAiReply` (`app/Jobs/Whatsapp/GenerateAiReply.php:219-232`):
```php
$booking = ($effective['capture'] ?? null) === 'booking' ? BookingTokenParser::parse($text) : null;
if (preg_match(BookingTokenParser::REGEX, $text)) { …warn if invalid…; $text = BookingTokenParser::strip($text); }
$objectiveMet = str_contains($text, '[[OBJECTIVE_MET]]') || $booking !== null;
```
The token is **stripped unconditionally** even when capture is off, "the model can parrot a token it saw in its own history."

`BookingTokenParser` (`src/Whatsapp/Support/BookingTokenParser.php`):
- `REGEX = '/\[\[\s*BOOKED\s*:\s*(\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2})\s*(?:\|\s*(zoom|showroom))?\s*\]\]/iu'` — space or `T` allowed, channel optional.
- Parsed in `app.user_timezone`, **returned in `app.timezone`**.
- **Sanity window** (`:41-43`): rejected if earlier than `now()->subMinutes(15)` or later than `now()->addDays(180)` — "A token slightly past is a clock skew; one far past or absurdly far out is the model hallucinating."

**Step 3 — the confidence gate never blocks a success.** The low-confidence hand-off is skipped when `$booking !== null || $objectiveMet` (`GenerateAiReply.php:284`): "blocking success helps nobody."

**Step 4 — the event.** `completeFlowObjective()` (`:905-914`) calls `WhatsappFlowRepository::completeObjective($conversation)`, which ends the newest `RUNNING` + drip-complete run with `ENDED_OBJECTIVE` (rule-based outranking time-based), then fires
`event(new WhatsappBookingCaptured($conversation, $run, $booking['at'], $booking['channel']))`.
The event (`app/Events/Whatsapp/WhatsappBookingCaptured.php`) carries the **ended run**, whose meta holds the starter's correlation bag; `$run` may be null when the token fired with no active run. It is dispatched on the empty-reply path too (`:252-253`) as well as after an auto-send (`:304-305`).

**Step 5 — the listener.** `App\Listeners\AppointmentEngine\RecordWhatsappBooking` (wired in `app/Providers/EventServiceProvider.php:28-30`, `ShouldQueue`, `$tries = 3` — "queued so a hiccup here can never delay or break the customer's reply"):

1. **PRIMARY correlation**: `data_get($event->run?->meta, 'ae.run')` → `WorkflowRun::where('uuid', …)` → `$aeRun->lead` (`:41-44`).
2. **FALLBACK**: contact `phone_e164` + the channel's `group_id` → newest `ae_leads` row; then the newest run `WAITING` on `WAIT_WA_BOOKING` (`:46-58`).
3. No lead, or a trashed one → `Log::info('WhatsApp booking captured with no AE lead — recorded nowhere.')` and return (`:60-66`).
4. **Channel**: `$event->channel` (the lead's in-chat choice) `??` a **ternary**, not a third rung — `$event->channel ?? ($aeRun?->currentNode?->type === 'action.whatsapp_ai' ? (string) $aeRun->currentNode->config('default_meeting_type') : Booking::CHANNEL_SHOWROOM)` (`:69-72`). ⚠️ It matters: when the current node **is** `action.whatsapp_ai` but its `default_meeting_type` is unset, the value is the **empty string** — `CHANNEL_SHOWROOM` is *not* the fallback on that branch, and `Booking::typeFor('')` then resolves to a showroom type only because that is its `default` arm.
5. **The write**, in a transaction (`:74-106`). Keys are `['wa_flow_run_id' => $event->run->id]` when a run exists, else `['ae_lead_id', 'scheduled_at', 'source' => 'ai']`. `ae_call_id` **stays null by design** — "this booking came from a chat, not a call; `wa_flow_run_id` is the equivalent provenance pointer." On an existing row whose time differs, `scheduled_at` and `type` are updated: "The lead re-negotiated in the same chat: the promise moved, the row moves with it."
6. `CrmPipeline::advanceStatus($lead, STATUS_APPOINTMENT_SET)` (`:110`).
7. If the run is open, the appointment id is pinned into `context.wa_booking.appointment_id` and a `booking` log line is written (`:112-120`).
8. `WorkflowRunner::resume($lead, WorkflowRun::WAIT_WA_BOOKING)` (`:122`) — which nulls `waiting_for`/`resume_at`, logs `resumed`, and dispatches `AdvanceWorkflowRun` (`Runner/WorkflowRunner.php:119-130`).
9. **The timer pull-forward** (`:129-137`): every other `WAITING` run for this lead with a future `resume_at` has it set to `now()` and is dispatched. Reason given inline: a chat booking can land while the run is parked on a *nurture timer*, not on `WAIT_WA_BOOKING` — pulling the alarm forward makes the plan's "Already booked?" check run now, so the confirmation goes out at once and the nurture never does.

**Step 6 — the step settles.** `WhatsappAiTakeover::settle()` (`:130-190`) resolves in this order: (1) booked — the listener's `appointment_id` marker **or**, belt-and-braces, an `ae_lead_id` + `source=ai` + `ae_call_id IS NULL` row created since dispatch; (2) no host run found and past `ENGAGE_GRACE_MINUTES = 30` → `no_booking` ("the conversation never started"); (3) host run not running → `no_booking` with `ended_reason`; (4) past `give_up_hours` → `no_booking`, and *"the chat itself lives on — a booking agreed later still writes its row (the listener needs no parked run)"*; else re-park with `RECHECK_HOURS = 6` capped at the deadline.

---

### 9. Zoom, and the identity bridge back

`ZoomMeetings::ensure($appointment, $lead)` (`src/AppointmentEngine/Services/ZoomMeetings.php:48`) is "the ONE place an engine booking gets its Zoom meeting": a no-op for showroom or an already-linked row; host = `$lead->assignedAdmin?->email ?: $credentials->webinarHostEmail()`; a type-2 meeting at `scheduled_at` for `appointment_engine.zoom.meeting_minutes` (default 30); pins `zoom_meeting_id` + `meeting_link` (truncated to 500). Failure is absorbed and raises the `ae.nudge` team alert — "channel=zoom with a NULL link is the Appointments screen's 'no link yet' marker."

Callers: `AiCall::bookIfPromised():403` and `EndBooked::execute():35`. `ZoomInvite` (`Handlers/ZoomInvite.php:36-95`) is the older path: it reuses `$appointment->meeting_link` when present, otherwise creates a meeting at the appointment's future `scheduled_at` (falling back to a next-morning slot when there is no appointment) and pins `type = TYPE_VIDEO_CALL` + the ids back onto the row.

**The bridge back into the Zoom module.** `app/Jobs/Zoom/IdentifyLiveMeeting::leadFor()` (`:180-196`) names a live RTMS meeting's lead by trying `zoom_meetings.zoom_meeting_id` first, then
`appointments.zoom_meeting_id → appointments.ae_lead_id → ae_leads.lead_id`.
Covered by `tests/Feature/Zoom/IdentifyLiveMeetingTest.php:57-83`.

---

### 10. The AI-brain knowledge bridge

`ProfileKnowledge` (`src/AppointmentEngine/Services/ProfileKnowledge.php`) mirrors a workflow step's chosen script/knowledge `ContentItem` into `ai_call_kb_entries` on the CRM's `AiCallProfile` — the same table the CRM's caller reads.

- Entries are titled `"Workflow step {node uuid} · script|knowledge: {item name}"` (truncated to 200) with `seq` 900 (script) / 901 (knowledge), so re-saving **replaces** rather than duplicates (`push()` deletes `title LIKE '{prefix}%'` first).
- `retract($profileId, $node)` removes them from a profile the node no longer belongs to — "a brain swap must not leave the old voice still answering from this deal's facts."
- Either path dispatches `SyncProfileKnowledge::dispatch($profile->id)` only when something actually changed.
- `PlanCompiler::compile()` retracts from the OLD `action.ai_call` nodes **before** the transaction that deletes them (`:50-59`) and pushes to the NEW ones **after** it (`:210-219`), both fail-soft.

Note the profile itself is a plain CRM `Src\VoiceAgent\AiCallProfile` — the AE step stores `profile_id` in its node config and validates it against `$lead->group_id` at dial time — `AiCallProfile::where('id', …)->where('group_id', $lead->group_id)->first()` at `AiCall.php:192` (`:216` is inside the quiet-hours refusal, a different guard). `AiCallProfile::MEETING_SHOWROOM`/`MEETING_ZOOM` deliberately hold the same string values as `Booking::CHANNEL_*` (`src/VoiceAgent/AiCallProfile.php:203-211`) "but are declared here so this module never depends on that one."

---

### 11. The closer bridge (`CloserRotation`)

`CloserRotation::offer()` (`src/AppointmentEngine/Services/CloserRotation.php:183-265`) is the one place the engine picks a person, and it writes the same fact into **four** places so they cannot disagree (`:56-59`):

Inside one transaction (`:190-214`): supersede any `PENDING` handoff for this (lead, appointment) as `STATUS_EXPIRED`; create the `ae_closer_handoffs` row; write `ae_leads.assigned_admin_id` + `team_id`; write `appointments.assigned_admin_id`.
Then, outside it and fail-soft (`:218-227`): `CrmPipeline::syncCloser($lead, $closer->id)` (the engagement's `closer` role) and, when `lead_id` is set, `LeadDistributionService::assign($crmLead, $closer->id, LeadAssignment::REASON_APPOINTMENT)` (times_assigned, tier, activity trail).

A closer with no Telegram subscription to `ae.lead_assigned` is created `STATUS_ACCEPTED` directly rather than `PENDING` — "the booking must never wait on a phone that will never buzz."

---

### 12. The two AI-call ledgers, merged for reading

`ae_calls` and the CRM's `ai_voice_calls` are never merged as tables; they are merged at read time in two places:

- **One lead's history** — `AiCallerRow::forCrmLead($crmLeadId)` (`src/AppointmentEngine/Support/AiCallerRow.php:29`) resolves `ae_leads.lead_id → ae_leads.id`, takes AI-channel calls with `refusal_reason IS NULL`, and shapes them field-for-field like `AiCallPresenter::row()`. `Manage/Leads/LeadsController.php:3498` concats them into the CRM tab and sorts by `created_at` desc, take 50.
- **The whole list** — `AiCallBook::paginate()` (`src/AppointmentEngine/Support/AiCallBook.php:101`) UNIONs two projections of five sort columns and lets the **database** order and page them, with `ledger` + `row_id` as final tiebreakers because "rows tying on `created_at` across two tables would otherwise be free to swap places between page 1 and page 2." Gated on `config('features.appointment_engine_enabled')` (`config/features.php:31`). Refused rows appear here (status 0, `Not called`) but not on the lead tab — the list is an operations queue, the tab is a conversation log.

---

### 13. Deletion, and what the bridges deliberately do *not* delete

`LeadEraser::erase()` (`src/AppointmentEngine/Services/LeadEraser.php:44-87`), one transaction, in this order:
1. `WhatsappFlowRun::where('meta->ae->lead', $lead->uuid)` — `endRun(ENDED_IDLE)` each `RUNNING` one (so the inbox monitor sees it end), then **hard delete** the rows, because `ChatTakeover::begin()` refuses to re-arm a conversation holding a `STOPPED`/`HANDOFF` run of the shadow.
2. `WorkflowRunLog` → `CloserHandoff` → `WorkflowRun` → `Call`: hard deletes.
3. `appointments` where `ae_lead_id`: **soft** delete — "the CRM's appointment table keeps its audit trail; every reader scopes them out."
4. `$lead->forceDelete()`.

Hard deletes are the point (`:22-29`): the sheet reader matches by phone, `enrolOnce()` keys on `(lead, workflow)`, and the takeover's stay-stopped guard keys on ended runs — a lead left lying around in any of those keeps the person out of the flow forever. **The CRM person record and the engagement are never touched**: "they belong to the CRM, and the re-created lead links back to them."

---

### 14. Backfill commands

| Command | Query | Notes |
|---|---|---|
| `ae:link-leads [--dry] [--existing-only]` | `Lead::whereNull('lead_id')->whereNotNull('phone')`, `chunkById(200)` | `--dry` classifies via `CrmIdentity::preview()` → `LeadLinker::OUTCOMES` counts, "because a count alone hides exactly the thing an operator dry-runs to learn: how many PEOPLE a real run would mint"; `--existing-only` uses `linkExisting()` |
| `ae:link-projects [--dry]` | `Project::whereNull('project_id')`, `chunkById(100)` | |
| `ae:link-pipelines [--dry]` | `Lead::whereNotNull('lead_id')->whereHas('aeProject', project_id not null)` | opens the engagement, `syncCloser()`, then catches the status up: `APPOINTMENT_SET` if any appointment exists, else `CONTACTING` if `first_contacted_at`. **A LOST engine stage is deliberately not mirrored** — "whether it was a dead number or a mere give-up is unknowable after the fact, and only dead numbers may kill a deal" (`LinkPipelines.php:51-55`) |
