# Appointment Engine — services, support layer and integrations

**Module:** [AI Appointment System](/docs/modules_handbook/manage/appointment-engine/readMe.md) · **Namespaces:** `Src\AppointmentEngine\Services`, `Src\AppointmentEngine\Support`

The layer between the runner and the outside world: the **read-models** that compute what every screen shows (funnels, run progress, lead rows, the appointment book), the **voice integration** that puts an AI on the phone, the **connection** registry and its verifiers, and the **knowledge/document** pipeline that feeds the brains.

> Companion docs: [runner.md](/docs/modules_handbook/manage/appointment-engine/runner.md) for the engine that calls these; [surfaces.md](/docs/modules_handbook/manage/appointment-engine/surfaces.md) for the screens they feed; [bridges.md](/docs/modules_handbook/manage/appointment-engine/bridges.md) for the ids that cross the module boundary.

> **⚠️ 2026-09-09 — the voice integration and the connection registry described below were retired with the database tidy** (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). Gone: `VoiceCaller`, the engine's `RetellCallMapper`, `RetellWebhookBridge`, `AppointmentEngineRetellController` and `/webhooks/ae/retell`, `AiCallBook`, `AiCallerRow`, `Connection` + `ConnectionVerifier` (`ae_connections`), `Setting` (`ae_settings`), `FlowNode` / `CallProfileFlow` (`ae_flow_nodes`), `Thread` / `Message` (`ae_threads` / `ae_messages`), `RoutingRule` / `RoutingEngine` (`ae_routing_rules`), `BlockedNumber` (`ae_blocked_numbers`), `CrmProject` and `ae:link-projects`. In their place: `Handlers\AiCall` places through the platform's `App\Actions\PlaceAiVoiceCall` on the shared Retell credential and writes the shared `ai_voice_calls` (with `ae_lead_id`, `group_id`, `channel`, `refusal_reason`, `outcome`, `lead_spoke`, `telephony_cost`); the host `RetellWebhookController` settles the row; `Services\EngineCallHooks` wakes the parked run from `AiCallAftermath`; the AI Calls page reads one table; `AiCallBlockedNumber` carries a `group_id`; the private-sheet key is `appointment_engine.sheets.service_account_json` and `ServiceAccountSheetReader::configured()` is its switch; a deal is `Src\Property\Project`. Read-model sections that only reference `ae_calls` (FunnelSummary, LeadEngagement) now read `ai_voice_calls` filtered on `ae_lead_id` / `channel = 'ai'` with the same expressions.

Everything below lives under the feature flag `features.appointment_engine_enabled` (`config/features.php:31`, env `FEATURE_APPOINTMENT_ENGINE_ENABLED`, default `true`). The route block is gated on it (`routes/web.php:1954`). Every AE route also carries `permission:view-appointment-engine` plus the `ae.agency` middleware (`routes/web.php:1957`).

---

### 1. `src/AppointmentEngine/Support/` — what each class computes, and who reads it

| Class | Computes | Consumers |
|---|---|---|
| `AeScope` | The agency a viewer is acting in; `apply` / `applyShared` / `allows` | Every AE read/write — replaces `GroupScope` inside the suite |
| `FunnelSummary` | The one-band funnel (leads in → automation → result) | `Manage/AppointmentEngine/DashboardController.php:68` (all projects, ranged), `:254` (the AI-chat snapshot), `ProjectsController.php:287` (one project, all time, team-narrowed) |
| `ProjectFunnel` | `leads / spoke / booked / attended` per project | `DashboardController.php:447`, `ProjectsController.php:113` (hub list), `:233` (one project) |
| `RunProgress` | A run's position on its plan (`4/16`), the step list, the entry badge | `LeadsController.php:80,763,765,776`, `ProjectsController.php:771`, `Manage/Whatsapp/InboxController.php:1505,1506,1535` |
| `LeadPresenter` | One AE lead row | `LeadsController.php:890`, `ProjectsController.php:774` |
| `AppointmentRow` | One AE appointment row | `ProjectsController.php:261`, `ShowroomController.php:146` |
| `AppointmentCalendar` | A month of appointments for the shared `MonthGrid` | `ShowroomController.php:176`, `ProjectsController.php:244` |
| `Booking` | The engine's dialect of the CRM `appointments` table | 13 files import it (runner handlers, controllers, `ZoomMeetings`, `CloserRotation`, `RecordWhatsappBooking`) |
| `LeadEngagement` | The ENGAGEMENTS band on the lead tables | `LeadsController.php:82`, `ProjectsController.php:772` |
| `HostOptions` | Sendable WhatsApp channels + eligible AI flows | `DashboardController.php:113` (setup checklist), `WorkflowsController.php:487` |
| `AiCallBook` | Both AI-call ledgers as one paginated book | `Manage/Calls/AiCallsController.php:67,79,80,83,84,210` |
| `AiCallerRow` | An engine call shaped like a CRM AI-call row | `Manage/Leads/LeadsController.php:3498`, and `AiCallBook::hydrate()` |
| `LeadRowMapper`, `Plan` | (not in this section's scope) | — |

#### `AeScope` — the scope every other class uses

`AeScope::groupId()` resolves the acting agency in this order (`Support/AeScope.php:35-48`): the user's own `groupId()`, else the session key `ae.agency_id` (`:26`), else `null` = the platform view. `needsChoice()` (`:57`) is true only for a user with no group who has not yet chosen. `apply()` (`:92`) adds `where(column, groupId)` and is a **no-op when `groupId` is null**; `applyShared()` (`:107`) widens to `column IS NULL OR column = groupId` — used for `ae_projects`, whose platform rows are shared.

#### `FunnelSummary::build(?User $viewer, ?Project $project, ?CarbonInterface $from, ?CarbonInterface $to, ?int $teamId)`

Two independent scopings, and the distinction is the reason the class exists (`Support/FunnelSummary.php:24-27`): **`$project` narrows the LEAD SET; `$from`/`$to` narrow each EVENT by its own timestamp.** A lead who arrived last month but was called this week counts as this week's *call*, not this week's *lead*.

The lead set (`:158-179`): `Lead::query()`, `whereRaw('1 = 0')` when `$viewer` is null, then `AeScope::apply(…, 'ae_leads.group_id')`, then optional `ae_leads.team_id`, then — when a project is given — `ae_project_id = $project->id OR (ae_project_id IS NULL AND project = $project->name)`. The name fallback is what keeps pre-bridge imports visible.

| Returned key | Exact definition | Line |
|---|---|---|
| `leads.total` | `SUM` of the per-source counts of `ae_leads` in the set, windowed on `ae_leads.created_at` | `:53,98` |
| `leads.sources[]` | `{key,label,count}`. `sheet` and `ctwa` are ALWAYS emitted, zero included ("nothing from the sheet yet" is information); any other source is appended only when its count > 0, so the rows sum to the total | `:62-77` |
| `calls.placed` | `SUM(refusal_reason IS NULL)` over `ae_calls` where `ae_lead_id ∈ set`, `channel = 'ai'`, windowed on `ae_calls.created_at` | `:82-87` |
| `calls.spoke` | `SUM(lead_spoke = 1)` over the same rows | `:86` |
| `calls.refused` | `SUM(refusal_reason IS NOT NULL)` — counted **apart** so "calls made" can never be inflated by calls nobody made | `:86` |
| `messages.workflow` | Outbound `whatsapp_messages` on conversations whose contact's `phone_e164` is in the lead set, with `meta->source = 'ae_workflow'`, windowed on `whatsapp_messages.created_at` | `:143` |
| `messages.ai` | Same, with `meta->ai->generated = true` | `:144` |
| `messages.total` | `workflow + ai` — **not** a count of all outbound. Anything an agent typed by hand is deliberately excluded | `:146` |
| `result.booked` | `COUNT(*)` of `appointments` where `ae_lead_id ∈ set`, windowed on `appointments.created_at` | `:89-93` |
| `result.attended` | `SUM(outcome = Appointment::OUTCOME_ATTENDED)` (= `1`) | `:93` |
| `result.recorded` | `SUM(outcome IS NOT NULL OR status = Appointment::STATUS_CANCELLED)` (= `7`) | `:93` |

The messages half short-circuits to `['total'=>0,'workflow'=>0,'ai'=>0]` when no conversation matches (`:133-135`).

#### `ProjectFunnel::byProject(?User $viewer, $projects, $from, $to, ?int $teamId)`

Four numbers per project, **two aggregate queries total** regardless of row count (`Support/ProjectFunnel.php:56-78`). Returns `array<projectId, {leads,spoke,booked,attended}>`, pre-seeded with zeros for every passed project (`:48-50`).

| Key | Definition |
|---|---|
| `leads` | `COUNT(*)` of `ae_leads` grouped by `(ae_project_id, project)`, `AeScope`-scoped on `ae_leads.group_id`, windowed on `ae_leads.created_at`, optional `team_id` |
| `spoke` | `SUM(stage >= Lead::STAGE_ANSWERED)` — stage ≥ `3`, the same definition the Leads screen counts by (`:52-58`) |
| `booked` | `COUNT(*)` of `appointments` **joined** to `ae_leads` on `appointments.ae_lead_id`, `AeScope`-scoped on `appointments.group_id`, windowed on `appointments.created_at` (`:73-77`) |
| `attended` | `SUM(appointments.outcome = 1)` over the same join |

Rows are folded onto a project by `ae_project_id ?? $idByName[$row->project]` (`:63`, `:81`) — old leads carry only the project NAME, and folding them by name is what keeps history on the hub. The join on `ae_lead_id` naturally excludes human-made CRM appointments, whose `ae_lead_id` is null (`:71-72`).

> `spoke` here (`ae_leads.stage >= 3`) and `calls.spoke` in `FunnelSummary` (`ae_calls.lead_spoke = 1`) are **different measures** — a lead-count vs a call-count. They are not meant to match.

#### `RunProgress`

The single mapping from a run's current node to the plan's numbered steps.

- `actions()` (`:152-158`) — the run's workflow nodes of type `action.ai_call` or `action.whatsapp`, **sorted by id**. Compile order *is* the plan's numbering, so the numbers always match the Workflow tab.
- `inBookedChain()` (`:187-192`) — true when the current node's type starts with `end.` or is `action.assign_agent`; those are compiled *before* the main chain, so a run standing there has finished the sequence.
- `currentIndex()` (`:168-181`) — `actions->count()` if in the booked chain; `0` when `current_node_id` is null; otherwise the index of the first action whose `id >= current_node_id` (so a run parked on a wait/condition reads as "heading for" the next action), falling back to `count()` past the last action.
- `position()` (`:83-99`) — `{current: min(index+1, count), total: count, phase: 'booked'|'sequence'}`; null when the workflow has no action nodes.
- `steps()` (`:51-74`) — the full list, each `{n, kind: 'call'|'message', label, state: 'done'|'current'|'upcoming'}`. Labels come from two batch lookups: `AiCallProfile` names by `config('profile_id')` and `WhatsappTemplate` names by `config('template_id')`, falling back to `'AI call'` / `'WhatsApp message'`.
- `entryLabel()` (`:31-42`) — from the workflow's first `trigger.*` node: `trigger.google_sheet`→`Sheet`, `trigger.ctwa`→`Ad`, `trigger.whatsapp_keyword`→`Keyword`, `trigger.meta_lead_form`→`Meta`, default `Flow`.
- `mapForLeads()` (`:110-146`) — every OPEN run (`status ∈ {pending, running, waiting}`) for a page of leads, in two queries, keyed by `ae_lead_id`, each entry `position() + {workflow, entry}` so two flows on one lead read apart ("Sheet 4/16" vs "Keyword 2/16").

#### `LeadPresenter::row(Lead, ?Engagement, array $runs, array $engagementStats)`

The lead book and a project's Leads tab are the same table with one column swapped, so both build rows here (`Support/LeadPresenter.php:11-16`). Keys: `uuid, workflow_runs, name` (`'Unnamed lead'` fallback), `phone, email, project` (`aeProject?->name ?: $lead->project` — the keyed project is truth, the legacy free-text column is the fallback), `project_uuid, project_id, stage, stage_label, stage_color, source_label, agent` (`profile->full_name ?: email`), `engagement` (`CrmPipeline::card()`), `last_activity_at`, then the engagement stats spread in (`:67`). **Intent / Budget / Buying journey left this shape on 2026-09-07** — still extracted, still stored, still branching the runner (`condition.field`, `ai.qualify`), but served by the Lead Show payload instead of every list row (`:18-22`).

#### `AppointmentRow::make(Appointment, ?Engagement)`

`Support/AppointmentRow.php:24-61`. Beyond the obvious fields:

- `cancelled` = `status === Appointment::STATUS_CANCELLED` (7).
- `is_past` = `scheduled_at !== null && scheduled_at->isPast()`.
- `channel` = `Booking::channelFor($appointment->type)` — a **null `type` renders as showroom**, and `channel_chosen` (`type !== null`) is what marks a pre-merge row.
- `zoom_pending` = `Booking::zoomLinkPending()` — a Zoom booking with no meeting behind it, the degraded state an agent must fix by hand.
- `via` = `'call'` when `source = 'ai'` and `ae_call_id` is set, `'chat'` when `source = 'ai'` with no call (the WhatsApp AI booked it), `null` for a human booking.

#### `AppointmentCalendar::props(Builder $query, ?string $monthParam, ?int $viewerUserId)`

`Support/AppointmentCalendar.php:28-50`. The caller passes an already-scoped query; this only adds the window and normalisation. `?month=` is parsed as `'Y-m'` and **any parse failure falls back to the current KL month** (`config('app.user_timezone','Asia/Kuala_Lumpur')`). The window is the grid's own 42 cells: `$month->startOfWeek(MONDAY)` to `+42 days`. Events eager-load `lead.user.profile`, `project.catalogProject`, `createdByUser.profile`, order by `scheduled_at`, and are normalised by the same `Appointment::toCalendarArray($viewerUserId)` `/manage/calendar` uses.

#### `Booking` — the engine's dialect of the appointment book

Since the 2026-09-06 merge every AE booking is a row in the CRM `appointments` table, and the engine's vocabulary maps onto the CRM's integers **here, once** (`Support/Booking.php:8-16`).

| Constant | Value |
|---|---|
| `CHANNEL_SHOWROOM` / `CHANNEL_ZOOM` | `'showroom'` / `'zoom'` |
| `CHANNELS` | `showroom => {name: 'Showroom', color: 'amber'}`, `zoom => {name: 'Zoom', color: 'sky'}` |
| `OUTCOME_ATTENDED` / `OUTCOME_NO_SHOW` / `OUTCOME_CANCELLED` | `'attended'` / `'no_show'` / `'cancelled'` |
| `OUTCOMES` | `attended => {Attended, emerald}`, `no_show => {No show, rose}`, `cancelled => {Cancelled, slate}` |
| `SOURCE_AI` / `SOURCE_HUMAN` | `'ai'` / `'human'` |

| Method | Rule |
|---|---|
| `typeFor(string $channel): int` | `zoom → Appointment::TYPE_VIDEO_CALL (2)`, everything else → `TYPE_SHOWROOM_VISIT (1)` |
| `channelFor(?int $type): string` | `TYPE_VIDEO_CALL → 'zoom'`, **every other type including null → `'showroom'`** — the product only distinguishes "they come to us" from "we meet online" |
| `outcomeFor(string): ?int` | `attended → 1`, `no_show → 2`, everything else → `null`. Cancelled is a STATUS in the CRM, handled by callers |
| `aeOutcome(Appointment): ?string` | `status === 7 → 'cancelled'` **checked first**, then `outcome 1 → 'attended'`, `2 → 'no_show'`, else `null` (not recorded is never a no-show) |
| `outcomeRecorded(Appointment): bool` | `aeOutcome() !== null` |
| `zoomLinkPending(Appointment): bool` | channel is zoom AND `meeting_link === null` |

> Do not confuse this with `Src\Engagement\Booking` (a property booking). Both are imported as `Booking` in different files.

#### `LeadEngagement::mapForLeads(Collection<Lead>): array<aeLeadId, stats>`

The ENGAGEMENTS band, in the product's working order: **AI Caller → Phone Call → Message → Zoom Meeting → Showroom F2F** (`Support/LeadEngagement.php:14-40`). It is the CRM lead list's own band narrowed to this suite, and the sums are written to match it expression for expression. Two deliberate differences:

1. **AI Caller reads `ae_calls`, not `ai_voice_calls`** — in this suite the AI caller *is* the engine; reading the CRM ledger would report a different robot than the page is about.
2. **Portal and Zoom Webinar are absent** — a column that is structurally always "—" teaches people to stop reading the row.

Cost: two queries for the whole page. `blank()` (`:85-97`) seeds every key with a zero so a row shape never depends on what happened to exist.

| Key | Source |
|---|---|
| `ai_call` | The LATEST `ae_calls` row (`orderByDesc('id')`, `unique('ae_lead_id')`) with `channel='ai'`, as `{uuid, status_label, status_color, is_refusal, refusal_label, spoke, duration_seconds}` (`:113-146`) |
| `ai_call_count` | Count of that lead's calls **with `refusal_reason IS NULL`** — the phone never rang on a refusal, so counting them would inflate the only number that says the AI did its job (`:121-123`) |
| `call_duration_seconds` | `withSum('callRecordings', 'duration_seconds')` on the bridged CRM lead |
| `wa_in_count` / `wa_out_count` | Correlated subqueries over `whatsapp_messages` joined through conversations/contacts/channels, `chat_type = CHAT_INDIVIDUAL`, `provider != PROVIDER_SANDBOX`, matched on `whatsapp_contacts.user_id = leads.user_id` (`:163-169,182-183`) |
| `zoom_meetings_count` / `zoom_meeting_minutes` | `withCount` / `withSum('duration')` over `zoomMeetings`, both filtered by `ZoomMeeting::applyHappened()` — ⚠️ `duration` is what a meeting was *booked* for, so summing future bookings would credit a lead with an hour of contact before anyone spoke to them (`:173-178`) |
| `f2f_duration_seconds` | `withSum('f2fRecordings', 'duration_seconds')` |

Host channels hang off THE person, so they are read through the CRM bridge `ae_leads.lead_id`; an unbridged AE lead gets zeros, never a hidden error (`:33-37`). Everything is correlated subqueries on indexed `lead_id`, never joins (§14: a join on a non-unique relation inflates the paginator).

#### `HostOptions`

- `channels(?User)` (`:27-48`) — `AccountVisibility::applyWhatsappChannels`, then `status = STATUS_CONNECTED` AND `is_active = true`, ordered by name. Each option carries `window = (provider === PROVIDER_CLOUD_API)`, because only the Cloud API enforces the 24-hour window.
- `flows(array $channels)` (`:58-79`) — `WhatsappFlow` on those channels with `status = STATUS_ACTIVE`, `trigger_type ∈ PROACTIVE_TRIGGERS`, `ai_profile_id NOT NULL`, `objective NOT NULL` — **the same test the runner applies before starting one**. A picker that offers a flow the runner would refuse sends the operator to the wrong screen to fix it (`:10-18`).

#### `AiCallBook` — both AI-call ledgers as one page

The AI Calls page (`/manage/calls/ai-calls`) lists `ai_voice_calls` and `ae_calls` together. The history (`Support/AiCallBook.php:20-53`): `AiCallerRow` merged them on the *lead* page on 2026-09-06, but the LIST kept reading the CRM table alone; once the suite's own Conversations pages were removed (2026-09-07) and Channel → Phone Call was pointed here, the Appointment Engine had **no screen anywhere showing its own calls**, while the Dashboard counted `ae_calls` rows and linked to a page that could never display one.

**How the two page as one** (`:101-128`): two projections of the same five columns (`ledger`, `row_id`, `sort_created`, `sort_duration`, `sort_cost`) are `unionAll`ed and the **database** orders and pages the union, so memory stays bounded and the count is the real total. Only the resulting page's ids are hydrated (two queries per ledger, `:291-319`) and re-ordered to match. Ordering always ends `orderByDesc('sort_created')` (when not the primary sort) → `orderBy('ledger')` → `orderByDesc('row_id')`; without those tiebreakers rows tying on `created_at` across two tables could swap between pages and a call would appear twice or not at all (`:38-40`).

- Sort key mapping (`:103-107`): `duration_seconds → sort_duration`, `cost_usd → sort_cost`, anything else → `sort_created` (desc default).
- `ENGINE_COST` (`:61-62`): `NULL` when both `provider_cost` and `telephony_cost` are null, else their `COALESCE` sum — never `0.0`, because a zero in a sorted cost column reads as "this call was free".
- `statuses()` (`:81-88`): `AiVoiceCall::STATUSES` alone when the suite is off; otherwise `Call::STATUS_REFUSED (0) => ['Not called','slate']` prepended. The two ledgers share integers **1 pending … 7 failed**, which is what makes one status filter honest across both. Keep them that way.
- `engineQuery(?User)` (`:184-200`): `channel = 'ai'` only (human follow-ups share the ledger), `1=0` for a null viewer, `GroupScope::apply(…, 'ae_calls.group_id')`, and for anyone below `LeadVisibility::LEVEL_ALL` a `whereHas('lead.crmLead', LeadVisibility::apply)`. An unbridged engine lead belongs to nobody's scope, so it stays visible to a full-visibility admin and hidden from a scoped one — exactly what the CRM half does with its `lead_id IS NULL` rows.
- `applyEngineFilters()` (`:215-247`): search matches `ae_leads.name LIKE %term%` OR `ae_calls.to_number LIKE %digits%` (digits stripped with `preg_replace('/\D+/','',…)`); `status[]` numeric-filtered then `whereIn('ae_calls.status')`; `date_from`/`date_to` as `whereDate('ae_calls.created_at', >=|<=)`. ⚠️ Its counterpart is `App\Http\Requests\Manage\Calls\AiCallQueryRequest`, which states the same four filters for `ai_voice_calls`; they cannot share an implementation (the CRM reaches a name through `lead.user.profile.full_name`, the engine holds it on `ae_leads.name`) — **change either, change both** (`:205-211`).
- `statusCounts()` / `total()` (`:138-169`): chips and total are computed over the WHOLE book, unfiltered, and summed across both ledgers.
- `engineBlocks(Call)` (`:258-261`): `BlockedNumber::blocks($call->group_id, $call->to_number)` — the engine checks `ae_blocked_numbers`, the CRM agent checks its own list, and a number on one is not on the other. `AiCallsController::blockNumber` / `unblockNumber` therefore write to whichever list belongs to the caller that placed the call (`Manage/Calls/AiCallsController.php:155-193`).
- **Refused rows ARE included here**, unlike on the lead page (`:42-48`): this is an operations queue, where "we did not call these twelve people, and here is why" is what a person comes to find. They arrive under status `0` (`Not called`), a value the CRM ledger has none of, so no refusal can be mistaken for a conversation.

#### `AiCallerRow`

Shapes an `ae_calls` row exactly like `AiCallPresenter::row()` — same keys, same order (`Support/AiCallerRow.php:10-18`) — so one Vue component renders either ledger. `ledger` is `AiCallPresenter::LEDGER_ENGINE` (`'engine'`; the CRM's is `'crm'`, `src/VoiceAgent/Support/AiCallPresenter.php:32-33`).

- `forCrmLead(int $crmLeadId)` (`:30-48`): every `ae_calls` row for the CRM person's linked AE leads with `channel='ai'` **and `refusal_reason IS NULL`**, newest first, `limit(50)`. That tab is a conversation log; a call the AI declined to place said nothing to anybody.
- `profile_name` (`:70`) fills the CRM's campaign-name slot with `"{project} · AI Appointment call"` — which also answers "which project is this lead from" on the call row.
- `disconnection_reason` (`:77-78`) falls back to `Call::REFUSAL_REASONS[$refusal_reason]` — a refused row never reached the network, so the engine's own reason for not dialling stands in its place.
- `cost_usd` = `Call::totalCost()` rounded to 4 places, or null; `is_successful` = `(bool) lead_spoke`; `followup` is always `null` (the engine has no WhatsApp follow-up ladder).

---

### 2. The voice-agent integration

**Correction worth stating plainly: the AE does *not* use the shared `Src\Common\Voice\VoiceCaller`.** That interface is the Twilio one-way-playback contract (`src/Common/Voice/VoiceCaller.php:16-38`). Nor does it use the install-wide `Src\Common\VoiceAgent\ConversationalCaller`. The AE has its **own** `Src\AppointmentEngine\Services\VoiceCaller`, a direct Retell HTTP client that dials on the **agency's own credentials** so the minutes are billed to the agency's Retell account (`Services/VoiceCaller.php:10-21`). What *is* shared is the brain: the step's `profile_id` points at a `Src\VoiceAgent\AiCallProfile` row — the same profiles the CRM's caller uses.

#### Placement: `Runner/Handlers/AiCall::execute()`

Guards, **in the exact order they run**:

| # | Guard | Outcome | Line |
|---|---|---|---|
| 1 | `blank($lead->phone)` | `Call` row, status `REFUSED`, `refusal_reason = 'no_phone'`; `StepResult::next('default')` — the chat half of the flow works this person | `:58-69` |
| 2 | A call already in flight (`ctx['call_id']`) | reconcile → park / settle (see below) | `:76-166` |
| 3 | `attempts >= max` on re-entry | sets `ctx['call_exhausted']`, `next('default')` — never rings again | `:169-173` |
| 4 | **Dial governor**: `ae_calls` for this group with `channel='ai'`, `refusal_reason IS NULL`, `created_at >= now()-1min` ≥ `appointment_engine.voice.dials_per_minute` | `StepResult::wait(+1 minute)` — **no ledger row**, nothing was refused, merely queued | `:181-186` |
| 5 | `Connection` for `(group, 'caller')` missing **or `verified_at === null`** | `refusal_reason = 'not_connected'` | `:195` |
| 6 | Profile missing or `! $profile->isCallable()` (status ACTIVE **and** `retell_agent_id` + `synced_at` both set — `AiCallProfile.php:630,638-641`) | `refusal_reason = 'not_connected'` | `:196` |
| 7 | `BlockedNumber::blocks(group, phone)` | `refusal_reason = 'blocked_number'` → `next('default')`, skipped | `:197,217-219` |
| 8 | Outside `[call_start, call_end)` in `app.user_timezone` | `refusal_reason = 'quiet_hours'` → `wait(nextWindowOpen)` | `:198,212-215,284-302` |
| 9 | Budget fuse exhausted | `refusal_reason = 'daily_budget'` → Telegram nudge `ae.nudge` + `wait(insideHours(tomorrow 09:00))` | `:199,221-225` |

A `Call` row is written for **every** refusal (`:203-210`) — "the AI never called me" can always be answered from the ledger. The budget fuse (`:304-322`) reads `Setting::forGroup($groupId)->daily_call_budget_usd`; a `null` limit means no fuse. Spend = `SUM(COALESCE(provider_cost,0)+COALESCE(telephony_cost,0))` for today's ended AI calls, plus a *reserve* of `inFlightCalls × max_call_minutes × combined_per_minute_usd` for calls still running.

On success (`:233-250`): the lead's stage is raised monotonically to `STAGE_CALLED` and `first_contacted_at` stamped, `CrmPipeline::advanceStatus(…, Engagement::STATUS_CONTACTING)` runs, the call is placed, `ctx['call_id']` + `call_attempts` are stored, and the run **parks** on `WorkflowRun::WAIT_CALL` (`'call_result'`) with a nudge at `max_call_minutes + 5` minutes.

#### `VoiceCaller` — the Retell client

`Services/VoiceCaller.php`. Base `https://api.retellai.com` (`:24`). **Never throws** — a provider refusal is written onto the call row and returned as `false` (`:19-21`).

| Method | Behaviour |
|---|---|
| `place(Connection, Call, array $variables, ?string $agentId): bool` (`:29-66`) | `POST /v2/create-phone-call` with `Http::withToken($connection->credential('api_key'))`, timeout 20s. Body: `from_number` (the connection's `from_number`), `to_number`, `override_agent_id` = the step's profile agent, **falling back to the connection's own `agent_id`**, `retell_llm_dynamic_variables` (cast to object so `{}` not `[]`), and `metadata: {ae_call_uuid, ae_group_id}` — echoed on every webhook as the second key to match on. On success: `provider_call_id` + `status = STATUS_PENDING`. |
| `fetch(Connection, string $providerCallId): ?array` (`:69-81`) | `GET /v2/get-call/{id}`, timeout 15s; null on any failure. The reconcile path. |
| `settleFailed(Call, string $why)` (`:83-91`) | `status = STATUS_FAILED`, `disconnection_reason` truncated to 50 chars, `analysis = ['placement_error' => $why]`, `ended_at = now()`. |

Dynamic variables sent (`AiCall::variables()`, `:254-271`): `customer_name`, `project`, `agent_name`, plus `script` / `knowledge` — the bodies of the step's `script_id` / `knowledge_id` `ContentItem`s, **only when `status = ContentItem::STATUS_ACTIVE`**, flattened and capped at 12,000 characters.

#### `RetellCallMapper` — payload → row

`Services/RetellCallMapper.php`. Same rules as the host's mapper, so a call means the same thing on both.

`status(array $call): int` (`:18-32`), evaluated top to bottom:

| Condition | Status |
|---|---|
| `disconnection_reason ∈ {voicemail_reached, machine_detected}` | `STATUS_VOICEMAIL` (6) |
| `= dial_no_answer` | `STATUS_NO_ANSWER` (4) |
| `= dial_busy` | `STATUS_BUSY` (5) |
| `∈ {user_hangup, agent_hangup, call_transfer, inactivity, max_duration_reached}` | `STATUS_COMPLETED` (3) |
| any other non-empty reason | `STATUS_FAILED` (7) |
| no reason and `call_status = 'ongoing'` | `STATUS_IN_PROGRESS` (2) |
| no reason and `call_status = 'ended'` | `STATUS_COMPLETED` (3) |
| default | `STATUS_FAILED` (7) |

`settle(Call, array)` (`:35-81`) writes status, `disconnection_reason` (50 chars), `started_at`/`ended_at`, `duration_seconds`, `provider_cost`, `recording_url`, `lead_spoke`. Three traps encoded here:

- **`setTimezone`** on `Carbon::createFromTimestampMs()` — it returns UTC and Eloquent stores a datetime in the Carbon's own zone; unshifted it lands 8h behind this Asia/Kuala_Lumpur database (`:37-40`).
- **`abs()` on the diff** — Carbon 3's `diffInSeconds` is signed, and `max(0, …)` silently zeroed every duration (`:54-56`).
- **`call_cost.combined_cost` is in US CENTS** → `/100`, rounded to 4 places. Telephony is billed separately by the carrier and is **not** in this payload — hence the separate `telephony_cost` column (`:57-61`).

`lead_spoke` = the transcript contains at least one `role === 'user'` turn (`:63`). Turns are **deleted and re-inserted whole** so Retell's retries are idempotent (`:66-71`). If the payload already carries `call_analysis`, `settle()` also calls `analyse()` in the same pass — dropping this would lose bookings on every reconcile-after-the-fact path, because the booking capture reads `custom_analysis_data` (`:73-80`).

`turns(array)` (`:96-117`): only `role ∈ {agent, user}` with non-empty content; `seq` 0-based; content trimmed to 4,000 chars; `offset_ms` = `round(words[0].start × 1000)`.

`analyse(Call, array)` (`:83-93`): stores the whole `call_analysis` array, sets `analyzed_at`, and derives `outcome` via `outcome()` (`:119-151`), in this order:

1. Free-text `do not call` / `do_not_call` anywhere in `custom.outcome|result|appointment_booked` → `OUTCOME_DO_NOT_CALL` (4).
2. **The appointment GOAL's own reading**, `(new AppointmentObjective())->verdict($custom)`: `RESULT_MET → OUTCOME_APPOINTMENT (1)`, `RESULT_CALLBACK`/`RESULT_HUMAN → OUTCOME_CALLBACK (2)`, `RESULT_DECLINED → OUTCOME_NOT_INTERESTED (3)`. It owns the extraction fields, so it wins.
3. Free-text net for goal-less brains: `appointment|booked|'true'|'yes' → 1`, `callback|call back → 2`, `not interested → 3`, any other non-empty text → `OUTCOME_UNCLEAR (5)`, empty → `null`.

#### Webhooks — two doors, and why

| Door | Route / file | Auth |
|---|---|---|
| **AE's own** | `POST /webhooks/ae/retell` → `Webhooks\AppointmentEngineRetellController` (`routes/main.php:816`) | Row found FIRST by `call_id` from the **unverified** body, the agency's `api_key` taken from that row's `Connection`, and **only then** the `x-retell-signature` checked. A call id we do not hold is a `204` with no work: verifying it would need a key we do not know which of (`AppointmentEngineRetellController.php:19-25,35-45`) |
| **The host's, bridged** | `Webhooks\RetellWebhookController` → `RetellWebhookBridge::handle()` when `ai_voice_calls` has no row (`RetellWebhookController.php:104-120`) | The host verifies against the install-wide `services.retell.api_key` before anything is read |

Each agency's Retell agent should be pointed at `/webhooks/ae/retell` (stated in the connection catalogue, `Connection.php:42`).

**`AppointmentEngineRetellController::handle()` events** (`:47-73`):

- `call_started` — only `STATUS_PENDING → STATUS_IN_PROGRESS` + `started_at = now()`.
- `call_ended` — `settle()`, then wake the run **only if `! lead_spoke || analyzed_at !== null`**. A spoke call's booking lives in the analysis, which arrives seconds later as `call_analyzed`; waking now would advance the run past the call step with an empty analysis and **the booking would be lost forever** (`:57-63`).
- `call_analyzed` — `analyse()` then wake.
- `wake()` = `WorkflowRunner::resume($row->lead, WorkflowRun::WAIT_CALL)` (`:79-84`).

**`RetellWebhookBridge::handle(string $event, array $payload): bool`** (`Services/RetellWebhookBridge.php:33-87`) — returns `true` when the call is ours (whether or not anything was written), `false` to let the caller keep looking. `call_ended` → settle, plus analyse when the payload already carries `call_analysis`; `call_analyzed` → settle first if `ended_at` is still null, then analyse; **any other event returns `true` immediately with no wake**. It then resumes the run on `WAIT_CALL`. Write failures are `report()`ed and swallowed — a 5xx here would make Retell retry an event the reconcile backstop already covers (`:80-84`). The bridge exists because before it the host controller dropped AE calls as "unknown call" and the parked run only settled via the 15-minute reconcile: the booking always happened, a quarter of an hour after the lead hung up (`:9-20`).

**Reconcile backstop** (`AiCall::reconcile()`, `:324-341`): when a park's nudge fires with no webhook, `VoiceCaller::fetch()` is called directly and, if `call_status === 'ended'`, `settle()` (+ `analyse()`) runs from that payload.

#### `ae_calls` vs `ai_voice_calls`

Both ledgers exist because the AE is a separate product whose schema must move without dragging the CRM's (`2026_08_29_170000_create_ae_calls_tables.php:7-13`). Four `ae_calls` columns exist because the CRM's equivalent lacked them, and each gap cost something real (`:15-28`):

| Concern | `ae_calls` | `ai_voice_calls` |
|---|---|---|
| Model | `Src\AppointmentEngine\Call` (plain `Model` + `HasUuid`, `RecordsBlame`) | `Src\VoiceAgent\AiVoiceCall` (`SoftDeleteModel`) |
| Whose account is billed | the AGENCY's Retell key (`ae_connections`) | the install's `services.retell.api_key` |
| Tenancy | `group_id` | none (install-wide); scoped by `LeadVisibility` on `lead_id` |
| Refusals | **`refusal_reason` column** (`quiet_hours`, `daily_budget`, `blocked_number`, `not_connected`, `no_phone`) + `status = 0 'Not called'` | reasons folded into `disconnection_reason` (`AiVoiceCall::REFUSAL_REASONS`, 8 string values) |
| Did the customer speak | **`lead_spoke` boolean, indexed** — the provider analyses every completed call including agent-only ones and answers the whole schema from the agent's own words; an 11-second call with zero customer turns produced five "facts" about that person | `is_successful` |
| Cost | **`provider_cost` + `telephony_cost`** separately (agent and minutes are billed by different companies; the carrier rounds up to whole minutes) | one `cost_usd` |
| Business result | **`outcome` separate from `status`** (what the phone did ≠ what the business got) | `analysis` + `summary`/`sentiment` |
| Human calls | shares the ledger via `channel ∈ {ai, human}` + `made_by` + `notes` | AI only |
| Extras it lacks | — | `ai_call_profile_id`, `agent_version`, `direction`, `source`, `settings_snapshot`, `settings_version`, `settings_hash`, `dynamic_variables`, `meta` (the follow-up ladder), soft deletes |
| Turns | `ae_call_turns` (`seq`, `role`, `content`, `offset_ms`, unique `(ae_call_id, seq)`) | `ai_voice_call_turns` |

`Call::STATUSES` (`Call.php:24-42`): `0 Not called (slate)` · `1 Pending` · `2 In progress` · `3 Completed` · `4 No answer` · `5 Busy` · `6 Voicemail` · `7 Failed`. `Call::OUTCOMES` (`:44-56`): `1 Appointment set` · `2 Call-back asked` · `3 Not interested` · `4 Do not call` · `5 Unclear`. `Call::CHANNELS` (`:78-84`): `ai` / `human`. `totalCost()` returns **null while nothing has settled, never 0.0** (`:131-138`); `analysisIsEvidence()` = `lead_spoke && analyzed_at !== null` — anything writing a "fact" about a person must ask this first (`:140-150`).

---

### 3. Connections and their verifiers

`ae_connections` is one row per `(group_id, provider)` — unique index (`2026_08_29_230000_create_ae_connections_table.php:45`). Deliberately **not** `messaging_credentials`, which is one row per install: this product is sold per agency, and merging them would mean one agency's Retell key serving another's calls (`:9-16`).

`Connection::PROVIDERS` (`Connection.php:39-73`) is the catalogue that drives the form, the validation and the masking. `secret` fields are **never returned to the client — not masked, not partially**: a masked secret still leaks its length, and a screen that can show four characters of a token is a screen that has the token in a payload (`:31-37`).

| Provider (`const`) | Name | `identity` | Fields (`secret`, all required) | Blocks when missing |
|---|---|---|---|---|
| `CALLER = 'caller'` | AI caller | `from_number` | `api_key` (secret, **also the webhook signing secret**), `agent_id` (`agent_…`), `from_number` (E.164) | AI calling, and every appointment the AI would book |
| `META = 'meta'` | Meta Ads | `ad_account_id` | `ad_account_id` (`act_…`), `access_token` (secret) | Campaigns, cost per lead, cost per appointment |
| `SHEETS = 'google_sheets'` | Google Sheets | `null` — no field is safe to echo | `service_account_json` (secret, multiline, max 10000) | The Google Sheet trigger, when the sheet is private |

Model behaviour: `credentials` is one encrypted JSON blob, `$hidden` (`:87`). `credentialValues()` returns `[]` on a `DecryptException` rather than throwing — an APP_KEY rotation would otherwise take down every page that merely *lists* connections (`:89-109`). `putCredentials()` **drops blank values** so re-saving a form that shows no secrets cannot wipe stored ones (`:124-147`). `isComplete()` requires every `required` field present **and a non-empty spec** (`:158-170`); `missingFields()` names them by label so the screen can say *which* (`:178-191`).

#### `ConnectionVerifier::verify(Connection): ?string`

Returns the failure sentence, or `null` on success (`Services/ConnectionVerifier.php:44-71`). Timeout 12s (`:29`). Endpoints: `https://graph.facebook.com/v21.0`, `https://api.retellai.com` (`:31-32`).

Why it exists (`:9-24`): storing a token and calling that "connected" is the most expensive lie a setup screen can tell — every empty screen downstream then reads as the product failing rather than the credential being wrong. **Every check is read-only and cheap**; nothing places a call, sends a message or spends the customer's money to find out whether it could. **Errors are translated** — a provider's 401 body is not something a property agent can act on.

Order: `isComplete()` first → `'Some required fields are still empty.'`. Then the provider check. A `ConnectionException` (unreachable) is reported and answered with *"Could not reach the provider… Your details have not been changed."* — because unreachable is not the same as wrong, and telling someone their key is bad when the network blipped sends them to rotate a perfectly good credential (`:57-63`). Any other `Throwable` → *"The check could not be completed. Nothing has been changed."*

| Provider | Checks, in order |
|---|---|
| `caller` (`:83-120`) | `GET /get-agent/{agentId}`: 401/403 → *"The API key was rejected…"*; 404 → *"No agent with the ID {id} exists on this account… it starts with agent_."*; any other failure → *"The provider rejected the request (HTTP n)."* Then `GET /list-phone-numbers`: **only enforced when the list came back** and is non-empty — a provider that changes this endpoint must not lock a working account out of its own settings screen. Sets `display_identity = from_number`. All three are checked because each fails differently at 2am and "the AI did not call" is the same symptom for all of them. |
| `google_sheets` (`:134-169`) | JSON must decode to `type === 'service_account'` with `client_email` and `private_key`, else a sentence naming the Google Cloud path. Then mints a real token via `ServiceAccountCredentials(spreadsheets.readonly)` — a signed-JWT round trip, so a pass means the key cryptographically works. `GuzzleHttp ConnectException` is translated to the same "unreachable is not wrong" outcome (google/auth speaks Guzzle, not the Laravel client). Sets `display_identity = client_email` — the email every sheet must be shared with, the one thing about this credential meant to be shown. Whether a PARTICULAR sheet is shared can only be learned per sheet, on the workflow step that reads it. |
| `meta` (`:177-200`) | `GET /{act_id}?fields=name,account_status,currency` (prefixing `act_` when absent). `graphError()` translates: code `190` subcodes `458/459/460` → password changed / app removed, `463` → expired, else "copied whole?"; codes `200`/`10` → *"needs ads_read … or whatsapp_business_messaging"*; code `100` → ID not found. Then `account_status !== 1` (ACTIVE) → *"readable but not active, so no spend can be read"* — worth failing over rather than discovering on an empty Campaigns screen. Sets `display_identity = "{name} · {currency}"`. |

`record()` (`:251-261`) always writes the outcome, so a screen reading the row later sees what the person who pressed the button saw. **`verified_at` is only ever set by a check that PASSED and cleared by one that failed** — the timestamp always answers "when did we last prove this works", never "when did we last try". `is_active = ($error === null && isComplete())`. Note the placement guard reads `verified_at === null`, not `is_active` (`AiCall.php:195`).

---

### 4. The knowledge / document pipeline

```
upload → ae_documents (uploaded) → ClassifyDocument (classifying)
       → DocumentReader::text() → AiClient prompt → pending_review | failed
       → a person corrects the type / approves
       → ae_content_items (DRAFT) → made live by hand
       → a workflow ai_call step names it → ProfileKnowledge::push()
       → ai_call_kb_entries → SyncProfileKnowledge → RetellAgentSync
```

**Upload** (`IngestController::store`, `:103-181`): `mimes:pdf,doc,docx`, max 100 MB per file (`MAX_KILOBYTES = 102400`, `:47`), 1–20 files, and a project is mandatory (`new_project` `required_without:project`) — a document with no project is the "one bucket for everything" this exists to prevent. `store()` returning `false`/`''` is thrown on, because saving that as a path is how a document ends up "could not be read" when it was never stored. The loop is **outside a transaction** on purpose: object storage is a network call and holding a DB transaction across several is how a slow upload becomes a lock timeout on an unrelated page (`:38-42`). One bad file is recorded as a `failed` row rather than losing the batch. `ClassifyDocument::dispatch()` fires **after** the row's transaction commits, or the job would look up an id not yet visible (`:149-152`).

**`ClassifyDocument extends AiJob`** (`app/Jobs/AppointmentEngine/ClassifyDocument.php`):

- `MIN_CONFIDENCE = 55` (`:41`) — below this the guess is not shown as a type at all: a confident-looking wrong label is more expensive than no label, because it stops the reviewer looking properly.
- `aiProvider()` (`:54-58`) — `AiPrompt::pinFor(AiRequest::PROMPT_AE_DOCUMENT_CLASSIFY)['provider']` else `config('ai.default_provider')`. Prompt key `'ae_document_classify'` (`src/Ai/AiRequest.php:163`), registered at `config/ai_prompts.php:407` with `'tier' => AiRequest::TIER_FLASH`, body in `resources/prompts/ae_document_classify.md`.
- `overlapKey()` = `"ae-document-classify:{id}"` (`:63-66`).
- `run()` (`:73-134`): skips a document already `pending_review`; sets `classifying`; `DocumentReader::text()`; **`null` text → `markFailed()` with a customer-facing sentence** ("If it is a scan or a photo of a printed page, re-save it as a text PDF…"). Calls `$ai->prompt(..., ['json' => true])` then `aiOrFail()` — a provider that is down IS worth retrying, so it throws; an unreadable file never will be, so it does not. `decode()` strips ``` fences and falls back to the outermost `{…}`. `confident = type !== null && type !== 'other' && confidence >= 55`; when not confident, `detected_type` and `confidence` are stored as **NULL** so the screen says "not read yet" rather than showing a guess as a fact. `extracted` holds `{title (160), reason (400), suggested_type, suggested_confidence, entries}` with entries capped at 60 and each scalar value at 2,000 chars.
- **The result is a proposal, never an activation** (`:21-26`). The terminal state of an upload is `pending_review`.

**`DocumentReader`** (`Services/DocumentReader.php`) — kept apart from the job because the two change for different reasons: the job is about queueing, cost and retries, this is about file formats; folding them would mean touching the money path to add a file type (`:13-17`).

| Constant / method | Rule |
|---|---|
| `LIMIT = 12000` | Characters sent to the model. Capped in **characters** because it is applied before any provider — and therefore any tokeniser — is chosen (`:33-39`) |
| `MAX_BYTES = 100 * 1024 * 1024` | Largest file worth opening (`:42`) |
| `text()` (`:48-74`) | Null on blank path or oversize; `Storage::get()` in a try/catch; dispatch on the **filename extension**: `pdf` → `fromPdf`, `docx` → `fromOoxml('word/document.xml')`, **everything else including `.doc` → null** (guessing at the old binary format produces mojibake a model would happily classify — worse than admitting we cannot read it) |
| `fromPdf()` (`:80-93`) | `Smalot\PdfParser::parseContent($bytes)` — parsed from bytes, not a path, because the file may live in object storage and never touch local disk |
| `fromOoxml()` (`:102-144`) | Writes to `tempnam()`, opens with `ZipArchive`, concatenates every entry under the prefix. `</w:p>`, `<w:br/>`, `</a:p>` become **spaces before `strip_tags`**, or every line runs into the next and `"Q: price?A: from RM…"` reads as one token |
| `clean()` (`:150-167`) | `mb_convert_encoding($text,'UTF-8','UTF-8')` **first** — a `/u` regex on invalid UTF-8 returns NULL, which silently turned a readable two-page Q&A into "no text". Then whitespace collapse + entity decode; **under 80 characters returns null** (a scanned page's stray header is not a document); finally truncated to `LIMIT` |

Everything fails soft and returns null: a corrupt PDF is not a reason to retry. **It cannot read scanned-image PDFs** — no text layer, and finding one needs OCR, a far larger dependency. No text means unreadable and left for a person, never an invented type (`:19-27`).

**Approval** (`IngestController::classify` / `approve`, `:195-259`): setting the type by hand **clears `confidence`** — a number describing the model's certainty would be a lie once a person overruled it. `approve()` refuses when the type is null or `other` (422) or when no entries were pulled (422), maps type → kind via `KIND_FOR_TYPE` (`faq → knowledge`, `calling_script → script`, `whatsapp_sequence → sequence`, `:56-60`) and creates the `ContentItem` as **`STATUS_DRAFT`** — approving the reading is not approving it to speak.

**`ProfileKnowledge`** (`Services/ProfileKnowledge.php`) mirrors a workflow step's script/knowledge into a shared `AiCallProfile`'s KB — the same place the CRM's caller reads from.

- Entries are titled `"Workflow step {node->uuid} · {script|knowledge}: {item name}"`, truncated to 200 chars, with `seq` 900 (script) / 901 (knowledge) (`:42,54-55`).
- `push()` (`:34-64`) **deletes every entry whose title starts with the node's prefix first**, so re-saving replaces rather than duplicates, then re-creates from `config['script_id']` / `config['knowledge_id']` (looked up by `ContentItem.uuid`).
- `retract()` (`:73-88`) removes a node's entries from a profile it no longer belongs to — a brain swap must not leave the old voice still answering from this deal's facts.
- Either dispatches `SyncProfileKnowledge` only when something actually changed.
- `flattenBody()` (`:94-111`) renders `{question, answer}` as `"Q: …\nA: …"`, `{section, says}` as `"Section: …"`, anything else as space-joined scalars, joined by blank lines.

Both hooks are driven by `PlanCompiler::compile()`: **retract before the old nodes are deleted** (outside the transaction, because it talks to Retell, and fail-soft — a knowledge hiccup must never block a save), **push after the new call nodes exist** (`Services/PlanCompiler.php:49-61,210-218`).

**`SyncProfileKnowledge`** (`app/Jobs/AppointmentEngine/SyncProfileKnowledge.php`) — a plain `ShouldQueue`, `timeout = 300`, middleware `WithoutOverlapping("ae-profile-sync:{id}")->releaseAfter(60)->expireAfter(600)`, delegating to the host's `Src\VoiceAgent\Services\RetellAgentSync::sync()`. Queued because it waits on KB indexing and must never hold a page request.

---

### 5. The less-visited models

| Model | Table | Notable constants / methods |
|---|---|---|
| `Setting` | `ae_settings` (one row per group, `group_id` unique) | `forGroup(?int)` returns an **unsaved blank model with `currency = 'MYR'`** rather than null, so callers need no null check and every field is still null (`Setting.php:44-48`). Every money field is nullable by design: the dashboard's largest tile is a commission figure, and any default would put an invented number in it (`:9-15`). Fillable: `uuid, group_id, commission_per_appointment, cost_per_manual_appointment, currency, created_by, updated_by`; both money fields cast `decimal:2` |
| `Connection` | `ae_connections` | See §3 |
| `ContentItem` | `ae_content_items` (`SoftDeleteModel`) | `KINDS`: `knowledge` "Knowledge base" / `script` "Calling script" / `sequence` "WhatsApp sequence". `CATEGORIES`: `property_info`, `sales_technique`, `objection_handling`, `booking_process`, `faq`, `other` — a call step can be pointed at exactly the material it should speak from, so an agent asked about maintenance fees is not handed the closing script (`:33-52`). `STATUSES`: `draft` (slate) / `pending_review` "Waiting for review" (amber) / `active` "Live" (emerald). `isLive()` = status is `active`. `body` cast to array — stored **parsed, never as an opaque file**: an upload the AI cannot read is not content, it is an attachment |
| `Document` | `ae_documents` (no soft deletes) | `TYPES`: `whatsapp_sequence`, `calling_script`, `faq` "Project Q&A", `other` "Not recognised" — each with a `becomes` line. `STATUSES`: `uploaded` (slate) / `classifying` "Reading it" (sky) / `pending_review` "Waiting for review" (amber) / `failed` "Could not read it" (rose). The type is a **guess with a confidence, shown back for correction before anything commits** |
| `FlowNode` | `ae_flow_nodes` (unique `(group_id, node_key)`) | `forGroup(?int, ?int $actorId)` seeds an agency's flow from `CallProfileFlow::nodes()` **once, guarded on having no rows at all**, so an agency that deliberately emptied a field never has the default written back on the next page load (`:41-77`). Grid → pixels applied once at seed (`x * COLUMN`, `y * ROW`). `ordered()` sorts by the code's node order, not row order. `setting(key)` falls back to the schema default — a field added after an agency seeded has no stored value, and reading null would render an empty box where the default is what actually happens (`:92-118`). `requiredConnection()` reads `needs` from the code, not from storage: renaming "AI calls" does not stop it needing the caller connection |
| `Message` | `ae_messages` | **Four authors, not two**: `lead`, `ai`, `human`, `unknown` "Unattributed". UNKNOWN is the one that matters — some outbound rows genuinely cannot carry a sender, and filing them as HUMAN is how thousands of automated sends come to look like a person typed them; the spec makes "AI content is visually distinct" an acceptance criterion, which cannot hold if the schema has nowhere to put "we don't know" (`:8-15`). `DELIVERIES`: `queued` "Sending" / `sent` / `delivered` / `read` / `failed`. **`DELIVERY_RANK`** `queued 0 < sent 1 < delivered 2 < read 3 < failed 4`, and `advancesTo()` only accepts a strictly higher rank: Meta's receipts are not ordered, a `delivered` can arrive after a `read`, and `failed` is terminal and above everything (`:50-89`). `provider_message_id` is **globally unique**, not scoped to the thread — a wamid is unique per WhatsApp account, and scoping it would let one message land twice under two threads if a number were re-parented (`2026_08_30_090000…:29-33`) |
| `Thread` | `ae_threads` | **Takeover and flag are separate deliberately**: flagging asks a human to look, taking over stops the AI mid-conversation; collapsing them would mean a leader cannot say "someone should read this" without silencing the automation doing the work (`:12-19`). `aiIsHandling()` = `human_took_over_at === null` |
| `CallTurn` | `ae_call_turns` | `ROLE_AGENT = 'agent'`, `ROLE_LEAD = 'lead'`. ⚠️ The mapper writes `'agent'` / **`'user'`** from Retell's transcript (`RetellCallMapper.php:104`), so `ROLE_LEAD` never appears in data written by that path. Turns are rows rather than a blob so "did the customer speak?" is a COUNT, not a JSON parse on every read |
| `CallProfileFlow` | (no table — code defaults) | `COLUMN = 232`, `ROW = 132`. `TYPES`: `trigger` "Trigger", `whatsapp` "WhatsApp", `call` "AI call", `decision` "Decision", `success` "Success". Six authored nodes: `inbound` (needs `meta`) → `notice` (`whatsapp`) → `call` (`caller`) → `answered` (decision) → `booked` (`whatsapp`) / `nurture` (`whatsapp`); edges labelled `Yes` / `No` off `answered`. `fields()` is the **typed** schema — `select`, `number` (with `unit`/`min`/`max`), `time`, `toggle`, `content` (with `kind`), `locked` (a fact the customer may not change, **with the reason**). Locked values (`discloses_ai` = "Always, in the opening line" — required by the voice provider's terms and consumer law; `voicemail` = "Hangs up — no message, no minutes billed") are **not stored**: a copy in the database is a copy that can go stale (`:255-261`). Defaults worth knowing: `dedupe_days 30`, quiet `09:00–21:00`, `max_attempts 3`, `retry_gap_hours 24`, `answered_means 'spoke'`, `reminder_hours_before 16`, `nurture max_days 7` |
| `BlockedNumber` | `ae_blocked_numbers` (unique `(group_id, phone)`) | `blocks(?int $groupId, string $phone)` compares **digits only** (`preg_replace('/\D+/','',…)`) — a list holding `+60123456789` must catch `60123456789` arriving from a webhook. Checked by the **caller** before every dial, not by the workflow that asked for one: a workflow is configuration and can be wrong; someone who asked not to be called must not be called because a step was misconfigured (`:8-13`) |

---

### 6. `config/appointment_engine.php` — every key

The file's own rule (`:5-16`): settings that belong to the **product**, never to a customer. Anything an agency owns — their WhatsApp number, ad account, voice agent — lives encrypted in `ae_connections`. WhatsApp is the worked example: Meta signs every webhook with the APP secret (ours), while the number that received the message is the customer's, so one app secret verifies the request and the `phone_number_id` inside the payload says which agency it is about. Putting the app secret in a per-agency table would mean every customer needing their own Meta app.

| Key | Env var | Default | Cast | Read by |
|---|---|---|---|---|
| `meta.verify_token` | `AE_META_VERIFY_TOKEN` | `null` | — | **nothing** (see gaps) |
| `meta.app_secret` | `AE_META_APP_SECRET` | `null` | — | **nothing** (see gaps) |
| `voice.max_call_minutes` | `AE_MAX_CALL_MINUTES` | `10` | `int` | `AiCall.php:248` (park nudge = `+ 5` min), `:319` (budget reserve) |
| `voice.daily_budget_usd` | `AE_DAILY_BUDGET_USD` | `50` | `float` | **nothing** — the fuse reads `ae_settings.daily_call_budget_usd` instead (`AiCall.php:306`) |
| `voice.combined_per_minute_usd` | `AE_COMBINED_PER_MINUTE_USD` | `0.20` | `float` | `AiCall.php:319`. Every answered minute is billed twice — voice provider and telephone network |
| `voice.dials_per_minute` | `AE_DIALS_PER_MINUTE` | `3` | `int` | `AiCall.php:184`. Per agency, across every source: telephony reputation systems flag PATTERNS (volume, simultaneous rings), not single calls, so a run over the floor waits a minute |
| `zoom.meeting_minutes` | `AE_ZOOM_MEETING_MINUTES` | `30` | `int` | `Services/ZoomMeetings.php:69`. A config constant, not a per-step field — the ai_call drawer is already nine fields deep |

There is **no shared Retell secret here**: Retell signs with the API key, which is per-agency, so the webhook resolves the agency from the call id and verifies against that agency's own key (`:26-29`). Related keys outside this file: `features.appointment_engine_enabled` (`FEATURE_APPOINTMENT_ENGINE_ENABLED`, default `true`) and the host's `services.retell.*`.

---

### 7. The three `Link*` backfill commands

All three are idempotent, chunked, fail-soft (failures are `report()`ed and the row is left recoverable rather than mis-linked), and each carries a `--dry` option. They exist because the four convergence bridges were added on 2026-09-06 to a book that already had rows.

| Command | Signature | Backfills | Query it walks | Engine |
|---|---|---|---|---|
| `ae:link-leads` | `{--dry} {--existing-only}` (`LinkLeads.php:17-19`) | `ae_leads.lead_id` — the CRM **person** | `Lead::whereNull('lead_id')->whereNotNull('phone')`, `chunkById(200)` | `CrmIdentity::ensureLinked($lead, $createMissing)` → the host `LeadLinker`. `--existing-only` calls `linkExisting()` so no account is ever minted. `--dry` classifies each row through `CrmIdentity::preview()` and prints counts by `LeadLinker::OUTCOMES` name — a bare count would hide exactly what an operator dry-runs to learn: how many PEOPLE a real run would mint |
| `ae:link-projects` | `{--dry}` (`LinkProjects.php:17`) | `ae_projects.project_id` — the CRM **sales project** | `Project::whereNull('project_id')`, `chunkById(100)` | `CrmProject::ensureLinked()` — match by `LOWER(TRIM(name))` within the deal's `group_id`, else mint through `SalesProjectRepository::create()` with commission left blank for a human to fill |
| `ae:link-pipelines` | `{--dry}` (`LinkPipelines.php:20`) | the **sales pipeline** `Engagement` for each bridged lead | `Lead::whereNotNull('lead_id')->whereHas('aeProject', fn ($q) => $q->whereNotNull('project_id'))`, `chunkById(100)` | `CrmPipeline::ensureOpen()`, then for each lead that now has one: `syncCloser()` (mirrors `assigned_admin_id` onto the CLOSER role), then a **monotonic status catch-up** — `STATUS_APPOINTMENT_SET` if any `appointments.ae_lead_id` row exists, else `STATUS_CONTACTING` if `first_contacted_at` is set |

`ae:link-pipelines` deliberately does **not** mirror a LOST engine stage (`LinkPipelines.php:51-55`): whether it was a dead number or a mere give-up is unknowable after the fact, and only dead numbers may kill a deal. It also respects a soft-deleted engagement — an admin who removed the lead from the project said something.

Only one AE command is scheduled: `$schedule->command('ae:run-workflows')->everyMinute()->withoutOverlapping()` (`app/Console/Kernel.php:209`). The three `Link*` commands are manual, run once per environment after the convergence deploy.
