# Appointment Engine — the runner

**Module:** [AI Appointment System](/docs/modules_handbook/manage/appointment-engine/readMe.md) · **Entry point:** `ae:run-workflows` (every minute, `withoutOverlapping`) · **Namespace:** `Src\AppointmentEngine\Runner`

The backend logic, in the order a lead meets it: a **trigger** puts them in the book, a **plan** the agency drew is compiled into a graph, the **runner** walks that graph one node at a time, **handlers** do the work (dial, message, wait, decide), and when a booking lands the **closer rotation** hands it to a person.

> Companion docs: [data-model.md](/docs/modules_handbook/manage/appointment-engine/data-model.md) for the tables these write; [bridges.md](/docs/modules_handbook/manage/appointment-engine/bridges.md) for the ids that leave the module; [ai-agent/readMe.md](/docs/modules_handbook/manage/appointment-engine/ai-agent/readMe.md) for the builder UI on top of the plan.

> **⚠️ 2026-09-09 — the engine's duplicate tables merged into the platform's own, and parts of this file describe deleted code.** Every sibling doc carries this banner; this one was missed. The [changelog entry](/docs/modules_handbook/manage/appointment-engine/changelog.md) is authoritative. Read the sections below with these substitutions:
>
> - **Projects.** `ae_projects` is GONE — a deal IS the CRM `projects` row (`Src\Property\Project`), and every `ae_project_id` column is now `project_id`. `Lead::aeProject()` is now `Lead::crmProject()`; the other models just declare `project()`. Note the two call sites quoted below read the **column, not the relation**, so `$lead->aeProject?->project_id` is now plain `$lead->project_id`. **§7's scoping is therefore wider than it says**: `applyShared` now scopes `projects.group_id` — the SHARED CRM table the Sales and FLG suites also read — not a table private to this module. Every line number in §7's call-site tables has moved; re-grep rather than trusting them.
> - **Calls.** `ae_calls` / `ae_call_turns` → the shared `ai_voice_calls` / `ai_voice_call_turns`, and `appointments.ae_call_id` → `ai_voice_call_id`. So §4's booking step is `Appointment::firstOrCreate(['ai_voice_call_id' => $call->id], …)`. `ae_blocked_numbers` → `ai_call_blocked_numbers`.
> - **§10 and the webhook section are HISTORY.** `Services\VoiceCaller`, `RetellCallMapper`, `RetellWebhookBridge`, `Support\AiCallBook` and `AppointmentEngineRetellController` were all deleted and `/webhooks/ae/retell` closed. In their place: `Handlers\AiCall` places every call through the platform's one entry point `App\Actions\PlaceAiVoiceCall`; the host `RetellWebhookController` settles the row; `AiCallAftermath` hands it to `Services\EngineCallHooks`, which wakes the parked `WAIT_CALL` run. **Do not grep a file path out of §10** — those classes are gone.
> - **Dropped with no successor:** `ae_appointments`, `ae_visits`, `ae_threads`, `ae_messages`, `ae_flow_nodes`, `ae_routing_rules`, `ae_settings`, `ae_connections`. What remains is `ae_leads`, `ae_workflows`, `ae_workflow_nodes`, `ae_workflow_edges`, `ae_workflow_runs`, `ae_workflow_run_logs`, `ae_content_items`, `ae_documents`, `ae_project_closers`, `ae_closer_handoffs`, `ae_sheet_cursors`.
>
> ℹ️ "Catalogue" in this document means `NodeCatalogue`, the workflow node-type registry — **not** the project catalogue. The appointment engine stores no `catalog_*` key; it reaches the catalogue only through the CRM `projects` row. See [project-catalogue/start-here.md](/docs/modules_handbook/shared/project-catalogue/start-here.md).

## Contents

1. [Intake — how a lead enters the engine](#1-intake-how-a-lead-enters-the-engine)
2. [Design — the plan document and its compiler](#2-design-the-plan-document-and-its-compiler)
3. [The runner — how a run executes](#3-the-runner-how-a-run-executes)
4. [Call-side handlers](#4-call-side-handlers)
5. [Message-side and ending handlers](#5-message-side-and-ending-handlers)
6. [Assigning the closer](#6-assigning-the-closer)
7. [Scoping — who sees what](#7-scoping--who-sees-what)
8. [Two hazards that cross the module boundary](#8-two-hazards-that-cross-the-module-boundary)

---

## 1. Intake — how a lead enters the engine

Only **three** code paths ever create an `ae_leads` row, and only **three** ever create an `ae_workflow_runs` row. Everything below funnels through them:

| Creates the lead | Creates the run |
|---|---|
| `src/AppointmentEngine/Runner/Triggers.php:595` (`lead()`) | `Triggers.php:524` (`enrolOnce()`, sheet only) |
| `app/Http/Controllers/Manage/AppointmentEngine/LeadsController.php:144` (manual add) | `Triggers.php:630` (`enrol()`, WhatsApp / CTWA / Meta) |
| `LeadsController.php:227` (CSV / spreadsheet import) | `LeadsController.php:283` (`enrol()`, manual add + import) |

Everything else in the suite (webhooks, chat takeover, closer rotation) only *changes* leads that already exist.

---

#### 1. The six doors, and what matches on each

| Entry | Trigger node type | Swept by | Lead `source` | Matching rule |
|---|---|---|---|---|
| WhatsApp keyword | `trigger.whatsapp_keyword` | `Triggers::whatsapp()` (`:51`) | `whatsapp` | Inbound message with a non-blank body — the fetch is `type = TYPE_TEXT` **OR** `meta->referral IS NOT NULL` (`Triggers.php:78-85`), so a CTWA referral is eligible for keyword matching too contains / starts with / equals one of the node's comma-separated `keywords`, optionally narrowed to one `channel_id` |
| Click-to-WhatsApp ad | `trigger.ctwa` | `Triggers::whatsapp()` (`:51`) | `ctwa` | Inbound message of **any** type carrying `meta->referral`; `ad_id` (comma-separated) filters on `referral.source_id`; without a referral, only matches when `fallback_keywords` is set and the body contains one |
| Meta lead form | `trigger.meta_lead_form` | `Triggers::meta()` (`:198`) | `meta` | A new `flg_leads` row whose `raw.form_id` (or `raw.leadgen_form_id`) equals the node's `form_id`; blank `form_id` accepts every form |
| Google Sheet | `trigger.google_sheet` | `Triggers::sheets()` (`:261`) → `syncNode()` (`:287`) | `sheet` | A mapped data row at an absolute index ≥ `rows_seen`, or a "returning" row (§4.6) |
| Manual add | `trigger.manual` (nominal only) | — (synchronous HTTP) | `manual` | `POST /manage/appointment-engine/leads` (`routes/web.php:1991`) |
| CSV / spreadsheet import | `trigger.manual` (nominal only) | — (synchronous HTTP) | `import` | `POST /manage/appointment-engine/leads/import` (`routes/web.php:1993`) |

Source constants and their labels are `Src\AppointmentEngine\Lead::SOURCE_MANUAL='manual'`, `SOURCE_IMPORT='import'`, `SOURCE_WHATSAPP='whatsapp'`, `SOURCE_META='meta'`, `SOURCE_SHEET='sheet'`, `SOURCE_CTWA='ctwa'` (`src/AppointmentEngine/Lead.php:54-68`). Every new lead starts at `STAGE_NEW = 1` (`Lead.php:32`).

`trigger.manual` exists in the catalogue (`src/AppointmentEngine/NodeCatalogue.php:203`) and in the handler registry as a passthrough (`Runner/HandlerRegistry.php:17`), but no code looks for it: the manual paths take the workflow uuid straight from the form.

---

#### 2. The minute sweep

`ae:run-workflows` runs `everyMinute()->withoutOverlapping()` (`app/Console/Kernel.php:209`) and calls `Triggers::sweep()` first, then dispatches up to 200 due runs (`app/Console/Commands/AppointmentEngine/RunWorkflows.php:24-51`).

```php
// Triggers.php:42-48
$workflows = Workflow::where('is_active', true)->with('nodes')->get();
if ($workflows->isEmpty()) return 0;
return $this->whatsapp($workflows) + $this->meta($workflows) + $this->sheets($workflows);
```

**Only ACTIVE workflows are swept** — a paused flow's sheet is not polled at all by the heartbeat (its sheet can still be polled by hand, §4.9). The sweep is not agency-scoped: it walks every agency's live workflows in one pass, and each created lead takes `group_id` from *its* workflow (`Triggers.php:596`).

The class doc states the design rule (`Triggers.php:23-34`): this is **a sweep, not a hook** into the host's 2,400-line inbound WhatsApp job — it reads the rows that job writes, from a cursor, so it survives the host being refactored. Both push cursors start at "now" so a fresh install never replays history.

---

#### 3. Push sources

##### 3.1 WhatsApp keyword + CTWA — one fetch, two matchers

Both matchers share **one** cursor (`ae:triggers:whatsapp_cursor`, `Triggers.php:37`) because "a sibling sweep on the same cursor would double-advance it" (`:65-66`).

Guard order per sweep (`Triggers.php:53-91`):

1. Collect keyword trigger nodes and CTWA trigger nodes; return `0` if both sets are empty.
2. `Cache::get(WA_CURSOR)`; if `null`, `Cache::forever(WA_CURSOR, WhatsappMessage::max('id') ?? 0)` and **return 0 without processing anything**.
3. Fetch `WhatsappMessage` where `id > cursor`, `direction = 1` (`WhatsappMessage::DIRECTION_IN`), and (`type = 1` (`TYPE_TEXT`) **OR** `meta->referral IS NOT NULL`), eager-loading `conversation.contact` + `conversation.channel`, `orderBy('id')`, `limit(500)`.
   The `orWhereNotNull('meta->referral')` is deliberate: "a CTWA click can land as an image/video first message, and the referral rides whatever that message is" (`:79-81`). `meta.referral` is written by the host webhook job at `app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php:163` and whitelisted at `src/Whatsapp/Drivers/CloudApiDriver.php:1534-1551` (`source_type`, `source_id`, `source_url`, `headline`, `body`, `media_type`, `image_url`, `video_url`, `thumbnail_url`, `ctwa_clid`).
4. Advance the cursor to `$messages->max('id')` **before** processing (`:91`) — a crash mid-loop loses that batch rather than replaying it.

Then per message, in this order (`Triggers.php:94-145`):

1. `$phone = conversation.contact.phone_e164`; `$waContactId = conversation.contact.id`; `$channel = conversation.channel`.
2. **Skip** when `(! $phone && ! $waContactId) || ! $channel`. A hidden-number (username) contact has no phone — the contact id becomes the identity, and the lead runs the chat half of the flow while the AI-call step skips itself (`:97-99`; `ae_leads.phone` was made nullable for exactly this, `database/migrations/2026_09_07_190000_make_ae_leads_phone_nullable_add_wa_contact.php`).
3. **CTWA triggers first**: skip the node if its `channel_id` is set and differs; then `matchesCtwa()`; then create/reuse the lead with `SOURCE_CTWA`, stamp the ad identity, `enrol()`.
4. **`if (blank($message->body)) continue;`** — "keyword matching needs text; CTWA above already ran" (`:126-128`).
5. **Keyword triggers**: same `channel_id` filter, then `matches()`, then lead with `SOURCE_WHATSAPP`, `enrol()`.

`matchesCtwa()` (`:160-171`): with a referral, `ad_id` empty ⇒ match; otherwise `in_array((string) $referral['source_id'], $wanted, true)`. Without a referral, match only if `fallback_keywords` is non-empty and the body `contains` one.

`matches()` (`:541-560`) lower-cases and trims the body, splits `keywords` on commas, and applies `exact` (`===`), `starts_with` (`str_starts_with`) or **`contains` as the default arm** — any unknown mode falls through to contains.

`stampAdIdentity()` (`:182-196`) writes `source_campaign` ← `referral.source_id` and `ctwa_clid` ← `referral.ctwa_clid` **only where the lead's value is blank**: "a lead who clicks a second ad keeps the ad that actually acquired them."

One message can start several workflows: the loops do not `break` on a hit, and a message matching triggers in two agencies produces **two** leads (one per `group_id`).

##### 3.2 Meta lead form

`Triggers::meta()` (`:198-247`), cursor `ae:triggers:meta_cursor` (`:38`):

1. Return `0` if there are no `trigger.meta_lead_form` nodes **or** `! class_exists(FlgLead::class)` (`:204`).
2. Same null-cursor bootstrap to `FlgLead::max('id')`, return 0.
3. `FlgLead::where('id','>',$cursor)->orderBy('id')->limit(500)->get()`, advance cursor to the page max.
4. Per row: `e164($row->phone)`; **skip the row entirely when it is `''`**.
5. `$formId = data_get($row->raw,'form_id') ?? data_get($row->raw,'leadgen_form_id') ?? ''`; a node with a non-empty `form_id` that differs is skipped.
6. `lead(..., $row->full_name, SOURCE_META, $row->email)` → `enrol()`.

`FlgLead` is `Src\FacebookLeadGenerator\Lead`, table `flg_leads`, with `full_name`, `phone`, `email`, `raw` (array cast) (`src/FacebookLeadGenerator/Lead.php:14-25`).

---

#### 4. The Google Sheet pipeline

##### 4.1 Where the configuration lives

The trigger node's `config` is **compiled from the workflow's `plan`**, never hand-edited: `PlanCompiler::compile()` deletes every node and edge and rebuilds them in one transaction (`src/AppointmentEngine/Services/PlanCompiler.php:63-65`), creating the trigger as `$make(Plan::ENTRY_TYPES[$plan['entry']], $plan['entry_config'], …)` (`:125`). `Plan::ENTRY_TYPES` maps `'sheet' => 'trigger.google_sheet'` (`src/AppointmentEngine/Support/Plan.php:54`), and `Plan::ENTRIES = ['sheet','ctwa','keyword']` (`:25`).

`Plan::entryConfig()` whitelists and clamps the sheet keys (`Plan.php:216-223`):

| Key | Type / clamp | Default | Read at |
|---|---|---|---|
| `access` | `'service_account'` if exactly that string, else `'link'` | `link` | `Triggers.php:331` |
| `sheet_url` | string | `''` | `Triggers.php:325` |
| `worksheet` | string (tab NAME; ignored in link mode) | `''` | `Triggers.php:332` |
| `poll_minutes` | int, `max(1, min(1440, …))` | `15` | `Triggers.php:294` (`max(1, …)` again) |
| `on_first_sync` | `'enrol_all'` if exactly that, else `'new_only'` | `new_only` | `Triggers.php:367` |
| `pace_per_minute` | int, `max(1, min(10, …))` | `2` | `Triggers.php:414` (`max(1, …)` again) |

The same fields, with their admin-facing help text, are declared in `NodeCatalogue.php:214-262` — including the WHY for `on_first_sync` ("A sheet with months of old rows would otherwise also ring every one of them the moment this goes live") and for `pace_per_minute` ("five hundred simultaneous calls is how a caller ID gets flagged as spam").

Publish gate: `Workflow::sheetProblems()` (`src/AppointmentEngine/Workflow.php:250-276`) **blocks going live** on a filled-but-unparseable URL and on `service_account` without a verified `google_sheets` connection — deliberately harder than the WhatsApp badge, because "a sheet workflow with no way to read its sheet does nothing, silently, forever — there is no lead who complains."

##### 4.2 The two access modes

`SheetRef::fromUrl()` (`src/AppointmentEngine/Services/Sheets/SheetRef.php:31-40`) extracts the spreadsheet id with `#/spreadsheets/d/([a-zA-Z0-9_-]+)#` and the tab gid with `~[?&#]gid=(\d+)~` (default `0`). Anything else returns `null` → `SheetReadException('The sheet link does not look like a Google Sheets URL. Paste the address straight from the browser.')` (`Triggers.php:328`).

| | `link` (`SharedLinkSheetReader`) | `service_account` (`ServiceAccountSheetReader`) |
|---|---|---|
| Credentials | none | `ae_connections` row, `provider = 'google_sheets'`, `verified_at NOT NULL`, scoped to `$workflow->group_id` (`Triggers.php:335-338`) |
| Transport | `GET docs.google.com/spreadsheets/d/{id}/export?format=csv&gid={gid}`, 15s timeout | `GET sheets.googleapis.com/v4/spreadsheets/{id}/values/{range}` with a bearer token, 15s timeout |
| Tab selection | the **gid from the pasted URL** — the `worksheet` name is passed as `null` and ignored (`Triggers.php:349`, `SharedLinkSheetReader.php:21-22`) | the `worksheet` NAME, quoted and `'`-escaped, as `'Tab'!A1:Z10000`; blank ⇒ first tab |
| Bound | none | `A1:Z10000` (`ServiceAccountSheetReader.php:26`) — 26 columns, 10,000 rows |
| Token | — | `google/auth` `ServiceAccountCredentials` on scope `spreadsheets.readonly`, cached 50 min per `connection.id + updated_at` (Google tokens live an hour); a null token is `Cache::forget`-ed rather than cached (`:100-121`) |

Failure sentences (each becomes `SheetCursor.last_error` and is shown verbatim to the admin):

| Condition | Message |
|---|---|
| link mode, response not `text/csv` or failed | "The sheet is not shared. Open it in Google Sheets → Share → set "Anyone with the link" to Viewer — or switch this step to the private-sheet mode." — a private sheet **does not 403 here**, Google redirects to a login HTML page, so the content-type is the giveaway (`SharedLinkSheetReader.php:7-13, 36-40`) |
| link or SA, HTTP 404 | "No sheet exists at that link. It may have been deleted, or the link copied wrong." |
| SA, HTTP 403 containing `SERVICE_DISABLED` | "The Google Sheets API is not enabled on the service account's Google Cloud project…" |
| SA, other HTTP 403 | "The sheet is not shared with {client_email}. Open it in Google Sheets → Share → add that email as Viewer." |
| SA, HTTP 400 | "There is no tab called "{worksheet}" on that sheet…" |
| SA, other failure | "Google rejected the read (HTTP {status}). Try again shortly." |
| SA, unusable/rejected key | "Google rejected the service-account key for the stored Google Sheets connection." |
| `access = service_account`, no verified connection | "This step reads a private sheet, but Google Sheets is not connected — switch the step to a link-shared sheet." (`Triggers.php:341`) |
| rows empty but the table had ≥2 lines | "The first row must be headings, and one of them must be "phone"." (`Triggers.php:353`) |
| anything else | reported to the log; the cursor stores "The sheet could not be read. The error has been logged." (`Triggers.php:306-311`) |

`SheetReadException`'s message **is** the user-facing sentence; anything not on that list stays a plain `Throwable` (`Services/Sheets/SheetReadException.php:7-14`).

##### 4.3 The cursor — `ae_sheet_cursors`, one row per trigger node

Created by `SheetCursor::firstOrCreate(['ae_workflow_node_id' => $node->id], ['group_id' => $workflow->group_id])` (`Triggers.php:289`). It exists as its own table because "the step drawer rebuilds `config` from the field schema on every save, so anything the sweep stashed there would be wiped the moment a person renamed the step" (`database/migrations/2026_09_01_100000_create_ae_sheet_cursors_table.php:10-16`).

| Column | Type | Written | Protects against |
|---|---|---|---|
| `ae_workflow_node_id` | `unsignedBigInteger` **unique** | on create | two cursors for one node |
| `group_id` | nullable, indexed | on create | cross-agency reads |
| `fingerprint` | `string(64)` = `sha1("{spreadsheetId}\|{gid-or-tab}\|{access}")` (`SheetRef.php:51-56`) | every successful poll | applying an old row offset to a **different** sheet after the URL, tab or access mode is edited — a mismatch makes the poll "fresh" and re-baselines |
| `rows_seen` | `unsignedInteger` default 0 — count of *mapped* data rows already read | every successful poll: `$seen + count($new)` | re-reading (and re-ringing) rows already processed |
| `baseline_rows` | `unsignedInteger` default 0 — the connect-time size of the sheet | every successful poll (unchanged unless fresh) | "the AI rang a lead I collected last year". Rows **below** it are synced into the book but never enrolled. Survives multi-poll ingestion of a huge sheet, which a bare `rows_seen` could not (`Triggers.php:362-368`) |
| `last_polled_at` | timestamp | every poll **including both failure paths** | hammering a broken sheet every minute; it is what the `poll_minutes` throttle compares |
| `last_error` | `string(500)`, cleared to `null` on success | every poll | "a sheet that silently stops importing must not look identical to a sheet with no new rows" (`Triggers.php:252-255`, migration `:31-34`) |

##### 4.4 One poll, in order

`syncNode(Workflow, WorkflowNode, bool $force = false)` (`Triggers.php:287-313`) is the unit of work — public so the two manual buttons run *exactly* what the sweep runs:

1. `firstOrCreate` the cursor.
2. `$poll = max(1, (int) $node->config('poll_minutes'))`. **Unless `$force`**, return `['imported'=>0,'started'=>0,'error'=>null]` when `last_polled_at > now()->subMinutes($poll)`.
3. `pollSheet()` inside `try`; `SheetReadException` → write `last_polled_at` + `last_error`, return the message; any other `Throwable` → `report()`, write the generic sentence.

`pollSheet()` (`:323-393`), step by step:

1. `SheetRef::fromUrl(config('sheet_url'))`, else throw.
2. Pick the reader from `access`; `service_account` additionally requires the verified connection.
3. `$table = $reader->read($ref, $access === 'link' ? null : ($worksheet ?: null))`.
4. `$rows = LeadRowMapper::mapTable($table)`.
5. `if ($rows === [] && count($table) >= 2) throw` the headings/phone message.
6. `$fingerprint = $ref->fingerprint($worksheet, $access)`; `$fresh = $cursor->fingerprint !== $fingerprint`.
7. `$seen = $fresh ? 0 : min($cursor->rows_seen, count($rows))` — **clamp on shrink**, so deleted rows cannot leave the cursor past the end of the sheet (`:358`).
8. `$baseline = $fresh ? (on_first_sync === 'enrol_all' ? 0 : count($rows)) : (int) $cursor->baseline_rows`.
9. `$new = array_slice($rows, $seen, 1000, true)` — **at most 1,000 rows per poll**, keys preserved as ABSOLUTE indexes because "the baseline is an index" (`:370-372`). The remainder waits for the next poll.
10. `$returning = $this->returningRows($workflow, array_slice($rows, 0, $seen, true))` (§4.6).
11. `importSheetRows($workflow, $node, array_slice($returning + $new, 0, 1000, true), $baseline, array_keys($returning))`. The union puts returning rows **first**, so they take the earliest pacing slots.
12. Write the cursor: `fingerprint`, `rows_seen = $seen + count($new)`, `baseline_rows`, `last_polled_at = now()`, `last_error = null`.

Return shape: `['imported' => int, 'started' => int, 'returned' => int]` on success (`returned` is added only here, `:381-382`), `['imported'=>0,'started'=>0,'error'=>string|null]` from `syncNode`.

##### 4.5 Row → lead

`LeadRowMapper::mapTable()` (`src/AppointmentEngine/Support/LeadRowMapper.php:43-69`):

- `count($lines) < 2` ⇒ `[]`.
- Headings = the first line, each `strtolower(trim())`, then folded through `HEADING_SYNONYMS` (`:26-34`):

| Canonical | Accepted headings |
|---|---|
| `name` | `full_name`, `fullname`, `full name`, `lead_name`, `姓名` |
| `phone` | `phone_number`, `phone number`, `phone no`, `mobile`, `mobile_number`, `contact`, `contact_number`, `contact number`, `whatsapp`, `电话`, `手机` |
| `email` | `email_address`, `e-mail` |
| `campaign` | `campaign_name` |
| `project` | `project_name` |

  The map exists for Meta: "a lead-ads form linked to a Google Sheet exports `full_name` / `phone_number` / `email` / `campaign_name` — headings nobody should have to rename before the sheet works." Meta's plumbing columns (`form_id`, `lead_status`, `platform`) are deliberately **not** mapped: "it describes the export, not the person."
- **No `phone` heading ⇒ `[]`** — "the one column a lead cannot exist without."
- Each data line becomes `[heading => cell]`, skipping columns whose heading is `''`, with `null` for missing trailing cells; rows whose every value trims to `''` are dropped; the survivors are `array_values`-ed, so **the index is a position in the mapped list, not the sheet's physical row number**.

`LeadRowMapper::e164()` (`:80-93`) — the single normalisation used by the sheet sweep, the Meta sweep, the CSV import and the manual add (`Triggers.php:642-645`, `LeadsController.php:364-367`):

1. `preg_replace('/\D+/','',$raw)` — strip everything that is not a digit.
2. `''` or `strlen < 8` ⇒ return `''` (not a phone).
3. Leading `0` ⇒ replace with `60` — "a Malaysian number typed without the country code ("0123456789") gets one: leads type their numbers the way they dial them."
4. Return `'+' . $digits`.

So `0123456789` and `+60123456789` collapse onto the same lead. There is **no** other country handling and no per-agency default.

`Triggers::lead()` (`:562-618`) is the one place a lead is created or reused by a trigger:

- Lookup: `Lead::where('group_id', $workflow->group_id)` + `where('phone', $phone)` when a phone exists, else `where('wa_contact_id', $waContactId)`; `latest('id')->first()`.
- **On an existing lead it fills only:** `name` (if the lead's is blank), `source_campaign` (if blank), `ae_project_id` (only when currently `null` and the workflow has one — "a lead the Projects hub cannot place is a lead nobody works: fill the project key where it is still blank, never move it"), and always `last_activity_at = now()`. It never touches `email`, `phone`, `project`, `source`, `stage` or `team_id`. Then `CrmIdentity::ensureLinked()` runs so pre-bridge rows converge on touch.
- **On a new lead** (inside `DB::transaction`): `group_id` = the workflow's, `name` = supplied name or `'Lead ' . substr($phone,-4)` or `'Hidden number'`, `phone`, `wa_contact_id`, `email`, `project` = the **row's** project column if present else `$workflow->project?->name`, `ae_project_id` = the workflow's (a sheet row naming another project still belongs to the workflow's deal), `source`, `source_campaign`, `stage = STAGE_NEW`, `last_activity_at = now()`. No `team_id` — sweep-born leads land unstaffed.
- `CrmIdentity::ensureLinked()` runs **outside** the transaction: "the linker manages its own writes, and a linker failure must never unwind the lead itself."

For the sheet, `importSheetRows` supplies `name`, `email` (only if `filter_var(..., FILTER_VALIDATE_EMAIL)` passes), `project` and `campaign`, all trimmed, `?: null` (`Triggers.php:428-436`).

##### 4.6 `returningRows` — the one thing that re-opens a read row

```php
// Triggers.php:374-379
// Rows already read whose person has since been DELETED from the book
// (owner, 2026-09-09): the row is still on the sheet, so they are still on
// the list — back they come as a new lead and, above the baseline, back
// into the flow. Read rows are otherwise never re-read.
```

`returningRows()` (`:464-500`):

1. Walk the **already-read** slice (`0 … $seen`), `e164()` each phone, keep the **first** index per phone.
2. Return `[]` if no phone survived.
3. In chunks of 500 phones: `Lead::where('group_id', $workflow->group_id)->whereIn('phone', $chunk)->pluck('phone')` → the set of phones still in the book. (`Lead` is a `SoftDeleteModel`, so a soft-deleted lead also counts as absent.)
4. Any phone **not** in that set yields `[$index => $row]`; `ksort()` before returning.

In `importSheetRows` these indexes arrive as `$startOver` and are exempted from the baseline check:

```php
// Triggers.php:441-445
if ($index < $baseline && ! isset($startOver[$index])) {
    continue;
}
```

`LeadEraser` is what makes this reachable (`src/AppointmentEngine/Services/LeadEraser.php:29-85`). Deleting a lead from the Leads screen (`LeadsController::destroy`, `:648-667`) hard-deletes the runtime rows so the person is genuinely new: it ends any running `WhatsappFlowRun` takeover via `WhatsappFlowRepository::endRun(..., ENDED_IDLE)` then deletes those rows, deletes `WorkflowRunLog` → `CloserHandoff` → `WorkflowRun` → `Call`, soft-deletes the `Appointment` rows, and finally `$lead->forceDelete()` — all in one `DB::transaction`. The reason is spelled out at `LeadEraser.php:20-27`: "the sheet reader matches rows to leads by phone, the enrol-once rule keys on (lead, workflow), and the chat 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's own person record and engagement are **not** touched. The flash message says so when the lead came from a sheet: "Still on the sheet? The next check brings them back in as a new lead and the flow runs again." (`LeadsController.php:665`).

##### 4.7 Exactly which rows are skipped, and why

| Stage | Rule | Effect |
|---|---|---|
| Read | SA mode reads only `A1:Z10000` | column 27+ and row 10,001+ are invisible |
| Map | fewer than 2 lines | whole table ⇒ `[]`, no error |
| Map | no heading folds to `phone` | whole table ⇒ `[]` ⇒ `SheetReadException` when the table had ≥2 lines |
| Map | every cell in the row trims to `''` | row dropped and the list re-indexed |
| Map | heading cell is `''` | that column dropped from every row |
| Cursor | absolute index `< rows_seen` | never re-read — **unless** the phone has no live lead (§4.6) |
| Cursor | absolute index `≥ rows_seen + 1000` | deferred to the next poll, not lost — **except with returning rows present**: `$new` is capped at 1000 (`Triggers.php:372`) but the list is re-capped AFTER the union, `array_slice($returning + $new, 0, 1000, true)` (`:381`), so R returning rows push R new rows to the next poll |
| Import | `e164(row.phone) === ''` (blank, non-numeric, or fewer than 8 digits) | **no lead at all**, not counted in `imported`, no warning anywhere |
| Import | phone already used earlier **in the same batch** (`$seen[$phone]`) | only the first row wins |
| Import | phone already has a lead in this `group_id` | lead **reused**, not duplicated; still counted in `imported`; only name/campaign/project-id/activity are touched |
| Enrol | absolute index `< baseline_rows` and not a returning row | lead is in the book but gets **no run** — the "never ring the backlog" rule |
| Enrol | any `ae_workflow_runs` row already exists for (lead, workflow), in **any** status | no second run, ever (`enrolOnce`) |
| Runner | workflow `is_active = false` | the run is created `pending` but `WorkflowRunner::advance()` returns without doing anything (`src/AppointmentEngine/Runner/WorkflowRunner.php:56-58`) |

##### 4.8 `enrol()` vs `enrolOnce()`

| | `enrol()` (`Triggers.php:620-640`) | `enrolOnce()` (`Triggers.php:514-539`) |
|---|---|---|
| Used by | WhatsApp keyword, CTWA, Meta | Google Sheet only |
| Blocks when | an **open** run exists: status in `pending`, `running`, `waiting` | **any** run exists for (lead, workflow), including `done`, `stopped`, `failed` |
| `resume_at` | not set (null ⇒ due now) | `now()->addMinutes(intdiv($started, $pace))` |
| Dispatch | `AdvanceWorkflowRun::dispatch($run->id)` immediately | only `if (! $startAt->isFuture())`; the rest wait for the minute sweep, "which is what makes the stagger real" |

`WorkflowRun` statuses are `pending` / `running` / `waiting` / `done` / `stopped` / `failed` (`src/AppointmentEngine/WorkflowRun.php:20-25`). The stricter sheet rule is justified in the code: "a pull source re-sees rows whenever deletions shift indexes, and 'the AI rang me again because a row above mine was tidied away' is not explainable to anyone" (`:507-510`).

##### 4.9 The pacing stagger

`$pace = max(1, (int) $node->config('pace_per_minute'))` (`Triggers.php:414`, 1–10 after `Plan::entryConfig`, default 2). The nth **successful** enrolment of this poll (0-based, counting only rows that actually got a run) is scheduled `intdiv($n, $pace)` minutes out. With the default, 500 pasted rows become a ~4-hour drip instead of 500 simultaneous calls. `RunWorkflows` only picks up a `pending` run whose `resume_at` is null or `<= now()` (`RunWorkflows.php:33-38`), and `WorkflowRunner::advance()` re-checks the same thing "belt and braces… so a stray direct dispatch cannot defeat it" (`WorkflowRunner.php:41-44`). The AiCall handler adds its own per-minute floor on top (`Triggers.php:400-404`).

##### 4.10 The two manual "poll now" surfaces

| Surface | Route | Behaviour |
|---|---|---|
| Project → Leads tab, "Connect Google Sheet" (`Projects/Partials/SheetLinkModal.vue`) | `PUT /manage/appointment-engine/projects/{id}/sheet` → `ProjectsController::updateSheet` (`:609-676`, `routes/web.php:1978`) | Validates `sheet_url` (≤500) + `access` in `link|service_account`; rejects a non-Sheets URL and rejects `service_account` without the connection; finds **the workflow whose `plan.entry === 'sheet'`** (never "the primary workflow" — the old version wired the sheet into the KEYWORD flow and the next save erased the URL, `:633-638`); merges the two keys into `plan.entry_config`, saves through `Plan::normalize`, recompiles, then runs `syncNode(..., force: true)` **in the request** so the admin watches the list arrive. Flash: "…{n} leads synced in, none of them will be called." plus a note if the workflow is paused. |
| Workflow tab → sheet drawer, "Check now" | `POST /manage/appointment-engine/ai-agent/workflows/{id}/sheet-check` → `WorkflowsController::checkSheet` (`:242-263`, `routes/web.php:2110`) | JSON. 422 "This flow does not start from a Google Sheet." when the workflow has no `trigger.google_sheet` node. Otherwise `syncNode(force: true)` and returns `imported`, `started`, `returned`, `error`, `is_active`, `sheetState` (`last_polled_at`, `rows_seen`, `baseline_rows`, `last_error`). It reads the **saved** link, so the button is disabled while the drawer's text is unsaved (`WorkflowTab.vue:1309, 1317`). |

Both bypass the `poll_minutes` throttle and both work on a **paused** workflow (leads land in the book; their runs wait for Go live). Read-only state for the drawer comes from `WorkflowsController::planProps()`'s `pickers.sheetState` (`:317-319`) and for the Leads tab strip from `ProjectsController::sheetCard()` (`:687-730`: `sheet_url`, `access`, `poll_minutes`, `workflow_active`, `rows_seen`, `last_polled_at`, `last_error`, plus `sa_ready` / `sa_email`).

---

#### 5. Manual add and CSV import

Both live on the Leads screen and both are synchronous — no cursor, no stagger.

**Manual add** — `LeadsController::store()` (`:122-173`). Validates `name` (required, ≤120), `phone` (required, ≤32), `email` (nullable email ≤120), `project` (uuid, optional), `workflow` (uuid, optional). `resolveWorkflow()` only resolves **active** workflows in scope (`:297-311`); `resolveProject()` resolves through `AeScope::applyShared` (`:696-705`). Then: normalise the phone, look for an existing lead **by phone within the viewer's scope** — "A number already in the book is the same person, not a second lead — the import rule, applied to a single row" (`:139-141`) — else create with `source = manual`, `team_id = $viewer?->admin?->team_id` ("a lead added by hand starts on the adder's own team"), `ae_project_id` + `project`, `created_by`. If a workflow was chosen, `enrol()` it. `CrmIdentity::ensureLinked()` runs after the transaction.

**CSV / spreadsheet import** — `LeadsController::import()` (`:185-253`). Validates `file` (required, `mimes:csv,txt,xlsx,xls`, `max:10240` KB) and optional `workflow`. `readRows()` (`:339-361`) uses `Maatwebsite\Excel::toArray([], $file)[0]` for `xlsx`/`xls` and `fgetcsv` otherwise, then the **same** `LeadRowMapper::mapTable()`. Empty result ⇒ warning "No rows found. The first line must be headings, and one of them must be "phone"." Per row: `e164`; `$phone === '' || isset($seen[$phone])` ⇒ `$skipped++` and continue; existing lead by phone ⇒ `$joined++` (fields untouched); else create with `source = import`, name fallback `'Lead ' . substr($phone,-4)`, validated email, `project` string only (**no** `ae_project_id`, **no** `team_id`), `created_by`. Optional `enrol()`, then `CrmIdentity::ensureLinked()`. The flash always reports the first counter and the rest only when non-zero (`if ($joined)`, `if ($skipped)`, `if ($workflow)` — `LeadsController.php:251-256`); a clean import into no workflow flashes just "Imported: 7 new leads.": "Imported: {n} new leads, {j} already in the book, {s} skipped (no phone, or a duplicate in the file) — all put into '{workflow}'."

`LeadsController::enrol()` (`:272-290`) is the **open-run** rule (like `Triggers::enrol`) but **does not dispatch** the job — the minute sweep picks the `pending` run up within 60 seconds.

---

#### 6. What creating a lead also triggers

`CrmIdentity::ensureLinked($lead)` runs on every intake path and is idempotent, never throws at its caller (`src/AppointmentEngine/Services/CrmIdentity.php:66-101`). It resolves the CRM person through the host's `LeadLinker` and stamps `ae_leads.lead_id`, then calls `CrmPipeline::ensureOpen($lead)`, which opens the `(lead, project)` Engagement once both bridges exist and respects a soft-deleted engagement (`src/AppointmentEngine/Services/CrmPipeline.php:52-80`).

The phone-trust ladder is per **source** (`CrmIdentity.php:50-57`) and is the reason the sheet is treated differently from WhatsApp:

| Source | Trust rung |
|---|---|
| `ctwa`, `whatsapp` | `LeadLinker::TRUST_NETWORK_VERIFIED` — possession proven by the network |
| `import`, `manual` | `TRUST_TYPED` — an admin typed or uploaded it |
| `sheet`, `meta` | `TRUST_UNVERIFIED` |

The sheet is unverified **on purpose**: "the sheet's ROWS are a live public feed (Meta lead ads → Sheet is the canonical wiring, and a link-shared sheet is world-readable at best). A public-typed phone must never resolve a person: a malicious row (victim's phone + attacker's email) would otherwise graft the email onto an email-less account — the exact takeover the ladder exists to stop." (adversarial review, 2026-09-06). `effectiveTrust()` (`:143-160`) upgrades a lead to network-verified once an `ae_calls` row for **the lead's current number** has `lead_spoke = true` — so sheet leads converge on their CRM person the moment contact is real, never before. A lead with a blank phone is never linked (`filled($lead->phone)` guard, `:68`).

---

#### Related files

- `src/AppointmentEngine/Runner/Triggers.php` — the whole intake sweep
- `src/AppointmentEngine/Support/LeadRowMapper.php` — heading synonyms + `e164()`
- `src/AppointmentEngine/SheetCursor.php`, `database/migrations/2026_09_01_100000_create_ae_sheet_cursors_table.php`, `database/migrations/2026_09_06_120000_add_baseline_rows_to_ae_sheet_cursors_table.php`
- `src/AppointmentEngine/Services/Sheets/` — `SheetRef`, `SheetReader`, `SharedLinkSheetReader`, `ServiceAccountSheetReader`, `SheetReadException`
- `src/AppointmentEngine/Services/LeadEraser.php`, `CrmIdentity.php`, `CrmPipeline.php`
- `src/AppointmentEngine/Support/Plan.php`, `Services/PlanCompiler.php`, `NodeCatalogue.php`, `Workflow.php` (`sheetProblems()`), `WorkflowNode.php` (`needs()`)
- `src/AppointmentEngine/Lead.php`, `WorkflowRun.php`
- `app/Http/Controllers/Manage/AppointmentEngine/LeadsController.php` (`store`, `import`, `enrol`, `readRows`, `destroy`), `ProjectsController.php` (`updateSheet`, `sheetCard`), `WorkflowsController.php` (`savePlan`, `checkSheet`, `planProps`)
- `app/Console/Commands/AppointmentEngine/RunWorkflows.php`, `app/Console/Kernel.php:209`, `app/Jobs/AppointmentEngine/AdvanceWorkflowRun.php`
- `resources/js/Pages/Manage/AppointmentEngine/Projects/Partials/WorkflowTab.vue` (sheet drawer + "Check now"), `SheetLinkModal.vue`
- `routes/web.php:1955-2124` (the whole `manage/appointment-engine` group)

---

## 2. Design — the plan document and its compiler

An Appointment-Engine workflow has **two truths, and only one of them is edited**.

| Truth | Where it lives | Written by | Read by |
|---|---|---|---|
| The **plan** — what the admin designed | `ae_workflows.plan` (JSON) | `WorkflowsController::savePlan` (`app/Http/Controllers/Manage/AppointmentEngine/WorkflowsController.php:202`), `WorkflowsController::store` (`:131`), `ProjectsController::updateSheet` (`app/Http/Controllers/Manage/AppointmentEngine/ProjectsController.php:655`) | the editor (`resources/js/Pages/Manage/AppointmentEngine/Projects/Partials/WorkflowTab.vue`), `PlanCompiler`, `ShadowFlow` |
| The **graph** — what the runner executes | `ae_workflow_nodes` + `ae_workflow_edges` | **only** `Services\PlanCompiler::compile()` | `Runner\WorkflowRunner`, `Workflow::problems()` |

There is no node/edge CRUD any more. The canvas that had it was retired on 2026-09-06; its routes are gone (`routes/web.php:2105-2108` — only `PUT …/plan` remains) and the controller keeps two empty section markers where they used to be (`WorkflowsController.php:413-415`). `src/AppointmentEngine/Services/PlanCompiler.php:64-66` deletes **every** edge then **every** node of the workflow and rebuilds the lot inside one `DB::transaction`, so hand-editing a node would be erased by the next save.

#### Tables

`database/migrations/2026_08_30_100000_create_ae_workflow_tables.php`, plus `database/migrations/2026_09_06_230000_add_plan_to_ae_workflows_table.php` for `plan`.

| `ae_workflows` | type | notes |
|---|---|---|
| `id` | bigIncrements | |
| `uuid` | uuid unique | public identifier; route binding (`HasUuid`) |
| `group_id` | unsignedBigInteger, nullable, indexed | the agency |
| `name` | string | |
| `description` | string, nullable | |
| `plan` | json, nullable | **added after `description`**; null = a pre-redesign workflow never rebuilt |
| `ae_project_id` | unsignedBigInteger, nullable, indexed | optional — an agency-wide catch-all flow is legitimate |
| `is_active` | boolean, default false, indexed | live or paused |
| `published_at` | timestamp, nullable | when it FIRST went live; survives pause/resume |
| `created_by` / `updated_by` / `deleted_by` | unsignedBigInteger, nullable | `RecordsBlame` |
| timestamps + `softDeletes` | | model extends `SoftDeleteModel` (`src/AppointmentEngine/Workflow.php:25-41`) |

| `ae_workflow_nodes` | type | notes |
|---|---|---|
| `id`, `uuid` | | |
| `ae_workflow_id` | indexed | |
| `type` | string(64) | a `NodeCatalogue` key |
| `name` | string, nullable | the person's own label; null → the type's name |
| `config` | json, nullable | cast `array` |
| `x`, `y` | integer, default 0 | canvas coordinates |

| `ae_workflow_edges` | type | notes |
|---|---|---|
| `id`, `uuid`, `ae_workflow_id` | | |
| `from_node_id`, `to_node_id` | indexed | |
| `branch` | string(16), default `'default'` | which way out |
| — | `unique(['from_node_id','branch'])` | **one connection per output.** A second edge off the same branch would fork a lead down two paths at once |

`WorkflowEdge` declares only three branch constants (`src/AppointmentEngine/WorkflowEdge.php:21-23`): `BRANCH_DEFAULT = 'default'`, `BRANCH_YES = 'yes'`, `BRANCH_NO = 'no'`. Every other branch string (`booked`, `objection`, `no_answer`, `gave_up`, `invalid`, `no_booking`) is a raw literal from the node type's `outputs` array — there is no constant for them.

---

### The plan document

`src/AppointmentEngine/Support/Plan.php` is the whole schema. `Plan::normalize()` (`:88-182`) is a **whitelist**: the stored document carries only the keys below, whatever the client posted, and every value is type-coerced and clamped there rather than trusted from validation.

#### Top level

| Key | Shape | Source |
|---|---|---|
| `entry` | one of `'sheet'`, `'ctwa'`, `'keyword'` (`Plan::ENTRIES`, `:25`); anything else → `'keyword'` | `:90` |
| `entry_config` | per-entry keys, see below | `:154`, `:213-235` |
| `steps` | ordered list, **max 20** (`array_slice(…, 0, 20)`, `:93`) | `:92-142` |
| `layout` | `{start, stop, booked, chat}`, each `{x:int, y:int}` or `null` | `:144-150` |
| `hours` | `{start:'H:i', end:'H:i'}` | `:157`, `:193-204` |
| `chat` | `{enabled, profile_id, goal, objective}` | `:158-166` |
| `booked` | see below | `:167-180` |

#### `entry_config`, by entry (`Plan.php:213-235`)

| `entry` | keys | coercion |
|---|---|---|
| `sheet` | `access` | `'service_account'` if exactly that, else `'link'` |
| | `sheet_url` | string |
| | `worksheet` | string (blank = first tab) |
| | `poll_minutes` | int clamped **1–1440**, default 15 |
| | `on_first_sync` | `'enrol_all'` if exactly that, else `'new_only'` |
| | `pace_per_minute` | int clamped **1–10**, default 2 |
| `ctwa` | `channel_id` | int or null |
| | `ad_id` | string (comma-separate several; blank = every CTWA ad) |
| | `fallback_keywords` | string |
| `keyword` (default arm) | `channel_id` | int or null |
| | `keywords` | string |
| | `match` | one of `contains` / `starts_with` / `exact`, default `contains` |

`Plan::ENTRY_TYPES` (`:53-57`) maps the entry to the trigger node type the compiler builds: `sheet → trigger.google_sheet`, `ctwa → trigger.ctwa`, `keyword → trigger.whatsapp_keyword`.

#### `steps[]`

Every step, whatever its kind, carries `kind`, `pos` and `wait`:

| Key | Shape |
|---|---|
| `kind` | `'message'` if exactly `'message'`, otherwise **`'call'`** (`:94` — call is the fallback, not message) |
| `pos` | `{x:int, y:int}` or `null` — canvas position, **presentation only, the compiler never reads it** (`:105-107`) |
| `wait.value` | int clamped **0–999** (`:96`) |
| `wait.unit` | `minutes` / `hours` / `days`, default `hours` (`:97`) |
| `wait.business_hours` | bool, **default true** (`:100`). "Only count 9am–9pm by default — a 3-hour wait started at 8pm should land mid-morning, not at 11pm" |

**`kind: 'call'`** (`:109-131`)

| Key | Shape |
|---|---|
| `profile_id` | `AiCallProfile` id (int) or null |
| `max_attempts` | int clamped **1–5**, default 3 |
| `retry_gaps` | list of `{value:int ≤999, unit: minutes\|hours\|days}`. A **bare integer** (the original schema) is read as *hours*, so old plans re-save cleanly. **`0` is a real rung** — retry on the next tick (`:126`); a negative or non-numeric rung is dropped |

**`kind: 'message'`** (`:132-141`)

| Key | Shape |
|---|---|
| `channel_id` | WhatsApp channel id (int) or null |
| `mode` | `'text'` if exactly that, else `'template'` |
| `template_id` | int or null |
| `variables` | list of strings |
| `body` | string |

The editor overwrites `mode` on save from the channel's own capability rather than treating it as a choice — a Cloud-API number gets `template`, a bridge number `text` (`WorkflowTab.vue:129-133`).

#### `hours` (`Plan.php:193-204`)

Both values must match `/^([01]\d|2[0-3]):[0-5]\d$/` **and** `start < end` (a same-day window). Anything malformed falls back to `09:00`–`21:00` "rather than compiling a wait that can never open."

#### `chat` (`Plan.php:158-166`)

| Key | Shape |
|---|---|
| `enabled` | bool, default false |
| `profile_id` | `WhatsappAiProfile` id (int) or null |
| `goal` | a key of `CHAT_GOALS`, default `'appointment'` |
| `objective` | trimmed, **max 500 chars**; when empty it falls back to the goal's canonical text — "a flow that already holds a hand-tuned objective keeps it (normalize never overwrites a non-empty one)" |

##### `Plan::CHAT_GOALS` (`Plan.php:45-50`)

| Key | `name` | `objective` |
|---|---|---|
| `appointment` | `Schedule an appointment` | *"Help the buyer with whatever they ask about the project — answer their questions fully and helpfully first. Your end goal is to schedule a viewing appointment (showroom or Zoom), but never be pushy: only suggest a viewing naturally when the buyer shows real interest, and settle a concrete date and time when they agree."* |

One goal, deliberately: "the only objective tested end to end (a captured booking creates the appointment and ends the takeover)". The editor renders the single card plus a disabled "More goals · Soon" tile (`WorkflowTab.vue:1576-1593`).

#### `booked` (`Plan.php:167-180`)

| Key | Shape |
|---|---|
| `channel_id` | int or null |
| `confirm_template_id` | int or null |
| `variables` | list of strings; `defaults()` seeds `['{name}','{date}','{time}','{venue}','{venue_link}']` |
| `reminder_hours_before` | int clamped **0–168**, default 16 (0 = no reminder) |
| `reminder_template_id` | int or null |
| `assign` | bool, default true — whether the closer-rotation node is compiled at all |
| `assignment` | see below |
| `showroom_address` | trimmed, max **200** chars |
| `showroom_note` | trimmed, max **300** chars |
| `showroom_map_url` | trimmed, max **500** chars |

The three showroom fields are "where a showroom appointment happens — printed into the confirm/remind templates as `{venue}`/`{venue_link}`". `Runner\Handlers\EndBooked.php:43-57` builds `{venue}` as `showroom_address ?: 'Our showroom'` plus `' — ' . showroom_note` when filled, and `{venue_link}` as `showroom_map_url` falling back to the appointment date; for a Zoom booking both come from the meeting instead.

##### `booked.assignment` — the closer rotation (`Plan::normalizeAssignment`, `:243-253`)

| Key | Allowed | Default |
|---|---|---|
| `pool_type` | `project` / `group` / `team` / `admins` | `project` |
| `team_id` | int > 0, else null | `null` |
| `admin_ids` | int list, deduped, zeros filtered | `[]` |
| `strategy` | `round_robin` / `balance` | `round_robin` |
| `require_zoom` | bool | `true` |
| `accept_minutes` | int clamped **0–240** | `15` |

`Plan::ASSIGNMENT_DEFAULTS` (`:40-43`) is the same set. Its docblock states the compatibility rule: **a plan saved before 2026-09-08 has no `assignment` and reads these defaults** — round-robin over everyone assignable, 15 minutes. `Services\CloserRotation::DEFAULTS` (`:88-97`) matches, with two extra keys (`notify => true`, `admin_id => null`), and `configFromNode()` (`:118-137`) back-fills `strategy` from a legacy node's `mode` when `strategy` is absent.

#### `Plan::defaults($entry)` (`Plan.php:67-79`)

A fresh workflow is born with **one** call step — "a first-time reader should meet ONE step, not a wall of them": `wait {value: 0, unit: 'minutes'}`, `profile_id: null`, `max_attempts: 3`, `retry_gaps: [{4,hours},{24,hours}]`; `hours` 09:00–21:00; `chat` disabled with the appointment goal + its canonical objective; the `booked` block above with `assign: true`.

`defaults()` output is stored **raw**, not through `normalize()` (`WorkflowsController.php:131-135`), so a never-saved plan has no `layout` key, no per-step `pos`, and no `wait.business_hours`. Reads always run it back through `Plan::normalize()` (`PlanCompiler.php:47`, `WorkflowsController.php:285`, `ShadowFlow.php:40`), which fills those in without rewriting the row.

---

### The compiler

`Services\PlanCompiler::compile(Workflow $workflow)` — one static entry point, three phases.

**1. Retract knowledge (outside the transaction, fail-soft).** `:52-60` walks the workflow's existing `action.ai_call` nodes and calls `ProfileKnowledge::retract($profileId, $node)` for each. It is outside the transaction because it talks to Retell, and wrapped in `try/catch (\Throwable) { report($e); }` because "a knowledge hiccup must never block a save." `ProfileKnowledge::retract` deletes `AiCallKbEntry` rows titled `Workflow step {node uuid}%` and dispatches `SyncProfileKnowledge` only if something was removed (`src/AppointmentEngine/Services/ProfileKnowledge.php:73-88`).

**2. Rebuild the graph (one transaction).** `:64-202`. Edges are deleted before nodes. Every node is created through `$make(type, config, x, y, name)`, which merges the given config **over** `NodeCatalogue::defaults($type)` (`:73`) — so a field added to a node type later reaches graphs compiled before it existed.

**3. Aftermath.** `ShadowFlow::sync($workflow->refresh())` (`:206`), then `ProfileKnowledge::push()` for each new call node (`:210-218`, same fail-soft `try/catch` + `report`).

#### Coordinates

| Constant | Value | Meaning |
|---|---|---|
| `X_MAIN` | 400 | the main column: trigger, waits, calls, outcomes, messages, the final stop |
| `X_BOOKED` | 40 | the booked chain (assign at y=60, confirm at y=230) |
| `X_INVALID` | 780 | the dead-number stop (y=60) |
| `STEP_Y` | 170 | vertical step between rows |

"Coordinates are laid out top-to-bottom only for the database's benefit — no page renders the graph any more." The **editor's** layout is the plan's `layout`/`pos`, not these.

#### The fixed branching rules

The compiler's docblock (`:13-31`) states them, and they are the whole reason the canvas was replaced:

1. **Any step that produces an appointment → the booked chain** (assign the closer when asked, then confirm + remind) and the flow ends.
2. **A message step re-checks first**: woken for the next nurture but the chat brain already booked it → straight to the booked chain, nothing sent.
3. **An AI call that reaches a dead number → stop, lead marked lost.**
4. **A call missed after its own retries, or an objection → simply the next step in the list.**
5. **The list running dry → stop WITHOUT marking lost** (a person takes over).

#### The three fixed chains (built before the step list)

| Node | Type | Name | Config written |
|---|---|---|---|
| `$confirmed` | `end.booked` | `Appointment confirmed — confirm & remind` | `channel_id`, `confirm_template_id`, `variables`, `reminder_hours_before`, `reminder_template_id`, `showroom_address`, `showroom_note`, `showroom_map_url` (`:89-98`) |
| `$assign` (only when `booked.assign`) | `action.assign_agent` | `Assign the closer` | `mode` **and** `strategy` (both = `assignment.strategy`), `pool_type`, `team_id`, `admin_ids`, `require_zoom`, `accept_minutes`, `notify => true` (`:107-116`) |
| `$invalid` | `end.stop` | `Dead number — stop, mark lost` | `mark_lost => true` (`:121`) |

`$bookedEntry` is `$assign` when assignment is on (with `assign --default--> confirmed`), otherwise `$confirmed` itself (`:100`, `:118`).

#### Per-step expansion (`:131-195`)

`$tails` starts as `[[trigger, 'default']]` and holds the (node, branch) pairs waiting to be wired into whatever comes next. Each iteration:

- **`wait.value > 0`** → an `action.wait` node with `amount`, `unit`, `business_hours`, and **`window_start`/`window_end` stamped from the plan's `hours`** (`:141-149`). All current tails wire into it; it becomes the sole tail.
- **`kind === 'call'`** → an `action.ai_call` named `Step {i+1} — AI calls them`, config `profile_id`, `max_attempts`, `retry_gaps`, `retry_mode => 'auto'`, and `call_start`/`call_end` **= the plan's business hours**. Then a `condition.call_outcome` named `How did the call go?` with `booked_means => 'appointment'`. Wiring: `call --default--> outcome`, `outcome --booked--> $bookedEntry`, `outcome --invalid--> $invalid`. New tails: **`objection`, `no_answer`, `gave_up`** — all three treated identically, i.e. "the next step in the list."
- **`kind === 'message'`** → a `condition.booked` named `Already booked?` with `since => 'run'`, wired from the tails, with `check --yes--> $bookedEntry`. Then an `action.whatsapp` named `Step {i+1} — WhatsApp them` (`channel_id`, `mode`, `template_id`, `variables`, `body`), wired `check --no--> message`. New tail: `message.default`.

Finally an `end.stop` with `mark_lost => false` named `List done — a person takes over`, receiving every remaining tail (`:197-201`).

#### Worked example — one call step, then one message step

Plan: `entry: 'ctwa'`, `booked.assign: true`, `steps: [ {call, wait 0}, {message, wait {3, hours}} ]`.

```
             (400,60)  Click-to-WhatsApp ad            trigger.ctwa
                            │ default
             (400,230) Step 1 — AI calls them          action.ai_call
                            │ default
             (400,400) How did the call go?            condition.call_outcome
        ┌───────────────┼──────────────┬───────────────────┐
   booked│          invalid│      objection│ no_answer│ gave_up
        ▼               ▼              ▼ (all three)
 (40,60) Assign     (780,60) Dead   (400,570) Wait 3h    action.wait
  the closer         number —          │ default          window_start/end = plan hours
  action.assign_     stop, mark        ▼
  agent              lost           (400,740) Already booked?   condition.booked (since=run)
        │ default    end.stop          │ yes ──────────────► Assign the closer
        ▼            (mark_lost=true)  │ no
 (40,230) Appointment confirmed        ▼
  — confirm & remind             (400,910) Step 2 — WhatsApp them   action.whatsapp
  end.booked                           │ default
                                       ▼
                                 (400,1080) List done — a person takes over
                                            end.stop (mark_lost=false)
```

10 nodes, 12 edges, in creation order:

| # | from | branch | to |
|---|---|---|---|
| 1 | Assign the closer | `default` | Appointment confirmed — confirm & remind |
| 2 | Click-to-WhatsApp ad (trigger) | `default` | Step 1 — AI calls them |
| 3 | Step 1 — AI calls them | `default` | How did the call go? |
| 4 | How did the call go? | `booked` | Assign the closer |
| 5 | How did the call go? | `invalid` | Dead number — stop, mark lost |
| 6 | How did the call go? | `objection` | Wait (3h) |
| 7 | How did the call go? | `no_answer` | Wait (3h) |
| 8 | How did the call go? | `gave_up` | Wait (3h) |
| 9 | Wait (3h) | `default` | Already booked? |
| 10 | Already booked? | `yes` | Assign the closer |
| 11 | Already booked? | `no` | Step 2 — WhatsApp them |
| 12 | Step 2 — WhatsApp them | `default` | List done — a person takes over |

Observed shape, live: workflow #54 (`entry: sheet`, one call step, `assign: true`, wait 0) compiles to exactly **7 nodes / 8 edges** — `end.booked`, `action.assign_agent`, `end.stop`(invalid), `trigger.google_sheet`, `action.ai_call`, `condition.call_outcome`, `end.stop`(list done).

The nodes the compiler creates carry an explicit `name` except the **trigger** and the **wait**, which are created with `$name = null` and therefore display the catalogue's own name (`WorkflowNode::displayName()`, `src/AppointmentEngine/WorkflowNode.php:87-90`) — "Google Sheet row", "Click-to-WhatsApp ad", "WhatsApp keyword", "Wait". That is the label the publish gate quotes.

#### Config keys the compiler writes that are NOT in the type's field schema

These have no `fields` entry, so `NodeCatalogue::defaults()` does not seed them and `WorkflowNode::config()` returns `null` when a pre-redesign node lacks them. Each consumer therefore carries its own fallback.

| Node type | Extra keys | Consumer |
|---|---|---|
| `action.wait` | `window_start`, `window_end` | `Runner\Handlers\Wait.php:29-33` — falls back to `'09:00'`/`'21:00'` |
| `action.assign_agent` | `strategy`, `admin_ids` | `Services\CloserRotation::configFromNode` (`:118-137`) |
| `end.booked` | `showroom_address`, `showroom_note`, `showroom_map_url` | `Runner\Handlers\EndBooked.php:43-57` |

#### The chat section has no node

`plan.chat` compiles to **nothing in the graph**. Its runtime is a system-owned host flow: `Services\ShadowFlow::sync()` (`:37-89`) creates/updates one hidden `WhatsappFlow` per workflow, marked `settings.ae_workflow = {workflow uuid}`, `flow_type = TYPE_TIME_BASED (1)`, `trigger_type = TRIGGER_CAMPAIGN (3)`, `ai_profile_id = chat.profile_id`, `objective = chat.objective` (falling back to `"Book a property viewing appointment for {project} — agree a concrete date and time."`). The channel is the **first** non-null of: any step's `channel_id`, then `booked.channel_id`, then `entry_config.channel_id` (`:43-49`). The shadow is `STATUS_ACTIVE` only while the workflow `is_active`; it is set `STATUS_INACTIVE` — **never deleted** — when chat is off, because "its run history is the Chats tab's memory." The whole method is one `try/catch(\Throwable) → report()` returning null.

`ShadowFlow::sync` is called after every compile (`PlanCompiler.php:206`) and on **both** arms of the publish toggle (`WorkflowsController.php:385`, `:406`).

---

### `NodeCatalogue` — the type registry

`src/AppointmentEngine/NodeCatalogue.php`. "ONE REGISTRY, READ BY EVERYTHING: the palette a person drags from, the settings panel, the server-side validation, the canvas styling and the runner." A node row stores only type + name + config + position; everything about what the step *is* is read from here at read time, so "a fix to a node type reaches the workflows already using it" (`WorkflowNode.php:9-18`).

**Groups** (`:27-30`, semantics at `:14-19`): `trigger` (starts a workflow — exactly one, nothing connects into it), `action` (one way out), `condition` (asks something), `end` (nothing connects out).
**Stages** (`:37-44`): `lead_gen` "Lead generation", `appointment` "Appointment", `closing` "Closing".
**Kinds** (`:51-67`): `trigger`, `ai_action`, `ai_analysis`, `human`, `condition`, `system`, `end` — "the stage says WHEN in the journey; the kind says WHO does it."

#### All 25 types

| Type | group | stage | kind | needs | outputs |
|---|---|---|---|---|---|
| `trigger.meta_lead_form` | trigger | lead_gen | trigger | `meta` | `default` |
| `trigger.whatsapp_keyword` | trigger | lead_gen | trigger | `whatsapp_channel` | `default` |
| `trigger.ctwa` | trigger | lead_gen | trigger | `whatsapp_channel` | `default` |
| `trigger.manual` | trigger | lead_gen | trigger | — | `default` |
| `trigger.google_sheet` | trigger | lead_gen | trigger | *null in the catalogue* | `default` |
| `action.assign_agent` | action | lead_gen | human | — | `default` |
| `action.whatsapp` | action | appointment | ai_action | `whatsapp_channel` | `default` |
| `action.email` | action | appointment | ai_action | — | `default` |
| `action.ai_call` | action | appointment | ai_action | `caller` | `default` |
| `action.whatsapp_ai` | action | appointment | ai_action | `whatsapp_channel` | `booked`, `no_booking` |
| `action.wait` | action | appointment | system | — | `default` |
| `action.set_stage` | action | appointment | system | — | `default` |
| `action.notify_team` | action | appointment | system | — | `default` |
| `condition.answered` | condition | appointment | condition | — | `yes`, `no` |
| `condition.call_outcome` | condition | appointment | condition | — | `booked`, `objection`, `no_answer`, `gave_up`, `invalid` |
| `condition.booked` | condition | appointment | condition | — | `yes`, `no` |
| `condition.replied` | condition | appointment | condition | — | `yes`, `no` |
| `condition.field` | condition | appointment | ai_analysis | — | `yes`, `no` |
| `ai.qualify` | **action** | appointment | ai_analysis | — | `default` |
| `action.zoom_invite` | action | closing | ai_action | `whatsapp_channel` | `default` |
| `human.record_outcome` | **action** | closing | human | — | `default` |
| `condition.attended` | condition | closing | condition | — | `yes`, `no` |
| `end.closed` | end | closing | end | — | *(none)* |
| `end.booked` | **action** (+ `may_end: true`) | appointment | ai_action | `whatsapp_channel` | `default` |
| `end.stop` | end | closing | end | — | *(none)* |

`needs` values are `Connection` provider constants (`src/AppointmentEngine/Connection.php:26-28`): `META = 'meta'`, `CALLER = 'caller'`, `SHEETS = 'google_sheets'` — plus the non-Connection literal `'whatsapp_channel'`, which is a host WhatsApp number rather than an AE connection.

Two deliberate oddities:

- **`trigger.google_sheet` declares `needs => null`** even though a private sheet needs the Google connection: "Conditional… Static null here so the palette shows no false amber." `WorkflowNode::needs()` (`:69-76`) answers **per saved config** — `Connection::SHEETS` when `access === 'service_account'`, else null. It is the one type whose need is per node, not per type.
- **`end.booked` is kept under its `end.` key but is an ACTION** (`:672-676`): "booking is a step, with a confirmation and a reminder, and the closing stage follows it. `may_end` lets a flow that stops here pass the publish check." It is the only type carrying `may_end`.

`condition.call_outcome` has five outputs on purpose (`:511-513`): "Routing an invalid number and a hot lead into the same WhatsApp fallback wastes the sequence on people who can never convert."

`action.whatsapp_ai` has two outputs "like a condition… the chat either lands an appointment or it does not."

#### Fields, per type — key → default

`NodeCatalogue::defaults($type)` (`:764-777`) is every field's `default`, **skipping `type: 'locked'` fields** ("a fact about the system, not a stored value. A copy in the database is a copy that can go stale").

| Type | `defaults()` |
|---|---|
| `trigger.meta_lead_form` | `form_id: null` |
| `trigger.whatsapp_keyword` | `channel_id: null`, `keywords: null` **(required)**, `match: 'contains'` |
| `trigger.ctwa` | `channel_id: null`, `ad_id: null`, `fallback_keywords: null` |
| `trigger.manual` | *(no fields)* |
| `trigger.google_sheet` | `access: 'link'`, `sheet_url: null` **(required)**, `worksheet: null`, `poll_minutes: 15` (1–1440), `on_first_sync: 'new_only'`, `pace_per_minute: 2` (1–10) |
| `action.assign_agent` | `mode: 'round_robin'`, `admin_id: null`, `pool_type: 'project'`, `team_id: null`, `require_zoom: true`, `accept_minutes: 15`, `notify: true` |
| `action.whatsapp` | `channel_id: null` **(required)**, `mode: 'template'`, `template_id: null`, `variables: []`, `body: null` |
| `action.email` | `subject: null` **(required)**, `body: null` **(required)** |
| `action.ai_call` | `profile_id: null` **(required)**, `script_id: null`, `knowledge_id: null`, `call_start: '09:00'`, `call_end: '21:00'`, `max_attempts: 3` (1–5), `retry_gaps: [24, 48]`, `retry_mode: 'auto'`, `default_meeting_type: 'showroom'` — plus the **locked** `discloses_ai` |
| `action.whatsapp_ai` | `channel_id: null` **(required)**, `flow_id: null` **(required)**, `default_meeting_type: 'showroom'`, `give_up_hours: 48` (1–336), `knowledge_id: null` |
| `action.wait` | `amount: 1` (1–999), `unit: 'hours'`, `business_hours: true` |
| `action.set_stage` | `stage: '2'` (= `Lead::STAGE_CALLED`) |
| `action.notify_team` | `message: null` **(required)** |
| `condition.answered` | `means: 'spoke'` (or `connected`) |
| `condition.call_outcome` | `booked_means: 'appointment'` (or `outcome`) |
| `condition.booked` | `since: 'run'` (or `any`) |
| `condition.replied` | `within_hours: 24` (1–168) |
| `condition.field` | `field: 'intent'` (or `journey_stage`, `budget_amount`), `operator: 'is'` (or `at_least`, `at_most`), `value: null` **(required)** |
| `ai.qualify` | `overwrite: false` |
| `action.zoom_invite` | `duration: 30` (15–120) + the shared WhatsApp-send fields (`channel_id` **required**, `mode`, `template_id`, `variables`, `body`) |
| `human.record_outcome` | `remind_after_hours: 24` (1–168) |
| `condition.attended` | `unrecorded: 'wait'` (or `no`) |
| `end.closed` | *(no fields)* |
| `end.booked` | `channel_id: null` **(required)**, `variables: ['{name}','{link}','{project}']`, `confirm_template_id: null`, `reminder_hours_before: 16` (0–168), `reminder_template_id: null` |
| `end.stop` | `mark_lost: false` |

Two shared mechanics:

- **`whatsappSendFields()`** (`:91-118`) is the send schema reused by `action.whatsapp` and `action.zoom_invite`. It is template-first on purpose: "Outside 24 hours since the lead's last message, Meta delivers ONLY a pre-approved template — a free-text send comes back **131047** and nothing arrives… a workflow step that let someone type a message and would fail silently two days into a nurture sequence is the exact 'control that looks like it works' the spec forbids."
- **Deferred option lists.** A field may name `options: 'lead_stages'` instead of hard-coding them; `NodeCatalogue::fields()` (`:745-758`) resolves it from `Lead::STAGES` (`src/AppointmentEngine/Lead.php:45-52`: 1 New, 2 AI called, 3 Answered, 4 Appointment, 5 Showed up, 6 Lost) "so a stage added to the Lead model appears in the builder without this catalogue being edited into agreement with it."
- **`discloses_ai` is `type: 'locked'`** — value "Always, in the opening line", with the reason stored on the field: "Required by the voice provider's terms and by consumer law in several markets this sells into. It is not ours to switch off."

`NodeCatalogue::palette()` (`:789-812`) groups types stage → kind → type and drops empty kind buckets.

**Every type must have a handler.** `Runner\HandlerRegistry::MAP` (`src/AppointmentEngine/Runner/HandlerRegistry.php:13-39`) covers all 25; `HandlerRegistry::missing()` diffs the two lists. `end.closed` and `end.stop` share `EndStop`; all five triggers share `Passthrough`.

---

### `WorkflowTemplates` — the pre-redesign starter graphs

> ⚠️ **`WorkflowTemplates` is dead code.** The string `WorkflowTemplates` appears nowhere in `app/`, `src/`, `routes/`, `resources/`, `tests/` or `database/` outside its own file — `apply()` has no caller since the plan editor replaced the preset chooser. Treat it as history, not as a code path.

`src/AppointmentEngine/WorkflowTemplates.php` holds six authored graphs — `full_journey`, `ctwa_ai_appointment`, `ctwa_decision`, `google_sheet_calls`, `meta_form`, `closing_only` — each `{name, blurb, stages, nodes[{key,type,name,col,row,config}], edges[[from,to,branch?]]}`. `apply()` (`:298-330`) copies one into a workflow in a transaction, merging the template's config **over** `NodeCatalogue::defaults()` so "a template only has to say what it changes." Positions are **authored, not laid out by an algorithm**, and are transposed on write: `x = 60 + row * COL(260)`, `y = 60 + col * ROW(170)` — "the canvas reads top-to-bottom (flowchart grammar, 2026-09-06), so a template's `col` (its position ALONG the flow) becomes the Y axis."

These templates are the only remaining producer of `action.whatsapp_ai`, `condition.answered`, `condition.replied`, `ai.qualify`, `human.record_outcome`, `condition.attended`, `end.closed`, `trigger.manual` and `trigger.meta_lead_form` nodes — `PlanCompiler` emits none of them.

---

### The publish gate

`WorkflowsController::publish()` (`:376-411`), route `POST /manage/appointment-engine/ai-agent/workflows/{id}/publish` (`routes/web.php:2103`). Order matters:

1. Load the workflow through `AeScope` with `nodes` + `edges` eager-loaded.
2. **If already active → pause immediately, with no checks.** `is_active = false`, `ShadowFlow::sync()`, flash *"'{name}' is paused. Leads already in it stop where they are."*
3. Otherwise run `$workflow->problems()`. **If non-empty → refuse, flashing only `$problems[0]`** as a warning, and return. (The full list is on screen already: `planProps` sends `problems` on every load, `WorkflowsController.php:286`, rendered as an amber panel and used to disable the Go-live button, `WorkflowTab.vue:894`, `:909-912`.)
4. Set `is_active = true` and `published_at = published_at ?? now()` — first-live is never overwritten by a resume.
5. `ShadowFlow::sync()`, flash *"'{name}' is live. New leads will go through it."*

`Workflow::problems()` (`src/AppointmentEngine/Workflow.php:72-141`) is checked on demand, not enforced edit-by-edit: "A half-built graph is the normal state of a canvas somebody is working on… The check is what stands between a half-built graph and a LIVE one." It returns `array_values(array_unique($problems))`, so identical sentences collapse.

#### Order of checks inside `problems()`

1. Trigger count (`:78-84`).
2. `nodes->count() < 2` (`:86-88`).
3. `$hasIncoming` = every `to_node_id`, flipped (`:90`).
4. AI-call profiles are fetched **once, batched**, `whereIn('id', …)->where('group_id', $this->group_id)` — "mirroring the runtime's exact scoping — the gate must test what the runtime will see" (`:94-98`).
5. Then, per node, in this order: unreachable → missing branch outputs → `missingConfig()` → `sheetProblems()` → `aiCallProblems()` → `whatsappAiProblems()`.

#### Every sentence `problems()` can produce

`{label}` is `WorkflowNode::displayName()` — the node's own `name`, else the catalogue type's `name`.

| Sentence | Trigger |
|---|---|
| `Nothing starts this workflow. Add a trigger.` | no node whose `group()` is `trigger` |
| `There is more than one trigger. A lead can only enter one way.` | ≥2 trigger-group nodes |
| `The workflow does nothing yet — add a step after the trigger.` | fewer than 2 nodes in total |
| `"{label}" is not connected to anything, so it will never run.` | a non-trigger node with no inbound edge — "the commonest way a workflow silently does nothing" |
| `"{label}" has nothing after it.` | no edge leaves the node on its `default` branch — **skipped** when the spec sets `may_end` (only `end.booked`) |
| `"{label}" has no {branch} path.` | no edge on a non-`default` output, e.g. *has no booked path*, *has no objection path*, *has no no_answer path*, *has no gave_up path*, *has no invalid path*, *has no yes path*, *has no no path*, *has no no_booking path* |
| `"{label}" needs {missing}.` | one sentence per entry from `WorkflowNode::missingConfig()` (below) |

**`missingConfig()`** (`WorkflowNode.php:124-160`) yields, in order: `strtolower($field['label'])` for every `required` field that is `blank()`; then the WhatsApp-send rule; then the `end.booked` rule.

| Node type | possible `{missing}` values |
|---|---|
| `trigger.whatsapp_keyword` | `keywords` |
| `trigger.google_sheet` | `sheet url` |
| `action.whatsapp`, `action.zoom_invite` | `send from`, plus `a message` (mode `text`, blank body) **or** `an approved template` (mode ≠ `text`, blank template) |
| `action.email` | `subject`, `message` |
| `action.ai_call` | `ai profile` |
| `action.whatsapp_ai` | `chat from`, `conversation flow` |
| `action.notify_team` | `what to say` |
| `condition.field` | `this` |
| `end.booked` | `confirm and remind from`, `a confirmation template`, `a reminder template (or set the reminder to 0)` (only when `reminder_hours_before > 0`) |

The send rule keys off the presence of a `template_id` **field**, not a `mode` field — explicitly, "A WhatsApp-sending step is one that carries a template picker — NOT one that merely has a `mode` field; 'Assign an agent' has a mode too" (`:137-139`).

**`sheetProblems()`** (`Workflow.php:250-277`) — only for `trigger.google_sheet`. These "BLOCK going live where the WhatsApp connection badge merely warns, deliberately: a sheet workflow with no way to read its sheet does nothing, silently, forever — there is no lead who complains."

| Sentence | Trigger |
|---|---|
| `"{label}" has a sheet link that does not look like a Google Sheets URL.` | `sheet_url` non-empty **and** `SheetRef::fromUrl($url) === null`. (A blank URL is already covered by `needs sheet url`.) |
| `"{label}" reads a private sheet, but Google Sheets is not connected — switch the step to a link-shared sheet.` | `access === 'service_account'` and no `Connection` row for this `group_id` with `provider = google_sheets` and `verified_at` not null |

**`aiCallProblems()`** (`Workflow.php:155-185`) — only for `action.ai_call` **with a `profile_id` set** (an empty one is already `needs ai profile`). "These BLOCK deliberately: an AI call whose profile cannot capture an appointment time books nothing, silently, forever — the agency believes the automation books, and nobody complains because nobody knows."

| Sentence | Trigger |
|---|---|
| `"{label}"'s AI profile no longer exists. Pick another under AI Agent → AI Profiles.` | the id is not in the batched, group-scoped `AiCallProfile` set — i.e. deleted **or belonging to another agency** |
| `"{label}"'s AI profile ({profile name}) is inactive or not synced to the voice provider yet.` | `! $profile->isCallable()` — `status !== STATUS_ACTIVE`, or `retell_agent_id === null`, or `synced_at === null` (`src/VoiceAgent/AiCallProfile.php:628-641`) |
| `"{label}"'s AI profile ({profile name}) does not extract an appointment time, so a slot agreed on the phone would never be booked. Pick the "Schedule an appointment" goal on the profile, or add a field named "appointment_time" to its post-call extraction (AI Agent → AI Profiles).` | `objectiveGoal() !== AiCallProfile::GOAL_APPOINTMENT ('appointment')` **and** none of `AiCall::APPOINTMENT_KEYS` appears among the profile's `canonicalExtraction()` field names |

`AiCall::APPOINTMENT_KEYS` is `AppointmentObjective::TIME_ALIASES` = `['appointment_time', 'appointment_datetime', 'appointment']` (`src/VoiceAgent/Objectives/AppointmentObjective.php:39`, aliased at `src/AppointmentEngine/Runner/Handlers/AiCall.php:47`). The goal short-circuit is dated in the code: "A profile built for the appointment GOAL extracts the time by construction — the goal appends the field at Sync — so only a goal-less profile has to be read for the name (2026-09-08)."

**`whatsappAiProblems()`** (`Workflow.php:195-237`) — only for `action.whatsapp_ai` **with a `flow_id` set**. `PlanCompiler` never emits this type, so these fire only on template-seeded or pre-redesign graphs.

| Sentence | Trigger |
|---|---|
| `"{label}"'s conversation flow is missing or switched off. Pick a live one under Messages → Flows.` | flow not found, or `! $flow->isActive()`. **Returns immediately** — the four below are not evaluated |

> ⚠️ That early return (`Workflow.php:203-205`) is followed by **five** more checks, not none: a different number (`:209`), keyword-triggered (`:213`), no AI profile or objective (`:217`), must open with a template (`:229`), and the opener's validity.
| `"{label}"'s conversation flow lives on a different WhatsApp number than the step chats from.` | node has a `channel_id` and it differs from `$flow->channel_id` |
| `"{label}"'s conversation flow is keyword-triggered, so it cannot be started FOR a lead. Use a Campaign/API or Google-Sheet flow.` | `! $flow->isProactiveTrigger()` — `trigger_type` not in `PROACTIVE_TRIGGERS` (`TRIGGER_SHEET = 2`, `TRIGGER_CAMPAIGN = 3`); `TRIGGER_KEYWORD = 1` is excluded because "cold-starting one bypasses its keyword semantics" |
| `"{label}"'s conversation flow has no AI profile or no objective — without both there is nobody to do the booking.` | `! $flow->hasAiProfile()` **or** `blank($flow->objective)` |
| `"{label}"'s conversation flow must OPEN with an approved template — on an official (Cloud API) number nothing else is delivered to someone who has not messaged first.` | channel `isCloudApi()` and the flow's first step is missing or its `type !== WhatsappFlowStep::TYPE_TEMPLATE` |
| `"{label}"'s conversation flow opener: {validator error}` | Cloud API, first step IS a template, and `TemplateStepValidator::validate($channel, $first->meta['template'])` returns an error — "the same validator `StartProactiveFlow` runs per recipient, so this check and the send-time one cannot drift" |

---

### Consequences a changer must know

- **Recompiling breaks in-flight runs pointing at removed nodes.** Every save deletes and recreates every node, so ids change. `Runner\WorkflowRunner.php:98-101` catches it: a run whose `currentNode` is null is finished as `STATUS_FAILED` with *"The step this lead was on no longer exists."*
- **The unique `(from_node_id, branch)` index is never contended by the compiler**, because `$tails` only ever holds distinct (node, branch) pairs; that is what lets `objection`/`no_answer`/`gave_up` all point at the same next node.
- **The editor's canvas draws two decisions per AI call ("Answered call?" then "Appointment scheduled?"), but the compiled graph has one** `condition.call_outcome` with five branches (`WorkflowTab.vue:1150`, `:1169`, `:1410-1411`). The drawing is presentation; dragging "re-ARRANGES, it never re-WIRES."
- **Two AI callers cannot sit adjacent** — a client-side rule only (`WorkflowTab.vue:canInsert`, `:148-149`): "its retries live inside the block, so a second caller right before or after it has no meaning." Neither `Plan::normalize` nor `savePlan` validation enforces it.
- **Business hours reach the graph in two places from one plan value**: `action.wait.window_start/window_end` and `action.ai_call.call_start/call_end`. Changing `hours` requires a recompile to take effect; nothing reads `plan.hours` at run time.

---

## 3. The runner — how a run executes

A **run** is one lead walking one workflow. The runner owns *movement* (which node the lead stands on, what status the run is in); **handlers own effects** (place a call, send a template, set a stage) and report back a `StepResult`. That split is deliberate: `src/AppointmentEngine/Runner/WorkflowRunner.php:14-17` — "a handler that throws [must not leave] a lead nowhere". Everything the runner does is written to an append-only trail, because "why was I rung three times" is a question the team gets asked (`WorkflowRunner.php:19-21`).

#### Cast of files

| File | Role |
|---|---|
| `src/AppointmentEngine/WorkflowRun.php` | The run row: status + wait constants, `isOpen()`, `log()`, relations |
| `src/AppointmentEngine/WorkflowRunLog.php` | One append-only trail line (`UPDATED_AT = null`) |
| `src/AppointmentEngine/Runner/WorkflowRunner.php` | `advance()` (guards + step loop), `resume()`, `start()`, `execute()`, `apply()`, `finish()` |
| `src/AppointmentEngine/Runner/StepResult.php` | The five things a step may say (`next`/`wait`/`park`/`end`/`fail`) |
| `src/AppointmentEngine/Runner/NodeHandler.php` | The one-method handler interface |
| `src/AppointmentEngine/Runner/HandlerRegistry.php` | `node type → handler class` map (25 entries) |
| `src/AppointmentEngine/Runner/Text.php` | `{token}` table + `fill()` |
| `src/AppointmentEngine/Runner/Notify.php` | Fail-soft Telegram line to whoever subscribed |
| `src/AppointmentEngine/Runner/WhatsappSender.php` | The one way a workflow sends WhatsApp |
| `app/Jobs/AppointmentEngine/AdvanceWorkflowRun.php` | Queue job: move one run forward, overlap-locked |
| `app/Jobs/AppointmentEngine/SendWorkflowWhatsapp.php` | A WhatsApp send scheduled for later (the appointment reminder) |
| `app/Console/Commands/AppointmentEngine/RunWorkflows.php` | `ae:run-workflows`, the minute heartbeat |
| `app/Console/Kernel.php:209` | `$schedule->command('ae:run-workflows')->everyMinute()->withoutOverlapping();` |

#### The run row (`ae_workflow_runs`)

Created by `database/migrations/2026_08_30_140000_add_workflow_runs_and_lead_source.php` and extended by `database/migrations/2026_08_30_150000_runner_columns_and_run_logs.php`.

| Column | Type | Meaning |
|---|---|---|
| `id` / `uuid` | bigIncrements / unique uuid | `WorkflowRun` uses `HasUuid`; the uuid is the public id (it is also stamped into WhatsApp message meta as `run`) |
| `group_id` | unsigned bigint, nullable, indexed | Agency partition |
| `ae_lead_id`, `ae_workflow_id` | unsigned bigint, indexed | Who, and which plan |
| `current_node_id` | unsigned bigint, nullable | Where the lead stands. Null until `start()` runs |
| `status` | string(16), default `'pending'`, indexed | See lifecycle below |
| `resume_at` | timestamp, nullable, indexed | When a timer next makes this run due |
| `waiting_for` | string(32), nullable, indexed | The **human** event a parked run resumes on |
| `steps` | unsigned int, default 0 | Loop fuse. The guard is `if ($run->steps >= self::MAX_STEPS)` (`WorkflowRunner.php:90`) and it runs **before** the node executes, so a run stops the moment `steps` *reaches* 200 — the 200th step is never run |
| `context` | json, nullable | Per-run scratch space shared by all handlers (`array` cast) |
| `last_error` | string, nullable | Set on `failed` by `finish()` (`WorkflowRunner.php:246`) — but **not only there**: `ChatTakeover::stopSequence()` writes `last_error = $reason` on a run it sets to **stopped** (`ChatTakeover.php:110-115`), and the lead page prints the column whatever the status (`LeadsController.php:461`), so a stopped run can legitimately show text here |
| `started_at`, `finished_at` | timestamps, nullable | Stamped by `start()` / `finish()` |
| `created_by` | unsigned bigint, nullable | Set only when a person enrolled the lead by hand |

`WorkflowRun` extends the plain `Diver` model — **no soft deletes** (`src/AppointmentEngine/WorkflowRun.php:16`). Casts: `resume_at`, `started_at`, `finished_at` → datetime; `context` → array; `steps` → integer (`WorkflowRun.php:43`).

#### Statuses — every value, and what moves it

Constants at `src/AppointmentEngine/WorkflowRun.php:20-25`; the UI labels/colours at `:27-34`.

| Constant | Value | Label / colour | Entered by | Left by |
|---|---|---|---|---|
| `STATUS_PENDING` | `'pending'` | Waiting to start / slate | The enroller: `Triggers::enrol()` (`Runner/Triggers.php:630`), `Triggers::enrolOnce()` (`:524`, with `resume_at` = the stagger), `LeadsController::…enrol()` (`app/Http/Controllers/Manage/AppointmentEngine/LeadsController.php:283`) | `start()` → `running`, once the run is due |
| `STATUS_RUNNING` | `'running'` | In progress / sky | `start()` (`WorkflowRunner.php:142-146`); every `NEXT` transition (`:189-195`) | Any `StepResult`, or a guard |
| `STATUS_WAITING` | `'waiting'` | Waiting on a step / amber | `WAIT` (`:200-205`) and `PARK` (`:212-217`) | A due `resume_at`, or `WorkflowRunner::resume()` matching `waiting_for` |
| `STATUS_DONE` | `'done'` | Finished / emerald | `NEXT` with no matching edge (`:184`); `END` (`:227`) | terminal |
| `STATUS_STOPPED` | `'stopped'` | Stopped / slate | Workflow gone (`:49`); lead gone (`:63`); `ChatTakeover::stopSequence()` (`src/AppointmentEngine/Services/ChatTakeover.php:106-116`) | terminal |
| `STATUS_FAILED` | `'failed'` | Failed / rose | No trigger node (`:137`); step budget (`:91`); missing node (`:99`); `FAIL` (`:233`) | terminal |

`isOpen()` = `pending | running | waiting` (`WorkflowRun.php:69-72`). It is the definition of "alive" — but most callers do **not** go through the method. `isOpen()` is called in exactly five places (`WorkflowRunner.php:35`, `InboxController.php:1502` the inbox card, `LeadsController.php:758` and `:773`, `RecordWhatsappBooking.php:112`); the enrol checks and `RunProgress` spell the three statuses out as a literal `whereIn`, so a change to `isOpen()` would silently not reach them.

#### The heartbeat — `ae:run-workflows`

Scheduled every minute with `withoutOverlapping()` (`app/Console/Kernel.php:209`). `handle()` does two things, in this order (`app/Console/Commands/AppointmentEngine/RunWorkflows.php:24-56`):

1. `Triggers::sweep()` — watches inbound sources and *creates* runs (returns how many were started).
2. Wakes due runs with this **exact** query (`RunWorkflows.php:28-47`):

```php
WorkflowRun::query()
    ->where(function ($q) {
        $q->where(function ($p) {
            $p->where('status', WorkflowRun::STATUS_PENDING)
              ->where(function ($r) {
                  $r->whereNull('resume_at')->orWhere('resume_at', '<=', now());
              });
        })
        ->orWhere(function ($w) {
            $w->where('status', WorkflowRun::STATUS_WAITING)
              ->whereNotNull('resume_at')
              ->where('resume_at', '<=', now());
        });
    })
    ->whereHas('workflow', fn ($q) => $q->where('is_active', true))
    ->limit(200)
    ->pluck('id');
```

Read it carefully, because three behaviours fall straight out of it:

- **A `pending` run with a future `resume_at` is *scheduled*, not due.** That is the sheet trigger's dial stagger: `Triggers` enrols row *n* with `resume_at = now()->addMinutes(intdiv($started, $pace))` (`Runner/Triggers.php:447`, `$pace = pace_per_minute`, `:414`), and only a non-future start is dispatched immediately (`Triggers.php:534`). Everything else waits for this sweep, "which is what makes the stagger real".
- **A `waiting` run with `resume_at = NULL` is invisible to the heartbeat.** That state (parked on a person with no nudge) can only be moved by `WorkflowRunner::resume()`.
- **`running` is not in the query at all**, and `whereHas('workflow', …)` applies both `is_active = 1` and the `Workflow` soft-delete scope — so runs of a paused or deleted workflow are never dispatched from here.

The command prints `triggered {n}, advanced {m}` and returns `SUCCESS`.

Timers are *also* delayed queue jobs; the sweep is belt and braces because "a queue restart loses delayed jobs, and a lead parked on a two-day wait must not be lost with them" (`RunWorkflows.php:10-14`).

#### The job — `AdvanceWorkflowRun`

`app/Jobs/AppointmentEngine/AdvanceWorkflowRun.php` carries one `public int $runId`, `public int $tries = 2` (`:25`), and one middleware:

```php
[(new WithoutOverlapping("ae-run:{$this->runId}"))->releaseAfter(30)->expireAfter(300)]
```

Per-run overlap lock, "because two workers advancing the same lead at once would execute a step twice, and a step can be a phone call" (`:10-12`). `handle()` finds the run and calls `WorkflowRunner::advance()`; a missing row is a silent no-op (`:37-44`). No queue or connection is set, so it rides the app default queue — picked up by Horizon's `supervisor-1` (`config/horizon.php:208-210`, `connection => redis`, `queue => ['default']`; 10 processes in production, `:337-341`). (There is no supervisor literally named `default`.) 

#### `WorkflowRunner::advance()` — the guards, in order

Order matters; this is the sequence in `src/AppointmentEngine/Runner/WorkflowRunner.php:31-70`.

| # | Line | Check | Outcome |
|---|---|---|---|
| 0 | `:33` | `$run->refresh()` | Always re-read: the job may have queued behind another advance |
| 1 | `:35` | `! $run->isOpen()` | `return` — a finished run is never touched again |
| 2 | `:42` | `status === pending && resume_at?->isFuture()` | `return` — "belt and braces so a stray direct dispatch cannot defeat" the stagger |
| 3 | `:48` | `$workflow === null \|\| $workflow->trashed()` | `finish(STOPPED, 'The workflow was deleted.')` |
| 4 | `:56` | `! $workflow->is_active` | **`return` with no write** — "A paused workflow holds its leads where they are. The pause is a person's decision; the runner does not overrule it." |
| 5 | `:62` | `$lead === null \|\| $lead->trashed()` | `finish(STOPPED, 'The lead no longer exists.')` |
| 6 | `:68` | `status === pending` | `start()` |

`Workflow` and `Lead` both extend `Diver\Database\Eloquent\SoftDeleteModel`, so `$run->workflow` / `$run->lead` already return `null` for a soft-deleted parent; the `trashed()` half of guards 3 and 5 is redundant belt-and-braces.

**`start()`** (`:132-149`): finds the first node whose `group()` is `'trigger'` among `$workflow->nodes()->get()`. None → `finish(FAILED, 'The workflow has no trigger.')`. Otherwise it writes `status = running`, `current_node_id = $trigger->id`, `started_at = now()` and logs `started` — `"Entered '{workflow name}'"`.

#### The step loop

`for ($i = 0; $i < self::STEPS_PER_JOB; $i++)` with `STEPS_PER_JOB = 25` and `MAX_STEPS = 200` (`:28-29`, loop at `:72-109`). Each iteration:

1. `$run->refresh()` (`:73`) — handlers write to the run, so the loop re-reads it.
2. Status must be `running` or `waiting`, else `return` (`:75-77`).
3. If `waiting`:
   - `resume_at !== null && resume_at->isFuture()` → `return` (`:81`) — parked and not yet due.
   - `waiting_for !== null && resume_at === null` → `return` (`:85`) — parked on a person with no nudge.
   - Anything else falls through and **re-executes the current node** (this is both the timer wake-up and the nudge).
4. `steps >= 200` → `finish(FAILED, 'Stopped after 200 steps — this workflow looks like it loops.')` (`:90-93`).
5. `$node = $run->currentNode`; **`null` → `finish(FAILED, 'The step this lead was on no longer exists.')`** (`:96-102`). This is not theoretical: `PlanCompiler::compile()` deletes *every* edge and node of the workflow and rebuilds them inside one transaction on each plan save (`src/AppointmentEngine/Services/PlanCompiler.php:65-66`), and its own docblock names this guard as the fail-soft for open runs (`PlanCompiler.php:27-28`).
6. `execute($run, $node, $lead->fresh())` (`:104`) — the lead is re-read every step.
7. `apply(...)`; it returns `false` for anything but `NEXT`, which ends the job (`:106-108`).

If all 25 iterations were `NEXT`, the runner dispatches a fresh `AdvanceWorkflowRun` rather than holding a worker (`:113`).

**`execute()`** (`:151-169`) resolves `HandlerRegistry::for($node->type)`; an unmapped type returns `StepResult::fail("No handler for step type '{type}'.")`. A throw is caught, `report($e)` is called, and it becomes `StepResult::fail('Step failed: ' . $e->getMessage())` — "A throw is a bug or an outage, not the lead's fault. The run stops here where a person can see it, with the real message — never skipping the step silently."

#### `StepResult` — the five kinds

Constructed only through its five static factories (`src/AppointmentEngine/Runner/StepResult.php:14-56`): `NEXT = 'next'`, `WAIT = 'wait'`, `PARK = 'park'`, `END = 'end'`, `FAIL = 'fail'`. Fields: `kind`, `branch` (default `'default'`), `until` (Carbon), `waitingFor`, `message`, `endStatus`.

What `apply()` does with each (`WorkflowRunner.php:172-237`; `$label = $node->displayName()`):

| Kind | Columns written | Log | Job dispatched | Loop continues? |
|---|---|---|---|---|
| `NEXT` | Looks up `WorkflowEdge::where('from_node_id', $node->id)->where('branch', $result->branch)`. **No edge → `finish(DONE, "Finished at '{label}'.")`**. Edge → `current_node_id = edge->to_node_id`, `status = running`, `steps + 1`, `waiting_for = null`, `resume_at = null` | `done` — the handler's message when it gave one, otherwise `"{label}"` with `" → {branch}"` appended for a non-`default` branch. Concatenation binds tighter than `?:`, so `WorkflowRunner.php:179` reads `$result->message ?: ("{$label}" . ($branch !== 'default' ? " → {$branch}" : ''))` — **the arrow is part of the fallback only**. Almost every handler supplies a message ("They replied", "Answered and booked", …), so the branch usually never appears in the log | — | **yes** |
| `WAIT` | `status = waiting`, `resume_at = until`, `waiting_for = null`, `steps + 1`. **`current_node_id` untouched — the same node runs again** | `waiting`, message or `Waiting until {D j M H:i}` in `app.user_timezone` | `AdvanceWorkflowRun::dispatch()->delay($until)` | no |
| `PARK` | `status = waiting`, `waiting_for = result->waitingFor`, `resume_at = until` (may be null), `steps + 1`. Same node again on re-entry | `parked`, message or `Waiting on a person: {waitingFor}` | only when `until !== null` | no |
| `END` | `finish($result->endStatus ?: 'done', message ?: "Ended at '{label}'.")` | the status as the event | — | no |
| `FAIL` (and `default`) | `finish(FAILED, message ?: "'{label}' failed.")` | `failed` | — | no |

**`finish()`** (`:239-250`) writes `status`, `finished_at = now()`, `waiting_for = null`, `resume_at = null`, and `last_error = mb_substr($why, 0, 250)` **only when the status is `failed`** (null otherwise), then logs `$status` as the event with `$why` as the summary.

Note the asymmetry that follows from the table: `steps` is incremented on `NEXT`, `WAIT` *and* `PARK`, so a long-lived parked run burns the 200-step fuse too.

#### `waiting_for`, `resume_at`, `current_node_id`, `context`

**`current_node_id`** only ever changes on `NEXT` (and on `start()`). `WAIT` and `PARK` deliberately leave it alone so the node re-runs — which is why re-entrant handlers keep their own state in `context`. `Wait` is the clearest example: first visit stores `context['wait_node'] = $node->id` and returns `wait($until)`; on the second visit it sees its own marker, removes it and returns `next` (`Runner/Handlers/Wait.php:16-38`).

**The three shapes of `waiting`:**

| `waiting_for` | `resume_at` | Meaning | Who wakes it |
|---|---|---|---|
| `null` | future | A timer (`WAIT`) | The delayed job, or the heartbeat once due |
| set | future | Parked on a person, with a nudge | `resume()` on the event; otherwise the nudge re-runs the node |
| set | `null` | Parked on a person, no timer | **Only** `WorkflowRunner::resume()` |

**`waiting_for` vocabulary** (`WorkflowRun.php:45-51`) — the migration explains why it exists at all: "A run parked on nothing in particular could only be resumed by a timer, and a person's action would go unnoticed" (`2026_08_30_150000_runner_columns_and_run_logs.php:9-13`).

| Constant | Value | Parked by | Resumed by |
|---|---|---|---|
| `WAIT_CALL` | `'call_result'` | `AiCall` (`Handlers/AiCall.php:84`, `:98`, `:250`) | `RetellWebhookBridge` (`src/AppointmentEngine/Services/RetellWebhookBridge.php:78`), `AppointmentEngineRetellController` (`app/Http/Controllers/Webhooks/AppointmentEngineRetellController.php:82`) |
| `WAIT_OUTCOME` | `'outcome'` | `RecordOutcome` (`:32`, `:37`), `ConditionAttended` (`:30`) | `AppointmentRepository` (`src/Appointment/Repositories/AppointmentRepository.php:144`) |
| `WAIT_WA_BOOKING` | `'wa_booking'` | `WhatsappAiTakeover` (`:120`, `:167`, `:191`) | `RecordWhatsappBooking` listener (`app/Listeners/AppointmentEngine/RecordWhatsappBooking.php:122`) |
| `WAIT_VISIT` | `'visit'` | — | — |
| `WAIT_VISIT_ANALYSED` | `'visit_analysed'` | — | — |
| `WAIT_REPLY` | `'reply'` | — | — |

**`WorkflowRunner::resume(Lead $lead, string $event)`** (`:119-130`) is static and takes the lead, not the run: it loads every run for that lead with `status = waiting` **and** `waiting_for = $event`, clears both `waiting_for` and `resume_at`, logs `resumed` — `"Resumed: {event}"` — and dispatches `AdvanceWorkflowRun`. The run stays `waiting`, but with both columns null it now falls through the loop's waiting checks and re-executes its node.

`RecordWhatsappBooking` does one extra thing worth knowing: after resuming `wa_booking` parks it also pulls forward *every* future timer on that lead (`status = waiting`, `resume_at > now()` → `resume_at = now()` + dispatch, `RecordWhatsappBooking.php:129-138`), so a chat-brain booking makes the plan's "already booked?" check run at once instead of after the next nurture wait.

**`context`** is one JSON bag shared by all handlers of the run. Everything written to it today:

| Key | Written by | Read by |
|---|---|---|
| `wait_node` | `Wait` (`:36`), removed on the second visit (`:18`) | `Wait` |
| `call_id` | `AiCall` (`:246`) | `AiCall` ("is the call in flight?") |
| `call_attempts` | `AiCall` (`:246`) | `AiCall` (retry ladder / cap) |
| `analysis_nudged` | `AiCall` (`:96`) | `AiCall` (one extra beat for a missing analysis) |
| `last_call_id` | `AiCall` (`:105`) | `ConditionCallOutcome` (`:27`) — "so a lead's older calls cannot answer for this one" |
| `call_exhausted` | `AiCall` (`:114`, `:170`) | `ConditionCallOutcome` (`:55` → `gave_up`) |
| `replied_since` | `ConditionReplied` (`:26`) | `ConditionReplied` (window start) |
| `wa_booking` (`flow_id`, `channel_id`, `dispatched_at`, `deadline`, later `appointment_id`) | `WhatsappAiTakeover` (`:107`), `RecordWhatsappBooking` (`:113-116`); cleared by `clear()` (`:205`) so a later takeover node starts fresh | `WhatsappAiTakeover::settle()` |
| `appointment_id` | `EndBooked` (`:51`) — pins the booking this confirmation is about | `Text::values()` (`:20`) |
| `link` | `EndBooked` (`:52`, the formatted **date** string) / `ZoomInvite` (`:40`, `:92`, the **join URL**) | `Text` `{link}` |
| `meeting_link` | `EndBooked` (`:53`) | `Text` `{meeting_link}` |
| `venue`, `venue_link` | `EndBooked` (`:54-57`) | `Text` `{venue}` / `{venue_link}` |
| `zoom_meeting_id` | `ZoomInvite` (`:41`, `:92`) | (nothing in the runner) |

#### Handlers

`NodeHandler` is one method — `execute(WorkflowRun $run, WorkflowNode $node, Lead $lead): StepResult` — and is documented as stateless: "everything it needs is on the run, the node and the lead" (`Runner/NodeHandler.php:8-11`).

`HandlerRegistry::MAP` (declared `Runner/HandlerRegistry.php:13`, entries `:14-38`) holds **25 entries** — it is a `private const`, readable only by reflection, resolved through the container by `HandlerRegistry::for()`; `missing()` diffs the map against `NodeCatalogue::types()`. Verified by running both: 25 catalogue types, 25 map keys, `missing()` empty, nothing extra in the map.

| Node type | Handler | Kinds it can return |
|---|---|---|
| `trigger.meta_lead_form`, `trigger.whatsapp_keyword`, `trigger.ctwa`, `trigger.manual`, `trigger.google_sheet` | `Passthrough` | `next('default')` |
| `action.assign_agent` | `AssignAgent` | `next` |
| `action.whatsapp` | `SendWhatsapp` | `next` / `fail` |
| `action.whatsapp_ai` | `WhatsappAiTakeover` | `next('booked'\|'no_booking')` / `wait` / `park(wa_booking)` / `fail` |
| `action.email` | `SendEmail` | `next` / `fail` |
| `action.ai_call` | `AiCall` | `next` / `wait` / `park(call_result)` / `fail` |
| `action.wait` | `Wait` | `wait` then `next` |
| `action.set_stage` | `SetStage` | `next` / `fail` |
| `action.notify_team` | `NotifyTeam` | `next` |
| `action.zoom_invite` | `ZoomInvite` | `next` / `fail` |
| `ai.qualify` | `Qualify` | `next` |
| `human.record_outcome` | `RecordOutcome` | `next` / `park(outcome)` |
| `condition.answered` | `ConditionAnswered` | `next('yes'\|'no')` |
| `condition.replied` | `ConditionReplied` | `next('yes'\|'no')` / `wait` |
| `condition.call_outcome` | `ConditionCallOutcome` | `next('booked'\|'objection'\|'no_answer'\|'gave_up'\|'invalid')` |
| `condition.booked` | `ConditionBooked` | `next('yes'\|'no')` |
| `condition.field` | `ConditionField` | `next('yes'\|'no')` |
| `condition.attended` | `ConditionAttended` | `next('yes'\|'no')` / `park(outcome)` |
| `end.booked` | `EndBooked` | `next` — deliberately, "the closing stage follows. With nothing wired after it the runner finishes here anyway" (`Handlers/EndBooked.php:79-81`) |
| `end.stop`, `end.closed` | `EndStop` (one class, two types) | `end('done')` |

Branch names must match a `WorkflowEdge.branch` exactly, or the `NEXT` case falls into `finish(DONE, "Finished at '…'.")` — a dangling output is treated as a legitimate end, not an error (`WorkflowRunner.php:181-187`).

#### Logging — `WorkflowRunLog`

`WorkflowRun::log($event, $summary, $detail = null, $node = null)` (`WorkflowRun.php:82-93`) inserts a row with `node_id`/`node_type` falling back to the run's current node, `summary` truncated to 250 chars, optional `detail` JSON, and an explicit `created_at` (the model has `UPDATED_AT = null`, so there is no `updated_at`).

| Event | Written at | Means |
|---|---|---|
| `started` | `WorkflowRunner.php:148` | The run entered the workflow at its trigger node |
| `done` | `:179` (a step completed, on `NEXT`) **and** `:249` via `finish(DONE, …)` | Both "step finished" and "run finished" — only the summary tells them apart |
| `waiting` | `:206` | A timer was set |
| `parked` | `:218` | Parked on a human event |
| `resumed` | `:127` | `resume()` matched this run |
| `stopped` | `:249`; also `ChatTakeover::stopSequence()` (`Services/ChatTakeover.php:116`) | Run stopped (workflow/lead gone, or a person stopped the sequence from the inbox) |
| `failed` | `:249`; also `SendWorkflowWhatsapp` on a failed reminder (`app/Jobs/AppointmentEngine/SendWorkflowWhatsapp.php:34`) | |
| `booking` | `AiCall::bookIfPromised()` (`:361`, `:369`, `:375`, `:409`), `RecordWhatsappBooking` (`:117`) | A booking was made, or *explicitly* was not — "a booking that silently never happens is the worst failure this product has" |
| `assign` | `CloserRotation` (`:233`, `:299`, `:316`), `HandoffsController` (`app/Http/Controllers/Manage/AppointmentEngine/HandoffsController.php:36`) | Closer rotation offers/accepts |

The lead's Show page gets the trail **oldest-first over the wire** — the reversal happens server-side: `LeadsController.php:456` eager-loads `logs` with `latest('id')->limit(40)` (the newest 40 rows), `:464` maps `$run->logs->reverse()->values()` into `trail`, and `Show.vue:243` renders it in the order given. The 40-line cap is per run.

#### `Runner\Text` — the full token table

`Text::fill($text, $lead, $run)` is a plain `strtr()` over `Text::values()` (`Runner/Text.php:49-52`). It is applied to: the WhatsApp free-text body and **each** template variable (`Runner/WhatsappSender.php:71`, `:85`), the email subject and body (`Handlers/SendEmail.php:24-25`), and the "Alert the team" message (`Handlers/NotifyTeam.php:17`).

The appointment behind the date/venue tokens is resolved **at send time** (`Text.php:17-22`): the run's pinned `context['appointment_id']` via `Appointment::find()` wins; otherwise the lead's latest appointment (`Appointment::where('ae_lead_id', …)->latest('id')->first()`). Resolving late is what lets a link an agent pastes in by hand after the confirm still reach the reminder.

| Token | Value | Fallback |
|---|---|---|
| `{name}` | `$lead->name` | `'there'` |
| `{project}` | `$lead->project` | `'the project'` |
| `{phone}` | `$lead->phone` | `''` (a hidden-number lead has no phone) |
| `{link}` | `context['link']` | `''` — **`EndBooked` writes the formatted date (`D j M, g:ia`) here, `ZoomInvite` writes the Zoom join URL** |
| `{meeting_link}` | `appointment->meeting_link` → `context['meeting_link']` → `context['link']` | never blank by design: "Meta refuses empty template params, and a date reads sensibly in a showroom confirm too" |
| `{agent}` | `$lead->assignedAdmin?->profile?->full_name` → `assignedAdmin?->email` | `'our team'` |
| `{date}` | `appointment->scheduled_at` in `app.user_timezone`, `'l, j M Y'` | `'to be confirmed'` |
| `{time}` | same instant, `'g:ia'` | `'to be confirmed'` |
| `{venue}` | `context['venue']` (Zoom → `'Zoom video call'`; else the step's showroom address + note) | `'our showroom'` |
| `{venue_link}` | `context['venue_link']` → `appointment->meeting_link` → `context['link']` | `'details to follow'` |

`config('app.user_timezone')` and `config('app.timezone')` both default to `Asia/Kuala_Lumpur` (`config/app.php:71,73`).

#### `Runner\Notify`

`Notify::team($event, $title, $body, ?Lead $lead)` builds a `NotifyMessage`, attaches an "Open the lead" URL (`/manage/appointment-engine/leads/{uuid}`) when a lead is given, and sends through `Src\Common\Notify\Services\Notifier`. The whole body is wrapped in `try/catch` + `report($e)` — "Never throws, never blocks" (`Runner/Notify.php:9-27`). **Three** registered events are sent from `src/AppointmentEngine`, and only two of them go through this helper: `ae.team_alert` (`NotifyTeam`, `AssignAgent`, `CloserRotation`) and `ae.nudge` (`RecordOutcome`, `AiCall`'s budget stop, `ZoomMeetings`), both defined in `config/notify.php:124-137` — `ae.nudge` is throttled by `NOTIFY_AE_NUDGE_THROTTLE_SECONDS` (default 3600). The third, **`ae.lead_assigned`** (`config/notify.php:117-123`), is sent by `CloserRotation.php:257` through `Notifier::sendToUsers()` — *addressed* to one closer rather than fanned to the team, which is why grepping `Notify::team` alone misses it. The same key also gates the closer pool at `CloserRotation.php:501` (a closer with no active subscription to it cannot be asked, so they are assigned directly).

#### `Runner\WhatsappSender`

One class, so "a workflow message is indistinguishable from one an agent typed, and lands in the same thread" (`Runner/WhatsappSender.php:19-25`). `send(array $config, Lead $lead, WorkflowRun $run): ?string` returns **a sentence on failure and `null` on success**; `SendWhatsapp` turns that into `fail` vs `next` (`Handlers/SendWhatsapp.php:22-24`). Order of checks:

1. Channel must exist and be `WhatsappChannel::STATUS_CONNECTED`, else `'The WhatsApp number this step sends from is not connected.'` (`:38-42`).
2. **Blank phone** (a hidden-number/username lead): the reply must go into the conversation they started — the lead's `waContact` and its latest conversation, whose own channel overrides the step's. No conversation → `'The lead has no phone number and no WhatsApp conversation to reply into.'` (`:44-59`).
3. Otherwise `WhatsappContact::firstOrCreate(['phone_e164' => $lead->phone], …)` + `WhatsappRepository::conversationFor()` — the same contact row the inbox would use (`:61-65`).
4. `mode === 'text'` requires `$conversation->canSendFreeform()`, else `'The 24-hour window is closed …'`; the body is `Text::fill`ed (`:67-76`). Any other mode is a template: it must be on this channel and `STATUS_APPROVED`, else `'The template is no longer approved on this number.'`; every variable is `Text::fill`ed (`:77-96`).
5. `createOutbound()` + `SendWhatsAppMessage::dispatch()`; meta carries `['source' => 'ae_workflow', 'run' => $run->uuid]` (`:98-99`).
6. Side effects on success: `ChatTakeover::begin($run, $lead, $conversation)`, `last_activity_at = now()` / `first_contacted_at` if unset, and `CrmPipeline::advanceStatus($lead, Engagement::STATUS_CONTACTING)` — monotonic, so a person's further progress is never pulled back (`:101-112`).

`SendWorkflowWhatsapp` (`app/Jobs/AppointmentEngine/SendWorkflowWhatsapp.php`) is the same sender on a delay — dispatched by `EndBooked` at `scheduled_at - reminder_hours_before` when that moment is still future (`Handlers/EndBooked.php:70-78`). It takes `runId` + the config array, no-ops if the run or lead is gone, and logs `done`/`failed` on the run (`:26-35`). It carries **no** overlap lock and does not touch run status — it is a send, not a step.

#### One tick, step by step

Take a lead parked mid-plan: `status = waiting`, `waiting_for = null`, `resume_at = 09:58`, `current_node_id` = a "Wait 2 hours" node whose `context['wait_node']` marker is set. It is now 10:00.

1. **10:00:00** — the scheduler runs `ae:run-workflows` (`Kernel.php:209`).
2. `Triggers::sweep()` runs first and returns how many *new* runs it created (`RunWorkflows.php:26`).
3. The due query (`RunWorkflows.php:28-47`) matches the run on the second branch (`waiting`, `resume_at` not null and `<= now()`), its workflow being active. Up to 200 ids come back.
4. `AdvanceWorkflowRun::dispatch($id)` per id (`:50`). The command prints `triggered 0, advanced 1`.
5. A Horizon worker picks the job up. `WithoutOverlapping("ae-run:{$id}")` takes the lock; if a sibling advance holds it the job is released for 30s.
6. `handle()` finds the run and calls `advance()` (`AdvanceWorkflowRun.php:37-44`).
7. `advance()` refreshes; `isOpen()` is true; not `pending`; workflow present and `is_active`; lead present. No `start()` (status is `waiting`).
8. Loop iteration 1: refresh; status `waiting` → `resume_at` (09:58) is **not** future, and `waiting_for` is null → fall through. `steps` (say 7) < 200. `currentNode` resolves.
9. `execute()` → `HandlerRegistry::for('action.wait')` → `Wait`. It sees `context['wait_node'] === $node->id`, removes the marker, returns `StepResult::next('default', 'Waited')` (`Handlers/Wait.php:16-21`).
10. `apply()` NEXT: finds the `default` edge out of the wait node, logs `done` / `"Waited"` against that node, writes `current_node_id = edge->to_node_id`, `status = running`, `steps = 8`, `waiting_for = null`, `resume_at = null`, returns `true`.
11. Loop iteration 2: refresh; `running`; `steps` 8 < 200; the new node is, say, `action.whatsapp`. `SendWhatsapp` → `WhatsappSender::send()` queues the template (each variable `Text::fill`ed), opens the chat takeover, bumps the lead, advances the CRM pipeline, returns `null` → `StepResult::next('default', 'WhatsApp queued')`.
12. `apply()` NEXT again → logs `done` / `"WhatsApp queued"`, moves to `condition.replied`, `steps = 9`, returns `true`.
13. Loop iteration 3: `ConditionReplied` stamps `context['replied_since']`, finds no inbound message yet, and returns `StepResult::wait($since->addHours($h))`.
14. `apply()` WAIT: `status = waiting`, `resume_at = $until`, `waiting_for = null`, `steps = 10`; logs `waiting`; dispatches `AdvanceWorkflowRun::dispatch($id)->delay($until)`; returns `false` → `advance()` returns, the worker frees the lock.
15. `current_node_id` still points at the condition node, so when the delayed job (or the next minute's sweep after `resume_at`) fires, `ConditionReplied` runs again — this time either `next('yes')` or, past the window, `next('no')`.

Had step 11's send failed instead, `apply()`'s FAIL branch would have called `finish(FAILED, "The template is no longer approved on this number.")`: `finished_at = now()`, `waiting_for`/`resume_at` cleared, `last_error` set to that sentence (250 chars max), and a `failed` line on the trail — which is exactly what the lead page prints under the run.

#### Safety rails, and why each exists

| Rail | Value / place | Why |
|---|---|---|
| `MAX_STEPS` | `200` (`WorkflowRunner.php:28`), fuse at `:90` | "The graph is acyclic by construction; on top of that a step budget … means a bug walks a lead a bounded number of steps and then stops with a reason" |
| `STEPS_PER_JOB` | `25` (`:29`), continuation at `:113` | Never hold a worker; hand the rest to a fresh job |
| Per-run overlap lock | `WithoutOverlapping("ae-run:{id}")`, `releaseAfter(30)`, `expireAfter(300)` | "a step can be a phone call" |
| Handler exceptions | caught in `execute()` (`:159-168`) | A run stops visibly with the real message instead of skipping a step |
| Missing node | `finish(FAILED, …)` (`:99`) | `PlanCompiler` rebuilds the whole graph on every save |
| Paused workflow | early `return`, no write (`:56`) | The pause is a person's decision |
| Heartbeat | `everyMinute()->withoutOverlapping()` | Delayed jobs are lost on a queue restart; a two-day wait must survive it |

---

## 4. Call-side handlers

Everything below is a `NodeHandler` — one class, one method, `execute(WorkflowRun $run, WorkflowNode $node, Lead $lead): StepResult`. The contract is enforced from two directions:

* `Src\AppointmentEngine\Runner\HandlerRegistry` maps a node **type string** to its handler class (`src/AppointmentEngine/Runner/HandlerRegistry.php:13-39`). A type present in `NodeCatalogue` but absent here fails the run visibly (`WorkflowRunner::execute`, `src/AppointmentEngine/Runner/WorkflowRunner.php:153-157`), and `HandlerRegistry::missing()` exists so a test can keep the two lists in step (`:49-52`).
* `StepResult` is one of exactly five kinds — `NEXT`, `WAIT`, `PARK`, `END`, `FAIL` (`src/AppointmentEngine/Runner/StepResult.php:14-18`). The **runner owns movement, handlers own effects** (`WorkflowRunner.php:14-16`): a `next('branch')` is resolved into an edge by `WorkflowEdge::where('from_node_id', …)->where('branch', $result->branch)` (`WorkflowRunner.php:178`), and **a branch with no edge is not an error — the run finishes `done`** (`:181-187`). A handler that throws is caught and converted to `FAIL` with the real message (`:159-168`).

| kind | run row written | job dispatched |
|---|---|---|
| `NEXT` | `current_node_id` = edge target, `status=running`, `steps+1`, clears `waiting_for`/`resume_at` (`WorkflowRunner.php:189-195`) | none (loop continues in-process) |
| `WAIT` | `status=waiting`, `resume_at=until`, `waiting_for=null`, `steps+1` (`:200-205`) | `AdvanceWorkflowRun`, delayed to `until` (`:207`) |
| `PARK` | `status=waiting`, `waiting_for=<event>`, `resume_at=until|null`, `steps+1` (`:212-217`) | `AdvanceWorkflowRun` delayed **only if** `until !== null` (`:220-222`) |
| `FAIL` | `status=failed`, `last_error` (250 chars), `finished_at` (`:239-249`) | none |

Every transition also writes a `ae_workflow_run_logs` row via `WorkflowRun::log()` (`src/AppointmentEngine/WorkflowRun.php:82-93`). A run is hard-capped at `MAX_STEPS = 200` and hands off to a fresh job every `STEPS_PER_JOB = 25` (`WorkflowRunner.php:28-29, 90-93, 113`). The minute-by-minute heartbeat that wakes due runs is `ae:run-workflows` (`app/Console/Commands/AppointmentEngine/RunWorkflows.php:20, 28-51`, scheduled `everyMinute()->withoutOverlapping()` at `app/Console/Kernel.php:209`) — belt and braces beside the delayed jobs, because a queue restart loses delayed jobs.

The two human/provider events these handlers park on:

| constant | value | who resumes it |
|---|---|---|
| `WorkflowRun::WAIT_CALL` | `'call_result'` (`WorkflowRun.php:46`) | `AppointmentEngineRetellController::wake()` (`app/Http/Controllers/Webhooks/AppointmentEngineRetellController.php:79-84`) and `RetellWebhookBridge::handle()` (`src/AppointmentEngine/Services/RetellWebhookBridge.php:77-79`) |
| `WorkflowRun::WAIT_OUTCOME` | `'outcome'` (`WorkflowRun.php:47`) | `AppointmentRepository::recordOutcome()` on **any** surface that records an outcome (`src/Appointment/Repositories/AppointmentRepository.php:136-149`) |

---

### 1. `action.ai_call` → `Runner\Handlers\AiCall`

The one node with real money behind it. Its own docblock states the invariant: **every refusal is a row** — "a call the product decided not to make … is recorded with its reason, so 'the AI never called me' can always be answered from the ledger" (`src/AppointmentEngine/Runner/Handlers/AiCall.php:26-37`). It has **one output, `default`** (`src/AppointmentEngine/NodeCatalogue.php:350`); all routing happens downstream in `condition.call_outcome`.

#### 1.1 Node config it reads

| key | type / default | read at | meaning |
|---|---|---|---|
| `profile_id` | `ai_profile`, `null`, **required** | `AiCall.php:192`, `:384` | The `Src\VoiceAgent\AiCallProfile` = prompt + voice + KB. Also re-read in `bookIfPromised` for the goal's meeting default. |
| `script_id` | `content` (`kind: script`), `null` | `:262-263` | `ContentItem` uuid, `status = active` only; flattened to text, `mb_substr(…, 0, 12000)` |
| `knowledge_id` | `content` (`kind: knowledge`), `null` | `:262-263` | same, as the `knowledge` dynamic variable |
| `call_start` | `time`, `'09:00'` | `:288`, `:293` | quiet-hours window open |
| `call_end` | `time`, `'21:00'` | `:288` | quiet-hours window close |
| `max_attempts` | `number` 1–5, `3` | `:73` | `max(1, (int) …)` |
| `retry_gaps` | `hours_list`, `[24, 48]` | **raw** `:149-150`, schema-default fallback `:157` | the ladder — see §1.4 |
| `retry_gap_hours` | *(not in the schema)* | `:155-156` | legacy single flat gap from pre-ladder nodes |
| `retry_mode` | `select` `auto|flow`, `'auto'` | `:121` | `flow` hands a no-answer to the graph instead of self-retrying |
| `default_meeting_type` | `select` `showroom|zoom`, `'showroom'` | `:388` | last resort in the channel precedence |
| `discloses_ai` | `locked` (display only) | — | "Required by the voice provider's terms and by consumer law … It is not ours to switch off" (`NodeCatalogue.php:393-396`) |

`WorkflowNode::config()` falls back to the catalogue's `default` whenever the stored value is missing, `null` or `''` (`src/AppointmentEngine/WorkflowNode.php:102-117`) — which is exactly why the ladder resolution deliberately reads `$node->getAttribute('config')` **raw** first (see §1.4).

The `script_id` / `knowledge_id` values are also mirrored into the profile's Retell knowledge base **at save time**, not at call time, by `ProfileKnowledge::push()` (`src/AppointmentEngine/Services/ProfileKnowledge.php:34-64`, called from `PlanCompiler.php:213`), which deletes the node's previous `Workflow step {uuid}%` entries and dispatches `SyncProfileKnowledge` — and `retract()` pulls them from the *old* profile on a brain swap (`PlanCompiler.php:55`), "a brain swap must not leave the old voice still answering from this deal's facts".

#### 1.2 Execution order — the whole state machine

The node runs **several times over its life**: place → park → decide. State lives in `WorkflowRun::$context`:

| context key | written where | meaning |
|---|---|---|
| `call_id` | `:246` (set), `:102` (unset) | a call is in flight |
| `call_attempts` | `:246` | attempts placed **by this run** |
| `analysis_nudged` | `:96` (set), `:102` (unset) | the one-shot extra wait for a lost `call_analyzed` |
| `last_call_id` | `:103` | the call `condition.call_outcome` must read |
| `call_exhausted` | `:104` (false), `:114`/`:170` (true) | the cap was hit |

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

1. **No phone** (`:58-69`). `blank($lead->phone)` → writes a `Call` row with `status = STATUS_REFUSED`, `refusal_reason = REFUSAL_NO_PHONE`, `to_number = null` → `next('default')`, message *"No phone number (hidden by WhatsApp) — skipping the call"*. A hidden-number (WhatsApp username) lead is worked by the chat half of the flow.
2. **A call is in flight** (`:76-166`) — `! empty($ctx['call_id'])`:
   * `Call::find($ctx['call_id'])`; if status is `PENDING(1)` or `IN_PROGRESS(2)` → `reconcile()` (§4.2), and if it is *still* one of those → `park(WAIT_CALL, now()+10min, 'Call still in progress')` (`:79-86`).
   * **Analysis-lost net** (`:91-100`): settled, `lead_spoke` true, `analyzed_at === null` → `reconcile()` + `refresh()`; if still null **and** `analysis_nudged` is unset → set the flag and `park(WAIT_CALL, now()+5min, 'Call over — waiting for its analysis')`. Second time through it advances with the miss on the record rather than waiting forever.
   * Clear `call_id`/`analysis_nudged`, set `last_call_id`, set `call_exhausted = false`, save (`:102-105`).
   * **Spoke** → `bookIfPromised()` (§1.6) → `next('default', 'They spoke to the AI')` (`:107-111`).
   * **Attempts spent** (`$attempts >= $max`) → `call_exhausted = true` → `next('default', "No answer after N attempts")` (`:113-117`).
   * **`retry_mode === 'flow'`** → `next('default', 'No answer — over to the flow')` (`:121-123`) — no wait, the graph owns the timing.
   * Otherwise the **retry ladder** (§1.4).
3. **Re-entered after the cap** (`:169-173`) — reached only when there is *no* `call_id` (a loop-back from the flow). `call_exhausted = true`, `next('default', "Attempts used up ({$max}) — not calling again")`.
4. **Dial governor** (`:181-186`) — see §1.5. Returns `wait(now()+1min)` and **writes no ledger row**, "because nothing was refused — merely queued".
5. **Refusal `match(true)`** (`:194-201`) — order matters, first hit wins. See §1.3.
6. `Call::create(...)` — **always**, refused or not (`:203-210`).
7. Per-refusal dispositions (`:212-231`), then placement (`:233-250`).

#### 1.3 The refusal ledger — every `Call::REFUSAL_*`

Constants and their human strings live on the model (`src/AppointmentEngine/Call.php:59-71`). `Call::wasRefused()` is `status === STATUS_REFUSED || refusal_reason !== null` and "must never render as a missed call" (`Call.php:121-125`).

| # | constant | value | guard that produces it | what the handler then returns |
|---|---|---|---|---|
| 0 | `REFUSAL_NO_PHONE` | `'no_phone'` | `blank($lead->phone)` — before every other check (`AiCall.php:58`) | `next('default')` |
| 1 | `REFUSAL_NOT_CONNECTED` | `'not_connected'` | `$connection === null || $connection->verified_at === null` (`:195`) — the agency's `Connection::CALLER` row is missing or unverified | `fail(Call::REFUSAL_REASONS['not_connected'])` = *"AI caller not connected"* (`:227-231`) |
| 2 | `REFUSAL_NOT_CONNECTED` | `'not_connected'` | `$profile === null || ! $profile->isCallable()` (`:196`); `isCallable()` = `status === STATUS_ACTIVE(1) && retell_agent_id !== null && synced_at !== null` (`src/VoiceAgent/AiCallProfile.php:638-640, 628-631`) | `fail('The AI profile on this step is missing or not synced to the voice provider.')` (`:228-229`) |
| 3 | `REFUSAL_BLOCKED` | `'blocked_number'` | `BlockedNumber::blocks($lead->group_id, $lead->phone)` (`:197`) — **digits-only** comparison, so `+60123456789` on the list catches `60123456789` from a webhook (`src/AppointmentEngine/BlockedNumber.php:34-45`) | `next('default', 'Number is on the do-not-call list — skipped')` (`:217-219`) |
| 4 | `REFUSAL_QUIET_HOURS` | `'quiet_hours'` | `! insideWindow($node)` (`:198`) | `wait(nextWindowOpen($node), 'Outside calling hours — waiting for the window')` (`:212-215`) — **not a failure** |
| 5 | `REFUSAL_BUDGET` | `'daily_budget'` | `budgetExhausted($lead->group_id)` (`:199`) | `Notify::team('ae.nudge', 'Daily call budget reached', …)` then `wait(Wait::insideHours(now()->addDay()->setTime(9,0)))` (`:221-225`) |

Both refusals #1 and #2 store the **same** `refusal_reason` string but produce **different** `fail()` messages — the row cannot tell them apart, only the run log can.

Note the asymmetry the ledger creates downstream: a `blocked_number` or `no_phone` refusal returns `next('default')` **without** setting `last_call_id`, so a following `condition.call_outcome` falls back to "the latest AI call for this lead", finds the refused row, and takes **`no_answer`** — not `invalid` (see §2).

#### 1.4 The retry ladder

Reached only when: a call settled, the lead did not speak, `$attempts < $max`, and `retry_mode !== 'flow'` (`:131-165`).

**Rung shapes.** A rung is `{value, unit}` since 2026-09-07; a bare integer (every node compiled before) still means **hours** (`:127-130`). `$toMinutes` (`:131-147`):

| input | result |
|---|---|
| `['value' => n, 'unit' => 'minutes']` | `n × 1` |
| `['value' => n, 'unit' => 'hours']` *(or any unrecognised unit, incl. missing)* | `n × 60` |
| `['value' => n, 'unit' => 'days']` | `n × 1440` |
| array with non-numeric `value`, or `value < 0` | `null` → filtered out |
| **array with `value = 0`** | **`0` — a real rung, kept** |
| bare integer `n > 0` | `n × 60` |
| **bare integer `0`** (or negative, or non-numeric) | `null` → filtered out |

`Plan::normalize()` is the writer side and agrees: `is_numeric($raw) && (int) $raw >= 0` — "0 is a real rung — retry on the next tick" (`src/AppointmentEngine/Support/Plan.php:118-129`).

**Where the gaps come from, in order (`:149-158`):**

1. `$node->getAttribute('config')['retry_gaps']` — the **raw stored** array. Read raw on purpose: "an old node's stored flat `retry_gap_hours` must not be shadowed by the new schema default" (`:129-130`).
2. If that yields `[]` → `$stored['retry_gap_hours'] × 60`, when `> 0` (the pre-ladder single flat gap).
3. Else → `$node->config('retry_gaps')`, i.e. the catalogue default `[24, 48]` → `[1440, 2880]` minutes (`NodeCatalogue.php:372-375`).

**Picking the rung (`:160-165`):**

```
$gap = (int) ($gaps[$attempts - 1] ?? ($gaps !== [] ? end($gaps) : 1440));
$gap = max(1, $gap);
```

* After miss **1** → `gaps[0]`; after miss **2** → `gaps[1]`; and so on.
* **Running off the end repeats the last rung** (`end($gaps)`).
* No usable rungs at all → **1440 minutes (24h)**.
* `max(1, …)` means a **0-minute rung becomes 1 minute** — the "next tick" semantics, since the heartbeat runs every minute.
* Human label: `1440|n → "Nd"`, else `60|n → "Nh"`, else `"Nm"` (`:163`).
* Return: `wait(Wait::insideHours(now()->addMinutes($gap)), "No answer — trying again in {$human} (after attempt {$attempts} of {$max})")`.

⚠️ `Wait::insideHours()` is called here **with its default 09:00–21:00 window**, *not* the node's `call_start`/`call_end` (`src/AppointmentEngine/Runner/Handlers/Wait.php:53`). A ladder wake therefore lands inside 9–9; if the node's own window is narrower the quiet-hours guard catches it on the next pass and re-waits to `nextWindowOpen()`. `insideHours` rolls a moment: before opening → today's opening; at/after closing → **tomorrow's** opening (`Wait.php:53-68`).

**Attempt counting.** `call_attempts` is incremented **only on a successful placement** (`:246`), so a refusal, a pacing wait or a failed placement never burns an attempt.

#### 1.5 Dial pacing and quiet hours

**Pacing** (`:175-186`) runs *before* the refusal checks and is the "install-wide floor under any per-source pacing". Its rationale is spelled out in the code: "every run parked on quiet hours resumes at `call_start` SHARP, and a burst of simultaneous dials is exactly the pattern telephony reputation systems flag."

```sql
Call::where('group_id', $lead->group_id)->where('channel', Call::CHANNEL_AI)
    ->whereNull('refusal_reason')->where('created_at', '>=', now()->subMinute())->count()
    >= config('appointment_engine.voice.dials_per_minute', 3)
```

`dials_per_minute` is `AE_DIALS_PER_MINUTE`, default **3** (`config/appointment_engine.php:40`). Refused rows are excluded, so a burst of quiet-hours refusals cannot starve real dials.

**Quiet hours** (`:284-302`):

* `insideWindow()` — `now()->timezone(config('app.user_timezone', 'Asia/Kuala_Lumpur'))->format('H:i')` compared with **string** `>=` / `<` against `call_start` / `call_end`. Zero-padded `H:i` sorts like the clock.
* `nextWindowOpen()` — today's `call_start` in the user timezone; if that is `<= now` add a day; returned converted to `config('app.timezone')`. Both timezone keys default to `Asia/Kuala_Lumpur` (`config/app.php:71-73`).

**Budget** (`:304-322`):

| term | source |
|---|---|
| `$limit` | `Setting::forGroup($groupId)->daily_call_budget_usd` — **null returns `false` immediately** (no budget = no fuse). `forGroup` returns an unsaved blank model when the agency has no row (`src/AppointmentEngine/Setting.php:44-48`) |
| `$spent` | `SUM(COALESCE(provider_cost,0)+COALESCE(telephony_cost,0))` over `group_id`, `channel = ai`, `whereDate(created_at, today())`, `ended_at IS NOT NULL` |
| `$inFlight` | count of same-group, same-day AI calls with `ended_at IS NULL` **and** `refusal_reason IS NULL` |
| `$reserve` | `$inFlight × max_call_minutes × combined_per_minute_usd` = `AE_MAX_CALL_MINUTES` (**10**) × `AE_COMBINED_PER_MINUTE_USD` (**0.20**) (`config/appointment_engine.php:30,35`) |
| verdict | `$spent + $reserve >= $limit` |

The current call's own row is created **after** this check, so it never counts itself.

#### 1.6 Placement, and the appointment-booking path

**Placement** (`:233-250`), in order:

1. `$lead->forceFill(['stage' => max((int) $lead->stage, Lead::STAGE_CALLED), 'first_contacted_at' => $lead->first_contacted_at ?? now()])->save()` — `STAGE_CALLED = 2` ("The AI dialled — whether or not anyone picked up", `src/AppointmentEngine/Lead.php:32-34`). Monotonic via `max()`.
2. `CrmPipeline::advanceStatus($lead, Engagement::STATUS_CONTACTING)` (=2, `src/Engagement/Engagement.php:40`). Monotonic and deferential: it refuses to touch `BOOKED(6)`/`FOLLOWING_UP(7)`/`COMPLETED(8)`/`LOST(9)`, and refuses any target `<= current` (`src/AppointmentEngine/Services/CrmPipeline.php:151-168`). Entirely fail-soft.
3. `$this->caller->place($connection, $call, $this->variables($node, $lead), $profile->retell_agent_id)`.
4. `false` → `fail((string) ($call->fresh()->analysis['placement_error'] ?? 'The call could not be placed.'))`.
5. `true` → context `call_id` + `call_attempts + 1`, then `park(WAIT_CALL, now()->addMinutes(max_call_minutes + 5))` = **15 minutes by default** — this is the reconcile backstop the webhook bridge refers to (`RetellWebhookBridge.php:17-20`).

**Dynamic variables** (`variables()`, `:254-271`): `customer_name` (`$lead->name`), `project` (`$lead->project`), `agent_name` (`$lead->assignedAdmin?->profile?->full_name`), plus `script` / `knowledge` from the two `ContentItem`s (`status = active` only), each flattened and cut to 12,000 chars. `flatten()` renders `{question, answer}` as `Q:/A:` and `{section, says}` as `Section: …` (`:273-282`).

**`bookIfPromised()`** (`:343-412`) — "a booking that silently never happens is the worst failure this product has", so **every miss is logged** on the run:

1. Read `$call->analysis['custom_analysis_data']`, take the first non-blank string among `AiCall::APPOINTMENT_KEYS` (`:353-358`). That constant is an alias of `AppointmentObjective::TIME_ALIASES` = **`['appointment_time', 'appointment_datetime', 'appointment']`**, in priority order (`AiCall.php:47`; `src/VoiceAgent/Objectives/AppointmentObjective.php:39`). The same list is what the publish gate `Workflow::aiCallProblems()` checks a goal-less profile for (`src/AppointmentEngine/Workflow.php:173-182`).
2. Nothing found → `run->log('booking', 'The call analysis carried no appointment time — nothing to book.')` and return.
3. `parseWhen()` delegates to `AppointmentObjective::parseWhen()` (`AiCall.php:424-427`) — a three-rung ladder (`AppointmentObjective.php:230-285`): **day-first** `d/m/Y|y` + `H:i`/`g:ia` regex first (so `5/9/2026` is 5 September, never US-style), then `Carbon::parse`, then `j M Y g:ia|H:i`, then ONE Malay/Chinese normalisation pass (`esok→tomorrow`, `petang→pm`, `明天→tomorrow`, `下午→pm`, `点→:00`, …) with a reorder regex because Chinese puts the daypart before the hour. **A daypart with no hour returns `null` on purpose** — "inventing 3pm for 'afternoon' books a time nobody agreed to". Failure → `run->log('booking', "Could not read the appointment time … An agent should call to confirm.", ['raw' => $when])`.
4. `$at->isPast()` → logged, no booking (`:373-378`).
5. **Meeting channel precedence** (`:384-388`), highest first:
   1. `(new AppointmentObjective())->channelFrom($custom)` — the lead's own answer, read from `meeting_type`; **anything that is not exactly `zoom` or `showroom` IS silence** (`AppointmentObjective.php:210-215`).
   2. `AiCallProfile::canonicalObjective($profile->objective)['default_meeting_type']` — the profile form's "where the appointment happens". For a single-channel goal, the default *is* the channel (`AiCallProfile.php:494-497`).
   3. `$node->config('default_meeting_type')`, else the literal `Booking::CHANNEL_SHOWROOM`.
   `AiCallProfile::MEETING_SHOWROOM|MEETING_ZOOM` are `'showroom'`/`'zoom'`, declared to match `Booking::CHANNEL_*` on purpose while keeping VoiceAgent independent of AppointmentEngine (`AiCallProfile.php:203-211`).
6. **`Appointment::firstOrCreate(['ae_call_id' => $call->id], [...])`** (`:394-401`) — idempotent per call. This is *the* CRM `appointments` table since the 2026-09-06 merge, not a private one. Fields: `group_id`, `ae_lead_id`, `lead_id` (the CRM person bridge), `project_id` = `$lead->aeProject?->project_id`, `assigned_admin_id`, `source = Booking::SOURCE_AI ('ai')`, `scheduled_at` in `config('app.timezone')`, `type = Booking::typeFor($channel)`, `status = Appointment::STATUS_SCHEDULED (1)`.
   `Booking::typeFor()`: `zoom → Appointment::TYPE_VIDEO_CALL (2)`, everything else → `TYPE_SHOWROOM_VISIT (1)` (`src/AppointmentEngine/Support/Booking.php:44-49`).
7. **`app(ZoomMeetings::class)->ensure($appointment, $lead)`** (`:403`) — §4.3.
8. `CrmPipeline::advanceStatus($lead, Engagement::STATUS_APPOINTMENT_SET)` (=3).
9. `run->log('booking', 'Booked D j M Y, g:ia (Showroom|Zoom) from the call.', ['raw' => …, 'appointment_id' => …])`.

#### 1.7 Every side effect of `action.ai_call`

| effect | where |
|---|---|
| INSERT `ae_calls` (one per attempt **and** per refusal) | `:59-66`, `:203-210` |
| UPDATE `ae_calls` — `provider_call_id`, `status=PENDING` | `VoiceCaller::place`, `src/AppointmentEngine/Services/VoiceCaller.php:63` |
| UPDATE `ae_calls` — `status=FAILED`, `disconnection_reason` (50 chars), `analysis.placement_error`, `ended_at` | `VoiceCaller::settleFailed`, `:83-91` |
| UPDATE `ae_calls` + DELETE/INSERT `ae_call_turns` (reconcile) | `RetellCallMapper::settle/analyse` via `AiCall::reconcile`, `:324-341` |
| UPDATE `ae_workflow_runs.context` | `:96`, `:105`, `:114`, `:170`, `:246` |
| UPDATE `ae_leads` — `stage`, `first_contacted_at` | `:233` |
| UPDATE `engagements.status` (via `ChangeEngagementStatus`, fail-soft) | `:238`, `:407` |
| INSERT `appointments` | `:394-401` |
| UPDATE `appointments` — `zoom_meeting_id`, `meeting_link` | `ZoomMeetings::ensure`, `src/AppointmentEngine/Services/ZoomMeetings.php:80-83` |
| INSERT `ae_workflow_run_logs` (`booking` events) | `:361`, `:369`, `:375`, `:409` |
| Telegram `ae.nudge` — "Daily call budget reached" | `:222` |
| Telegram `ae.nudge` — "Zoom meeting not created" | `ZoomMeetings.php:88-89` |
| Outbound HTTP `POST /v2/create-phone-call`, `GET /v2/get-call/{id}` | `VoiceCaller.php:34`, `:73` |
| Outbound HTTP `POST /users/{host}/meetings` | `ZoomServerService::createMeeting`, `src/Zoom/Services/ZoomServerService.php:462-465` |

`Notify::team()` never throws and never blocks (`src/AppointmentEngine/Runner/Notify.php:12-25`); the `ae.nudge` event is throttled to `NOTIFY_AE_NUDGE_THROTTLE_SECONDS`, default **3600s** (`config/notify.php:131-137`).

---

### 2. `condition.call_outcome` → `Runner\Handlers\ConditionCallOutcome`

Five outputs — `['booked', 'objection', 'no_answer', 'gave_up', 'invalid']` (`NodeCatalogue.php:515`) — because "routing an invalid number and a hot lead into the same WhatsApp fallback wastes the sequence on people who can never convert" (`:513-514`).

**Which call it reads** (`ConditionCallOutcome.php:27-29`): `$ctx['last_call_id']` first — the call *this run* just made — falling back to `Call::where('ae_lead_id', …)->where('channel', 'ai')->latest('id')->first()`. The docblock states why: "so a lead's older calls cannot answer for this one". **It does not filter out refused rows** (contrast §3).

**Config:** `booked_means`, `select`, default `'appointment'`; options `appointment` = "The AI captured an appointment time", `outcome` = "The AI marked the outcome as an appointment, even without a time" (`NodeCatalogue.php:519-527`).

**The five branches, in evaluation order:**

| order | branch | exact condition | message |
|---|---|---|---|
| 1 | `gave_up` | `$call === null` — no `last_call_id` and no AI call on the lead at all (`:31-33`) | *"No call was made"* |
| 2 | `invalid` | `$wrongPerson` **OR** (`status === STATUS_FAILED (7)` **AND** `disconnection_reason ∈ INVALID`) (`:38-40`). `$wrongPerson = filter_var($custom['wrong_person'] ?? $custom['wrong_number'] ?? false, FILTER_VALIDATE_BOOLEAN)` (`:36`). `INVALID = ['invalid_destination', 'dial_failed', 'telephony_provider_permission_denied', 'no_valid_payment', 'error_no_audio_received', 'invalid_number']` (`:22`) | *"Invalid number or wrong person — {reason or 'reported on the call'}"* |
| 3 | `invalid` | `$call->lead_spoke` **AND** `$call->outcome === Call::OUTCOME_DO_NOT_CALL (4)` (`:46-48`). Checked **after** `$booked` is computed but returns first, so a call that both booked *and* said "do not call" routes to `invalid` | *"They asked not to be called"* |
| 4 | `booked` | `$call->lead_spoke` **AND** (`Appointment::where('ae_call_id', $call->id)->exists()` **OR** (`booked_means === 'outcome'` **AND** `outcome === Call::OUTCOME_APPOINTMENT (1)`)) (`:43-44, 50-51`) | *"Answered and booked"* |
| 5 | `objection` | `$call->lead_spoke` and not booked (`:52`) | *"Answered, no appointment yet"* |
| 6 | `gave_up` | not spoke **AND** `! empty($ctx['call_exhausted'])` (`:55-57`) — the flag the call step set at `AiCall.php:114`/`:170` | *"No answer after the last allowed attempt"* |
| 7 | `no_answer` | everything else (`:59`) | *"No answer"* |

`Call::OUTCOME_*`: `APPOINTMENT = 1`, `CALLBACK = 2`, `NOT_INTERESTED = 3`, `DO_NOT_CALL = 4`, `UNCLEAR = 5` (`Call.php:44-48`).

Compiled plans wire it as: `booked → the booked chain`, `invalid → end.stop{mark_lost:true}`, and `objection` + `no_answer` + `gave_up` all fall through to the next list step (`src/AppointmentEngine/Services/PlanCompiler.php:170-174`).

---

### 3. `condition.answered` → `Runner\Handlers\ConditionAnswered`

Outputs `['yes', 'no']` (`NodeCatalogue.php:495`). Config: `means`, default `'spoke'`; the alternative is `'connected'` (`:498-506`).

| step | code |
|---|---|
| find the call | `Call::where('ae_lead_id')->where('channel','ai')->whereNull('refusal_reason')->latest('id')->first()` (`ConditionAnswered.php:16-17`) — **refused rows are excluded here**, unlike `condition.call_outcome` |
| `null` → | `next('no', 'No call was made')` (`:19-21`) |
| `means === 'connected'` | `$call->status === Call::STATUS_COMPLETED (3)` (`:24`) |
| otherwise | `(bool) $call->lead_spoke` (`:25`) |
| result | `next('yes'|'no', 'They answered'|'They did not answer')` (`:27`) |

The catalogue explains the default: "A connected call where nobody said anything is a pocket-dial or an answering machine. Counting it inflates every figure downstream" (`NodeCatalogue.php:504`). **No writes, no jobs, no notifications.**

---

### 4. `condition.field` → `Runner\Handlers\ConditionField`

Outputs `['yes','no']` (`NodeCatalogue.php:566`). Reads a **lead attribute by name**: `field` (default `'intent'`, options `intent` / `journey_stage` / `budget_amount`), `operator` (default `'is'`; `is` / `at_least` / `at_most`), `value` (required text) (`NodeCatalogue.php:569-583`).

```
$value = $lead->{$field} ?? null;                       // ConditionField.php:16
if ($value === null || $value === '') → next('no', 'Not known yet');   // :21-23
$hit = match (operator) {
    'at_least' => is_numeric($value) && (float) $value >= (float) $target,
    'at_most'  => is_numeric($value) && (float) $value <= (float) $target,
    default    => mb_strtolower(trim((string) $value)) === mb_strtolower(trim($target)),
};                                                       // :25-29
return next($hit ? 'yes' : 'no');                        // :31 — no message
```

The unknown-guard is deliberate and documented: "'budget over 1M' must not match a lead whose budget nobody has asked about" (`:19-20`), echoed in the field help "A lead the AI has not asked yet takes the NO branch — it never guesses an unknown is a zero" (`NodeCatalogue.php:582`). A non-numeric value under `at_least`/`at_most` yields `no`. **No writes.**

---

### 5. `ai.qualify` → `Runner\Handlers\Qualify`

One output, `default` (`NodeCatalogue.php:596`). Config: `overwrite`, toggle, default `false` — "Off means the AI only fills blanks. A budget somebody typed after a real conversation beats one heard on a call" (`:599-601`).

* Source call: `Call::where('ae_lead_id')->where('channel','ai')->whereNotNull('analysis')->latest('id')->first()` (`Qualify.php:29-30`). Data: `$call->analysis['custom_analysis_data'] ?? $call->analysis['custom'] ?? []` — safe when `$call` is null because `??` suppresses the null property fetch, giving `[]` → `next('default', 'Nothing captured on the call')` (`:32-36`).
* **Field map** (`:19-25`) — note two source names collapse onto one lead column:

  | analysis key | lead column |
  |---|---|
  | `intent` | `intent` |
  | `budget` | `budget` |
  | `timeline` | `timeline` |
  | `journey_stage` | `journey_stage` |
  | `buying_journey` | `journey_stage` |

* A value is taken when it `is_string`, `trim(...) !== ''`, and (`overwrite` **or** `blank($lead->{$to})`); it is cut to `mb_substr(…, 0, 120)` (`:41-47`).
* **Derived / validated writes** (`:49-59`):
  * `budget` set → `budget_amount = Lead::parseBudget($set['budget'])`, which understands `800k` / `1.2m` / `RM 750,000`, and returns **`null` below a 10,000 floor** because "a bare '3' is a bedroom count or a hedge, not a budget" (`src/AppointmentEngine/Lead.php:120-142`).
  * `intent` not a key of `Lead::INTENTS` (`investor`, `own_stay`, `upgrader` — `Lead.php:70-74`) → substring-match the captured text against those keys, else `null`.
  * `journey_stage` not a key of `Lead::JOURNEY_STAGES` (`first_time`, `some_experience`, `experienced` — `Lead.php:81-89`) → **unset entirely**.
* `array_filter($set, fn ($v) => $v !== null)` then `$lead->forceFill($set)->save()` — **the only write** (`:61-62`).
* Returns `next('default', 'Captured: a, b, c')` or `'Nothing new to capture'` (`:64`).

---

### 6. `action.set_stage` → `Runner\Handlers\SetStage`

One output, `default`. Config: `stage`, a select over `Lead::STAGES`, default `(string) Lead::STAGE_CALLED` (`NodeCatalogue.php:465-469`).

```
$stage = (int) $node->config('stage');
if (! isset(Lead::STAGES[$stage])) → fail('Unknown stage.');   // SetStage.php:17-19
$lead->forceFill(['stage' => $stage])->save();                 // :21  (NOT monotonic)
return next('default', 'Moved to ' . Lead::STAGES[$stage]['name']);
```

`Lead::STAGES` = `1 New`, `2 AI called`, `3 Answered`, `4 Appointment`, `5 Showed up`, `6 Lost` (`Lead.php:31-51`). Unlike `AiCall`'s `max()` write, this sets the stage **absolutely** — it can move a lead backwards, which is the point of a manual "Move the lead" step.

---

### 7. `action.zoom_invite` → `Runner\Handlers\ZoomInvite`

One output, `default`; `needs => 'whatsapp_channel'` (Zoom itself is connected account-wide server-to-server) (`NodeCatalogue.php:605-621`). Config: `duration` (minutes, 15–120, default **30**) plus the shared WhatsApp send fields `channel_id` (required), `mode` (`template`|`text`, default `template`), `template_id`, `variables`, `body` (`NodeCatalogue.php:617-620` + `whatsappSendFields()`).

**Appointment it acts on:** `Appointment::where('ae_lead_id', $lead->id)->latest('id')->first()` (`ZoomInvite.php:34`) — newest **by id**, which differs from `RecordOutcome`/`ConditionAttended` (newest by `scheduled_at`).

| # | branch of logic | effect | result |
|---|---|---|---|
| 1 | appointment exists **and** has `meeting_link` (`:38-47`) | context `link` = existing `meeting_link`, `zoom_meeting_id`; then `WhatsappSender::send` | `next('default','Zoom link sent')`, or `fail("Link on record, not sent: {$error}")` |
| 2 | no host (`:49-53`) — `$lead->assignedAdmin?->email ?: ZoomCredentialProvider::webinarHostEmail()` | none | `fail('No Zoom host: assign an agent, or set the webinar host on the Zoom integration.')` |
| 3 | create the meeting (`:57-74`) | `ZoomServerService::createMeeting($host, [...])` with `type => 2`, `start_time` = the appointment's `scheduled_at` **when it is in the future**, else `Wait::insideHours(now()->addDay()->setTime(10, 0))` (the legacy next-morning slot); `duration` from config; `timezone` = `app.user_timezone`; `settings => ['join_before_host' => true, 'waiting_room' => false]` | on throw: `report($e)` + `fail('Zoom could not create the meeting: …')` |
| 4 | empty `join_url` (`:76-80`) | none | `fail('Zoom created the meeting but returned no join link.')` |
| 5 | pin (`:84-90`) — **only when the appointment exists and `scheduled_at` is in the future** | UPDATE `appointments`: `type = Booking::typeFor(CHANNEL_ZOOM)` (=2), `zoom_meeting_id`, `meeting_link` (`mb_substr(…, 0, 500)`) | — |
| 6 | send (`:92-96`) | context `link` + `zoom_meeting_id`; `WhatsappSender::send` | `next('default','Zoom link sent')` or `fail("Meeting created, link not sent: {$error}")` |

`{link}` in the message body/variables resolves from `$run->context['link']` (`src/AppointmentEngine/Runner/Text.php:28`). `WhatsappSender::send` itself writes an outbound `whatsapp_messages` row, dispatches `SendWhatsAppMessage`, starts `ChatTakeover`, bumps `ae_leads.last_activity_at`/`first_contacted_at` and calls `CrmPipeline::advanceStatus(STATUS_CONTACTING)` (`src/AppointmentEngine/Runner/WhatsappSender.php:99-111`); it returns a **sentence** on failure and `null` on success (`:35-40, 52-53, 65-66, 80-82`).

---

### 8. `human.record_outcome` → `Runner\Handlers\RecordOutcome`

One output, `default`. Config: `remind_after_hours`, number 1–168, default **24** — "Every rate on the Showroom screen depends on this being filled in" (`NodeCatalogue.php:623-637`).

```
$appointment = Appointment::where('ae_lead_id', $lead->id)->latest('scheduled_at')->first();
```

| # | condition | result | side effect |
|---|---|---|---|
| 1 | `$appointment === null` (`RecordOutcome.php:20-22`) | `next('default', 'No appointment to record')` | none |
| 2 | `Booking::outcomeRecorded($appointment)` (`:24-26`) | `next('default', 'Outcome recorded: {name}')` | none |
| 3 | `$run->waiting_for === WorkflowRun::WAIT_OUTCOME` — the second visit, i.e. the nudge fired (`:29-33`) | `park(WAIT_OUTCOME, null, 'Nudged the team; still waiting')` — **no timer**, so it can only be woken by a real recording | `Notify::team('ae.nudge', 'Appointment outcome not recorded', "{$lead->name}'s appointment has passed with nothing recorded.", $lead)` |
| 4 | first visit (`:35-37`) | `park(WAIT_OUTCOME, max($appointment->scheduled_at ?? now(), now())->addHours($config), 'Waiting for the agent to record the outcome')` | none |

"Chase once, then wait without a timer" (`:28`). The `max(scheduled_at, now())` means a nudge for a past appointment is measured from *now*, not from the (already elapsed) slot.

`Booking::outcomeRecorded()` is `aeOutcome() !== null`, and `aeOutcome()` maps the CRM row into the engine's dialect: `status === Appointment::STATUS_CANCELLED (7) → 'cancelled'`; else `Appointment::OUTCOME_ATTENDED (1) → 'attended'`, `OUTCOME_NO_SHOW (2) → 'no_show'`, anything else → `null` (`src/AppointmentEngine/Support/Booking.php:86-106`). `Booking::OUTCOMES` names them Attended / No show / Cancelled (`:31-35`).

---

### 9. `condition.attended` → `Runner\Handlers\ConditionAttended`

Outputs `['yes','no']`. Config: `unrecorded`, default `'wait'`, alternative `'no'` — "Waiting is the honest default — an unrecorded outcome is not a no-show" (`NodeCatalogue.php:642-657`).

```
$appointment = Appointment::where('ae_lead_id', $lead->id)->latest('scheduled_at')->first();
```

| # | condition | result |
|---|---|---|
| 1 | `$appointment === null` (`ConditionAttended.php:19-21`) | `next('no', 'No appointment')` |
| 2 | `! Booking::outcomeRecorded(...)` **and** `unrecorded === 'no'` (`:27-29`) | `next('no', 'Not recorded — treated as no-show')` |
| 3 | `! Booking::outcomeRecorded(...)` otherwise (`:30`) | `park(WorkflowRun::WAIT_OUTCOME, null, 'Waiting for the outcome')` — no nudge timer |
| 4 | recorded (`:33-35`) | `next('yes')` iff `Booking::aeOutcome(...) === Booking::OUTCOME_ATTENDED ('attended')`, else `next('no')` |

The critical subtlety is called out in the code comment (`:23-26`): **cancelled lives in `status`, not `outcome`, and must read as a recorded "did not attend"** — `aeOutcome()` returns `'cancelled'` for it, so `outcomeRecorded()` is true and the node takes `no` instead of "parking a cancelled appointment [that] would wait forever for an outcome nobody is going to write". **No writes, no jobs, no notifications.**

---

### 10. The three call-side services

#### 10.1 `Services\VoiceCaller`

Places calls on the **agency's own** Retell credentials — "the key, the agent and the caller ID all come from that agency's verified connection, and the call is billed to that agency's Retell account" (`src/AppointmentEngine/Services/VoiceCaller.php:10-21`). Base URL `https://api.retellai.com` (`:24`). **It never throws**: a provider refusal is written onto the row and returned as `false` (`:20-21`).

`place(Connection $connection, Call $call, array $variables = [], ?string $agentId = null): bool` (`:29-66`):

| payload key | value |
|---|---|
| endpoint | `POST /v2/create-phone-call`, bearer `credential('api_key')`, `timeout(20)` |
| `from_number` | `credential('from_number')` |
| `to_number` | `$call->to_number` |
| `override_agent_id` | `$agentId ?: credential('agent_id')` — "The step's profile decides which agent rings; the connection's own agent is only the fallback" |
| `retell_llm_dynamic_variables` | `(object) $variables` |
| `metadata` | `['ae_call_uuid' => $call->uuid, 'ae_group_id' => $connection->group_id]` — "the second key the webhook can match on if the call id is somehow missing" |

Failure paths: a thrown exception → `report($e)` + `settleFailed($call, 'Could not reach the voice provider.')` → `false` (`:45-50`). A non-2xx or missing `call_id` → `Log::warning('Appointment engine: Retell refused the call.', …)` + `settleFailed(… 'The voice provider refused the call' [ + ': ' . message ] )` → `false` (`:52-61`). Success → `provider_call_id` + `status = STATUS_PENDING` (`:63`).

`settleFailed()` writes `status = STATUS_FAILED (7)`, `disconnection_reason = mb_substr($why, 0, 50)`, `analysis = ['placement_error' => $why]`, `ended_at = now()` (`:83-91`). Note this reason is **not** in `ConditionCallOutcome::INVALID`, so a placement failure is never `invalid` — and in practice `AiCall` returns `fail()` on it, so the run stops before any condition runs.

`fetch(Connection $connection, string $providerCallId): ?array` — `GET /v2/get-call/{id}`, `timeout(15)`, null on any failure (`:69-81`).

The connection catalogue for `Connection::CALLER` requires `api_key` (secret, and it doubles as the webhook signing secret), `agent_id`, `from_number` (E.164), and tells the admin to point the Retell agent at `/webhooks/ae/retell` (`src/AppointmentEngine/Connection.php:40-51`). `credential()` decrypts and returns `null` for a missing/empty value; a decrypt failure yields `[]` rather than throwing (`:98-122`).

#### 10.2 `Services\RetellCallMapper`

"The same mapping rules as the host's mapper … so a call means the same thing on both" (`src/AppointmentEngine/Services/RetellCallMapper.php:11-15`).

**`status(array $call): int`** (`:18-32`) — a `match(true)` on `disconnection_reason` first, `call_status` only as a fallback:

| disconnection_reason | `Call::STATUS_*` |
|---|---|
| `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` |
| empty **and** `call_status === 'ongoing'` | `STATUS_IN_PROGRESS = 2` |
| empty **and** `call_status === 'ended'` | `STATUS_COMPLETED = 3` |
| default | `STATUS_FAILED = 7` |

**`settle(Call $row, array $call): void`** (`:35-81`) writes `status`, `disconnection_reason` (50 chars, `null` when empty), `started_at`, `ended_at`, `duration_seconds`, `provider_cost`, `recording_url`, `lead_spoke`. Three guards worth knowing, each from a real bug:

* `Carbon::createFromTimestampMs(...)->setTimezone(config('app.timezone'))` — unshifted, the UTC Carbon lands **8h behind** this `Asia/Kuala_Lumpur` database (`:37-46`).
* `(int) abs($ended->diffInSeconds($started))` — "Carbon 3's `diffInSeconds` is SIGNED … and `max(0, …)` silently zeroed every duration" (`:54-56`).
* `provider_cost = round(call_cost.combined_cost / 100, 4)` — Retell reports **US cents**; telephony is billed separately by the carrier and is not in this payload (`:57-61`).

`lead_spoke` is `collect($turns)->contains(fn ($t) => $t['role'] === 'user')` (`:63`). Turns are **deleted whole and re-inserted** into `ae_call_turns` so Retell's retries are idempotent (`:66-71`). Finally, a payload that already carries `call_analysis` settles **and** analyses in one pass — "dropping this here would lose bookings on every reconcile-after-the-fact path" (`:73-80`).

**`analyse()`** (`:83-93`) writes `analysis` (the whole `call_analysis` block), `outcome`, `analyzed_at = now()`.

**`turns()`** (`:96-117`) keeps only `role ∈ {agent, user}` with non-blank content, assigns `seq` from 0, cuts content to 4,000 chars, and derives `offset_ms` from `words[0].start × 1000`.

**`outcome()`** (`:119-151`), in order:

1. `$text` = lowercased join of `custom.outcome`, `custom.result`, `custom.appointment_booked` (scalars only).
2. `'do not call'` / `'do_not_call'` in `$text` → `OUTCOME_DO_NOT_CALL (4)`.
3. **The goal's own reading**, `AppointmentObjective::verdict($custom)` (`:133-142`): `RESULT_MET → OUTCOME_APPOINTMENT`, `RESULT_CALLBACK`/`RESULT_HUMAN → OUTCOME_CALLBACK`, `RESULT_DECLINED → OUTCOME_NOT_INTERESTED`, `RESULT_UNCLEAR → null` (falls through). `verdict()` itself is ordered: booked-or-time → `met`; `wants_human` → `human`; `callback_time` filled → `callback`; a `main_objection` matching `/not interested|no interest|not keen|没兴趣|沒興趣|不感兴趣|不要了|do not call|don't call/u` → `declined`; else `unclear` (`AppointmentObjective.php:180-201`). "Filled" excludes `['', 'none', 'unknown', 'null', 'n/a', 'na', '没有', '无', '沒有', '無']` (`:42`, `:317-320`).
4. Free-text net for goal-less brains (`:144-150`): `appointment|booked|'true'|'yes'` → `APPOINTMENT`; `callback|call back` → `CALLBACK`; `not interested` → `NOT_INTERESTED`; any other non-empty text → `UNCLEAR (5)`; empty → `null`.

#### 10.3 `Services\ZoomMeetings`

"The ONE place an engine booking gets its Zoom meeting" (`src/AppointmentEngine/Services/ZoomMeetings.php:14-27`). It was `AiCall`'s private method until the chat path (`RecordWhatsappBooking`) shipped "Join link to follow" because nobody made the meeting (2026-09-08).

`ensure(Appointment $appointment, Lead $lead): ?string` (`:48-93`):

1. `Booking::channelFor($appointment->type) !== CHANNEL_ZOOM` → `null` (no-op for a showroom visit). `channelFor()` treats **every** non-`TYPE_VIDEO_CALL` type as showroom (`Booking.php:58-61`).
2. `$appointment->meeting_link` already set → return it. Never duplicates a meeting.
3. Host = `$lead->assignedAdmin?->email ?: $credentials->webinarHostEmail()`; blank → `RuntimeException` (caught below).
4. `ZoomServerService::createMeeting($host, ['topic' => "{project|'Property'} — {name|'Lead'}", 'type' => 2, 'start_time' => scheduled_at→UTC 'Y-m-d\TH:i:s\Z', 'duration' => config('appointment_engine.zoom.meeting_minutes', 30), 'timezone' => app.user_timezone, 'settings' => ['join_before_host' => true, 'waiting_room' => false]])`. `AE_ZOOM_MEETING_MINUTES` defaults to **30**, and is a config constant rather than a step field because "the ai_call drawer is already nine fields deep" (`config/appointment_engine.php:43-47`).
5. Empty `join_url` → `RuntimeException`.
6. Success → UPDATE `appointments.zoom_meeting_id`, `meeting_link` (500 chars) and return the link.
7. **Any `Throwable` is absorbed** (`:86-92`): `report($e)` + `Notify::team('ae.nudge', 'Zoom meeting not created', 'The AI booked a Zoom appointment but the meeting could not be created — make it by hand and paste the link.', $lead)` → `null`. The rationale: "the appointment already exists and outlives any Zoom outage — channel=zoom with a NULL link is the Appointments screen's 'no link yet' marker". `Booking::zoomLinkPending()` is the predicate that surfaces it (`Booking.php:115-119`).

---

### 11. How a parked call run wakes up

Three paths reach a run parked on `WAIT_CALL`, and the ordering rule is the same in all of them: **a call the lead spoke on must not wake the run before its analysis arrives**, or the booking is lost forever.

1. **`POST /webhooks/ae/retell`** (`app/Http/Controllers/Webhooks/AppointmentEngineRetellController.php`). Retell signs with the **API key, which is per-agency**, so the row is found first by `provider_call_id` from the *unverified* body, the agency's key is taken from that row's `Connection`, and only then is the signature checked; an unknown call id is a bare `204` (`:16-45`). Events: `call_started` → `STATUS_IN_PROGRESS` + `started_at` (only from `PENDING`); `call_ended` → `settle()`, and wake **only if `! lead_spoke || analyzed_at !== null`** (`:54-67`); `call_analyzed` → `analyse()` + wake.
2. **`RetellWebhookBridge::handle()`** (`src/AppointmentEngine/Services/RetellWebhookBridge.php`) — the host CRM's Retell webhook handing AE-owned calls over, since AE calls live in `ae_calls` not `ai_voice_calls` and were previously dropped as "unknown call", leaving the run to settle only via the 15-minute backstop. `call_ended` → settle (+ analyse if the payload carries `call_analysis`); `call_analyzed` → settle when `ended_at` is null, then analyse; anything else is consumed with no wake. Errors are reported and swallowed — a 5xx would make Retell retry an event the backstop already covers (`:80-84`).
3. **The park nudge itself** — `AiCall` re-enters at `now() + max_call_minutes + 5` (15 min default) and calls `reconcile()`, which does `VoiceCaller::fetch()` and, when `call_status === 'ended'`, runs `settle()` and then `analyse()` if `call_analysis` is present (`AiCall.php:324-341`).

---

## 5. Message-side and ending handlers

Everything below lives under `src/AppointmentEngine/Runner/`. A handler is one class implementing `NodeHandler` (`src/AppointmentEngine/Runner/NodeHandler.php:13`) with a single method:

```php
public function execute(WorkflowRun $run, WorkflowNode $node, Lead $lead): StepResult;
```

**The runner owns movement; handlers own effects** (`src/AppointmentEngine/Runner/WorkflowRunner.php:14`). A handler does its one thing and reports what happened; `WorkflowRunner::apply()` decides where the lead goes.

#### The five things a handler may return

`src/AppointmentEngine/Runner/StepResult.php:13` — a private constructor plus five named factories, so a handler physically cannot return anything else.

| Factory | `kind` | What the runner does (`WorkflowRunner.php:176-236`) |
|---|---|---|
| `StepResult::next($branch = 'default', $message)` | `'next'` | Logs `done`, finds the `WorkflowEdge` with `from_node_id = node` **and** `branch = $branch`. **No edge → the run finishes `done`, not an error** (`WorkflowRunner.php:181-186`). Otherwise moves `current_node_id`, sets `RUNNING`, `steps + 1`, clears `waiting_for` + `resume_at`, and keeps looping. |
| `StepResult::wait(Carbon $until, $message)` | `'wait'` | Status `waiting`, `resume_at = $until`, **`waiting_for = null`**, `steps + 1`, logs `waiting`, dispatches `AdvanceWorkflowRun` delayed to `$until`. The **same node runs again** when it wakes. |
| `StepResult::park(string $waitingFor, ?Carbon $until, $message)` | `'park'` | Status `waiting`, `waiting_for = $waitingFor`, `resume_at = $until` (may be null), logs `parked`, dispatches the delayed job **only if `$until !== null`**. |
| `StepResult::end($status = 'done', $message)` | `'end'` | `finish()` — status, `finished_at`, clears `waiting_for`/`resume_at`. |
| `StepResult::fail($message)` | `'fail'` | `finish()` with `WorkflowRun::STATUS_FAILED` and `last_error` truncated to 250 chars. |

A handler that **throws** is caught in `WorkflowRunner::execute()` (`WorkflowRunner.php:159-168`), `report()`ed, and converted to `StepResult::fail('Step failed: ' . $e->getMessage())` — never silently skipped.

Node type → handler is a flat map in `src/AppointmentEngine/Runner/HandlerRegistry.php:13-39`; `HandlerRegistry::missing()` (`:50`) exists purely so a test can prove `NodeCatalogue::types()` and the map never drift.

Two clocks move a parked/waiting run: the delayed `AdvanceWorkflowRun` job (overlap-locked per run, `app/Jobs/AppointmentEngine/AdvanceWorkflowRun.php:34`) and the every-minute sweep `ae:run-workflows` (`app/Console/Commands/AppointmentEngine/RunWorkflows.php:28-51`, scheduled at `app/Console/Kernel.php:209`) — belt and braces, because a queue restart loses delayed jobs.

---

### 1. `action.whatsapp` — `SendWhatsapp` + `WhatsappSender`

`src/AppointmentEngine/Runner/Handlers/SendWhatsapp.php` is a 4-line shell: it hands `$node->config ?? []` (the **raw** config array, not the `config()` accessor) to `WhatsappSender::send()` and translates the result.

| Return of `WhatsappSender::send()` | StepResult |
|---|---|
| `null` (success) | `next('default', 'WhatsApp queued')` |
| a sentence (failure) | `fail($error)` — **the whole run stops FAILED** |

#### 1.1 Config the sender reads

Fields come from `NodeCatalogue::whatsappSendFields()` (`src/AppointmentEngine/NodeCatalogue.php:91-119`), shared by `action.whatsapp` (`:315`) and `action.zoom_invite` (`:605`).

| Key | Type | Default | Read at |
|---|---|---|---|
| `channel_id` | channel picker, **required** | `null` | `WhatsappSender.php:37` |
| `mode` | select: `template` \| `text` | `'template'` | `WhatsappSender.php:64` |
| `template_id` | approved template on that channel | `null` | `WhatsappSender.php:75` |
| `variables` | ordered list of template params | `[]` | `WhatsappSender.php:86` |
| `body` | free text (only used in `text` mode) | `null` | `WhatsappSender.php:71` |

`WorkflowNode::missingConfig()` (`src/AppointmentEngine/WorkflowNode.php:139-147`) enforces the mode-dependent half at publish time: a step is "sending WhatsApp" iff its field list contains a `template_id` key — deliberately **not** merely having a `mode` field, because `action.assign_agent` has a `mode` too.

#### 1.2 The send, guard by guard, in order

`src/AppointmentEngine/Runner/WhatsappSender.php:35-114`. Order matters — each guard returns a sentence a human can act on.

1. **Channel** (`:37-41`) — `WhatsappChannel::find($config['channel_id'])`; must exist and be `WhatsappChannel::STATUS_CONNECTED = 3`. Else → `'The WhatsApp number this step sends from is not connected.'`
2. **Recipient resolution** (`:43-62`) — branches on `blank($lead->phone)`; see §1.4.
3. **Mode** (`:64`) — `($config['mode'] ?? 'template') === 'text'` selects the free-text branch; **anything else falls through to template**.
4. **Free-text branch** (`:65-73`)
   - `$conversation->canSendFreeform()` must be true, else → `'The 24-hour window is closed for this person — WhatsApp will only deliver an approved template now.'`
   - Payload: `type = WhatsappMessage::TYPE_TEXT (1)`, `body = Text::fill($config['body'], $lead, $run)`, `meta = ['source' => 'ae_workflow', 'run' => $run->uuid]`.
5. **Template branch** (`:74-97`)
   - The template must match **all three** of `id = template_id`, `channel_id = $channel->id`, `status = WhatsappTemplate::STATUS_APPROVED (1)`. Else → `'The template is no longer approved on this number.'` Note the channel is the **resolved** one, so a hidden-number lead needs the template approved on *their* channel.
   - Params: `array_values((array) $config['variables'])` each passed through `Text::fill()` — positional, so the stored order is the `{{1}}, {{2}}, …` order.
   - Payload: `type = WhatsappMessage::TYPE_TEMPLATE (10)`, `body = $template->name`, `meta.template = ['name', 'language', 'parameters']`.
6. **Write + dispatch** (`:99-100`) — `WhatsappRepository::createOutbound()` creates the row `STATUS_QUEUED (1)`, `DIRECTION_OUT (2)`, and bumps `last_activity_at` / `last_message_preview` on the conversation (`src/Whatsapp/Repositories/WhatsappRepository.php:1423-1456`); then `SendWhatsAppMessage::dispatch($message)` delivers through the channel's driver.
7. **Chat takeover** (`:105`) — `ChatTakeover::begin($run, $lead, $conversation)`; see §3.
8. **Lead stamps** (`:107`) — `last_activity_at = now()`, and `first_contacted_at` set **only if still null** (`$lead->first_contacted_at ?? now()`).
9. **Pipeline** (`:111`) — `CrmPipeline::advanceStatus($lead, Engagement::STATUS_CONTACTING /* 2 */)`.

The class comment (`:18-25`) states the design rule: it sends **the way the host inbox does** — same repository, same job, same drivers — so a workflow message is indistinguishable from an agent's and lands in the same thread.

#### 1.3 Template vs free text, and the 24-hour window

The window is **not** enforced by AE. `WhatsappConversation::canSendFreeform()` (`src/Whatsapp/WhatsappConversation.php:150-160`) is the single authority:

```php
if ($this->channel && ! $this->channel->isCloudApi()) {
    return true;                       // Bridge (Baileys) + Sandbox are free-form anytime
}
return $this->last_inbound_at !== null
    && $this->last_inbound_at->diffInHours(now()) < 24;
```

So:

| Channel kind | `mode: 'text'` | `mode: 'template'` |
|---|---|---|
| Cloud API | only while `last_inbound_at` is < 24h old — otherwise the **step fails and the run stops** | always allowed (subject to APPROVED) |
| Bridge / Sandbox | always allowed | allowed |

The catalogue's help text says the same thing to the admin twice — on the `mode` select (`NodeCatalogue.php:104`) and on `body` (`:323`: *"otherwise this step fails and the workflow stops here"*).

This is exactly why `end.booked` hard-codes `'mode' => 'template'` for both its sends (§7): a booking can arrive from a phone call, and a reminder fires hours later — neither can rely on an open window.

#### 1.4 A hidden-number (username) lead

WhatsApp can hide a person's number; those leads exist with `phone = null` and a `wa_contact_id` instead (`src/AppointmentEngine/Lead.php:94` fillable, relation `waContact()` at `:163-166`; `Triggers::lead()` matches by `wa_contact_id` when there is no phone — `src/AppointmentEngine/Runner/Triggers.php:566-569`, and names such a lead `'Hidden number'` at `:597`).

`WhatsappSender.php:43-56`:

```php
if (blank($lead->phone)) {
    // A hidden-number (username) lead is reachable ONLY through the
    // conversation they started — their WhatsApp contact is the
    // identity, and its own channel wins over the step's.
    $contact = $lead->waContact;
    $conversation = $contact
        ? WhatsappConversation::where('contact_id', $contact->id)->latest('updated_at')->first()
        : null;

    if ($conversation === null) {
        return 'The lead has no phone number and no WhatsApp conversation to reply into.';
    }

    $channel = $conversation->channel ?? $channel;
}
```

Three consequences a maintainer must keep:

- **The conversation is looked up by contact only** — not by `channel_id` — and the newest by `updated_at` wins.
- **The conversation's channel overrides the step's.** This is not a convenience; `RecipientResolver::resolve()` (`src/Whatsapp/Support/RecipientResolver.php:25-49`) can only address a phone-less contact as `{lid}@lid` on a Bridge channel or a `wa_user_id` BSUID on Cloud API. Sending the same contact out of a different channel would resolve to `null` and the message would be recorded FAILED with *"Contact has no reachable address on this channel (phone hidden by WhatsApp privacy)"* (`app/Jobs/Whatsapp/SendWhatsAppMessage.php:108-110`). The resolver's own comment warns the Cloud API's digit-stripping helper *"would mangle a lid into a stranger's phone number."*
- **Template mode still applies** after the channel swap, so the template must be approved on the conversation's channel, not the step's.

The normal path (`:57-62`) instead does `WhatsappContact::firstOrCreate(['phone_e164' => $lead->phone], ['name' => $lead->name])` and `WhatsappRepository::conversationFor($channel, $contact)` — "the same contact row the inbox would use for this person, so the message lands in the thread an agent will open." `conversationFor()` additionally best-effort links an unlinked non-sandbox contact into the CRM via `ContactLinker` (`WhatsappRepository.php:1315-1324`).

#### 1.5 Side effects, summarised

| Effect | Where |
|---|---|
| `whatsapp_messages` row (QUEUED, OUT) + conversation preview/activity | `WhatsappSender.php:99` |
| `SendWhatsAppMessage` queued (3 tries, 10s backoff, 160s timeout) | `WhatsappSender.php:100`, `app/Jobs/Whatsapp/SendWhatsAppMessage.php:38-53` |
| Possible new `WhatsappContact` + `WhatsappConversation` (+ CRM lead link) | `WhatsappSender.php:60-61` |
| Chat takeover host flow run started | `WhatsappSender.php:105` |
| `ae_leads.last_activity_at`, `first_contacted_at` (once) | `WhatsappSender.php:107` |
| Engagement status → CONTACTING (monotonic) | `WhatsappSender.php:111` |

---

### 2. `action.whatsapp_ai` — `WhatsappAiTakeover`

`src/AppointmentEngine/Runner/Handlers/WhatsappAiTakeover.php`. **The step never talks itself** (`:27`): it dispatches a *host* proactive flow and parks. The booking comes back as an appointment row written by `RecordWhatsappBooking`, which also resumes the run.

Two constants (`:36-39`):

| Constant | Value | Meaning |
|---|---|---|
| `RECHECK_HOURS` | `6` | how often a parked run re-checks the conversation's fate |
| `ENGAGE_GRACE_MINUTES` | `30` | how long after dispatch a missing host run means "never engaged" |

Outputs are `['booked', 'no_booking']` (`NodeCatalogue.php:400-435`) — two ways out, like a condition.

#### 2.1 Config

| Key | Type | Default | Notes |
|---|---|---|---|
| `channel_id` | channel, required | `null` | must be CONNECTED **and** `is_active` (`:51`) |
| `flow_id` | flow, required | `null` | a `WhatsappFlow` on that channel |
| `default_meeting_type` | `showroom` \| `zoom` | `'showroom'` | **not read by this handler** — read by `RecordWhatsappBooking` when the lead did not say (`app/Listeners/AppointmentEngine/RecordWhatsappBooking.php:69-72`) |
| `give_up_hours` | number 1–336 | `48` | `max(1, (int))` at `:105` |
| `knowledge_id` | content (kind `knowledge`) | `null` | `ContentItem` uuid, must be `STATUS_ACTIVE = 'active'` |

#### 2.2 First entry — guards in order (`:41-121`)

1. `! empty($ctx['wa_booking'])` → this is a **re-entry**; jump to `settle()` (§2.3).
2. **Channel**: exists, `STATUS_CONNECTED`, `is_active` → else `fail('The WhatsApp number this step chats from is not connected.')`
3. **Flow re-validated at run time** (`:58-63`) — `flow_id` **on that channel**, `isActive()`, `isProactiveTrigger()` (trigger is SHEET or CAMPAIGN, `src/Whatsapp/WhatsappFlow.php:160-163`), `hasAiProfile()`, and non-blank `objective`. The comment explains why the publish gate is not enough: *"A run outlives edits, so re-checked here."* → `fail('The conversation flow on this step is missing, inactive, or has no AI profile + objective.')`
4. **Phone** (`:66`) — `blank($lead->phone)` → `fail('The lead has no phone number.')`. This step has **no** hidden-number path.
5. **Consent** (`:72-76`) — if a contact exists and `isBlocked()` (`blocked_at !== null`) or `isOptedOut(WhatsappConsent::CATEGORY_MARKETING /* 1 */)` → `next('no_booking', 'They opted out of WhatsApp — not messaging')`. These are read-only pre-checks; `StartProactiveFlow` enforces them again at send time (`app/Jobs/Whatsapp/StartProactiveFlow.php:153-157`). Checking here *"turns a silent skip into a routed branch with a reason on the ledger."*
6. **Human ownership** (`:82-84`) — `$conversation->isAiHandedOff()` (`ai_handoff_at !== null`) → `next('no_booking', 'A human owns this WhatsApp thread — not automating over them')`.
7. **Slot** (`:88-92`) — a RUNNING `WhatsappFlowRun` of the **same `flow_type`** on that conversation → `wait(now()->addHour(), 'Already in another WhatsApp automation — trying again in an hour')`. Deliberately a `wait`, not the `no_booking` branch: *"try again in an hour rather than burning the branch — bounded by give-up."*
8. **Dispatch** (`:94-103`) — `StartProactiveFlow::dispatch($flow->id, ['phone', 'name'], $this->variables($node, $lead), ['started_by' => 'ae_workflow', 'capture' => 'booking', 'ae' => ['run' => $run->uuid, 'lead' => $lead->uuid, 'node' => $node->uuid]])`. The `ae.run` key is the **primary correlation** the booking listener uses. (`StartProactiveFlow` merges its own `started_by` first, so the AE value wins — `StartProactiveFlow.php:197-200`.)
9. **Context** (`:107-112`) — writes `context.wa_booking = {flow_id, channel_id, dispatched_at (ISO8601), deadline (ISO8601)}`.
10. Lead stamps + `CrmPipeline::advanceStatus(..., STATUS_CONTACTING)` (`:114-118`), identical to `SendWhatsapp`.
11. `park(WorkflowRun::WAIT_WA_BOOKING /* 'wa_booking' */, $this->nudge($deadline), 'WhatsApp AI is chatting with them')`.

`nudge()` (`:212-217`) = `min(now()+6h, deadline)` — the parked run always wakes exactly at the deadline at the latest.

`variables()` (`:228-245`) builds the **rendered/prompted** bag: `name`, `project`, `agent_name` (from `assignedAdmin->profile->full_name`), each dropped when empty, plus `project_details` = the knowledge item's body flattened (`flatten()` at `:251-268` renders `Q:/A:` and `section: says` shapes) and truncated to **6000** characters. Its docblock states the rule: *"Scalars only; internal control data travels in run META, never here (meta is never rendered or prompted)."*

#### 2.3 Re-entry: `settle()` (`:132-192`) — four checks, in order

| # | Check | Result |
|---|---|---|
| 1 | `! empty($state['appointment_id'])` **OR** an `Appointment` with `ae_lead_id = lead`, `source = Booking::SOURCE_AI ('ai')`, `ae_call_id IS NULL`, `created_at >= dispatched_at` | `clear()` then `next('booked', 'The WhatsApp AI booked an appointment')`. Belt and braces — the row is written **before** the resume, so this never misses. |
| 2 | No host `WhatsappFlowRun` for `(flow_id, contact_id, started_at >= dispatched_at − 1 min)` | past `dispatched_at + 30 min` → `next('no_booking', 'The WhatsApp conversation never started — see the Messages log')`; otherwise `park(WAIT_WA_BOOKING, now()+30min, 'Waiting for the conversation to start')` |
| 3 | Host run found but `! isRunning()` | `next('no_booking', …)` — message is *'The AI ended the chat without capturing a time — check the thread'* when `ended_reason === ENDED_OBJECTIVE ('objective')`, else *'The chat ended without a booking (reason)'* |
| 4 | Still running, `now() >= deadline` | `next('no_booking', 'No booking within {give_up_hours}h — moving on')`. **The chat is not cut off** — a booking agreed later still writes its row, where a downstream `condition.booked` can see it (`:183-184`). |
| — | otherwise | `park(WAIT_WA_BOOKING, nudge($deadline), 'Still chatting — checking again later')` |

`clear()` (`:202-206`) unsets `context.wa_booking` because *"The step's context is shared run-wide; a later whatsapp_ai node must start fresh, not inherit this one's conversation."*

#### 2.4 What wakes it

`app/Listeners/AppointmentEngine/RecordWhatsappBooking.php` (queued, `tries = 3`):

- correlates by `meta.ae.run` first, else falls back to (channel's `group_id` + contact's `phone_e164`) → newest `ae_leads` row (`:41-58`);
- `Appointment::firstOrCreate()` keyed on `wa_flow_run_id` when a host run exists — *"a retried event or a re-negotiated time updates rather than duplicates"* — with `ae_call_id` left NULL by design (`:74-106`);
- `CrmPipeline::advanceStatus($lead, Engagement::STATUS_APPOINTMENT_SET /* 3 */)` (`:110`);
- writes `context.wa_booking.appointment_id` and logs a `booking` line (`:112-120`);
- `WorkflowRunner::resume($lead, WorkflowRun::WAIT_WA_BOOKING)` (`:122`);
- **and pulls every timer forward** (`:129-137`): any WAITING run of that lead with a future `resume_at` gets `resume_at = now()` and an immediate `AdvanceWorkflowRun`, so the plan's `condition.booked` gate runs *now* and the nurture that would have followed never goes out.

`WorkflowRunner::resume()` (`WorkflowRunner.php:119-130`) only touches runs whose `status = waiting` **and** `waiting_for = $event`; it nulls both `waiting_for` and `resume_at`, logs `resumed`, and dispatches.

---

### 3. `ChatTakeover` — the chat brain, started from a send

`src/AppointmentEngine/Services/ChatTakeover.php`. This is the mechanism behind the *plan editor's* chat section (the `action.whatsapp_ai` node above is the older canvas-era node; `PlanCompiler` never emits it).

The design (`:12-20`): the takeover is a **real host flow run** of the workflow's **ShadowFlow**, so the host machinery owns everything afterwards — the AI answers replies, a captured booking ends the run `ENDED_OBJECTIVE`, an inbox handoff ends it `ENDED_HANDOFF`, and the run row is the Chats tab's ledger.

`ShadowFlow` (`src/AppointmentEngine/Services/ShadowFlow.php`) creates **one hidden host flow per AE workflow**: zero steps, `TYPE_TIME_BASED (1)`, `TRIGGER_CAMPAIGN (3)`, `ai_profile_id` from `plan.chat.profile_id`, `objective` from `plan.chat.objective`, named `'AE · {workflow name}'`, marked `settings.ae_workflow = {workflow uuid}` (`:69-79`). It is ACTIVE only while the workflow is live **and** chat is on; otherwise INACTIVE — **never deleted**, because *"its run history is the Chats tab's memory"* (`:31-34`). `PlanCompiler::compile()` calls `ShadowFlow::sync()` after every compile (`src/AppointmentEngine/Services/PlanCompiler.php:206`).

#### 3.1 `begin(WorkflowRun $aeRun, Lead $lead, WhatsappConversation $conversation)` — `:34-89`

Wrapped in `try/catch` + `report()` — *"a takeover hiccup must never fail the send."* Four refusals, in order:

1. `$workflow === null || ! data_get($workflow->plan, 'chat.enabled')` → return (`:39`).
2. `ShadowFlow::for($workflow)` is null or `! isActive()` → return (`:43-47`).
3. **Slot occupied** (`:51-57`) — any RUNNING `WhatsappFlowRun` with `flow_type = TYPE_TIME_BASED` on this conversation. *"The host allows ONE running TIME_BASED drip per conversation. Ours already running → nothing to do; someone else's → theirs."*
4. **Silenced** (`:63-69`) — any run of **this shadow flow** on this conversation whose `ended_reason` is `ENDED_STOPPED ('stopped')` or `ENDED_HANDOFF ('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 (`:74-85`):

```php
$run = $repository->startRun($shadow, $conversation, [
    'started_by' => 'ae_workflow',
    'capture' => 'booking',
    'ae' => ['run' => $aeRun->uuid, 'lead' => $lead->uuid, 'workflow' => $workflow->uuid],
], array_filter([
    'name' => (string) $lead->name,
    'project' => (string) ($lead->aeProject?->name ?: $lead->project),
]));

$repository->completeDrip($run);
```

`completeDrip()` (`src/Whatsapp/Repositories/WhatsappFlowRepository.php:251-260`) stamps `drip_completed_at = now()` and nulls `next_step_at`. The comment explains why it is called immediately: *"No drip to walk — the shadow has zero steps. Straight to the AI phase, so the brain answers the very first reply."* That flag is also the phase marker the two stop-halves below filter on.

#### 3.2 The three stop halves

These back the inbox card's three buttons (`app/Http/Controllers/Manage/AppointmentEngine/LeadsController.php:803-864`; routes `manage…leads.stop-sequence` / `.stop-chat` / `.stop-automation` at `routes/web.php:2001-2003`, all declared before `GET leads/{id}`).

| Method | What it ends | Filter |
|---|---|---|
| `stopSequence(Lead, string $reason)` `:101-131` | every open `WorkflowRun` (`pending`/`running`/`waiting`) → status `stopped`, `waiting_for = null`, `resume_at = null`, `last_error = $reason`, plus a `stopped` log line; **and** host drips still in the SCRIPTED phase | `whereNull('drip_completed_at')` → `endRun(ENDED_STOPPED)` |
| `stopChat(Lead)` `:142-153` | every flow run that is **answering as AI** | `whereNotNull('drip_completed_at')` → `endRun(ENDED_STOPPED)` |
| `stopForLead(Lead, $reason)` `:163-172` | both, returning `['runs', 'drips', 'chats']` | — |

`leadFlowRuns()` (`:181-194`) is the shared raw material: RUNNING flow runs whose conversation matches the lead — **by `contact_id = wa_contact_id` when the phone is blank, else by the contact's `phone_e164`** — and an early `collect()` when the lead has neither.

Note the asymmetry that is intentional: stopping the *sequence* leaves the chat AI answering; stopping the *chat* leaves the scheduled sequence running.

---

### 4. `action.wait` — `Wait`, and business-hours rolling

`src/AppointmentEngine/Runner/Handlers/Wait.php`. Outputs `['default']`.

| Key | Type | Default | Source of the value |
|---|---|---|---|
| `amount` | number 1–999 | `1` | plan step's `wait.value` |
| `unit` | `minutes` \| `hours` \| `days` | `'hours'` | plan step's `wait.unit` |
| `business_hours` | toggle | `true` | plan step's `wait.business_hours` |
| `window_start` | `'H:i'` | *(not in the catalogue)* | stamped by `PlanCompiler` from `plan.hours.start` |
| `window_end` | `'H:i'` | *(not in the catalogue)* | stamped by `PlanCompiler` from `plan.hours.end` |

#### 4.1 The two-visit protocol

```php
// Second visit: the sleep is over.
if (($run->context['wait_node'] ?? null) === $node->id) {
    $run->forceFill(['context' => array_diff_key($run->context ?? [], ['wait_node' => 1])])->save();
    return StepResult::next('default', 'Waited');
}
```
(`Wait.php:16-21`)

Otherwise it computes `$until = now()->add((int) $node->config('amount'), (string) $node->config('unit'))` — Carbon swaps a numeric first argument, so `add(3, 'hours')` is `addHours(3)` (`vendor/nesbot/carbon/src/Carbon/Traits/Units.php:288-290`) — optionally rolls it (below), writes `context.wait_node = $node->id`, and returns `StepResult::wait($until)` **with no message**, so the runner logs the default `'Waiting until D j M H:i'` in the app user timezone (`WorkflowRunner.php:206`).

A consequence worth knowing: because the second visit is keyed on the node id in context, **anything that pulls `resume_at` forward makes the wait end early and cleanly** — which is exactly how a chat-brain booking cancels a pending nurture (`RecordWhatsappBooking.php:129-137`).

#### 4.2 `insideHours()` — the rolling rule

```php
public static function insideHours(Carbon $at, string $start = '09:00', string $end = '21:00'): Carbon
{
    $local = $at->copy()->timezone(config('app.user_timezone', 'Asia/Kuala_Lumpur'));
    [$sh, $sm] = array_map('intval', explode(':', $start));

    // Zero-padded 'H:i' strings order exactly like the times they name.
    $hm = $local->format('H:i');

    if ($hm < $start) {
        $local->setTime($sh, $sm);          // before opening  → TODAY's opening
    } elseif ($hm >= $end) {
        $local->addDay()->setTime($sh, $sm); // at/after closing → TOMORROW's opening
    }

    return $local->timezone(config('app.timezone'));
}
```
(`Wait.php:53-68`)

Points a maintainer must not "fix" by accident:

- **It is a public static** — deliberately reusable, and it is the *only* rolling implementation in this handler.
- **String comparison of zero-padded `H:i` is intentional** and documented (`:58`); do not convert to Carbon comparisons "for safety" without re-reading the plan's guarantees below.
- **Timezone in, timezone out.** The window is expressed in `app.user_timezone` (default `Asia/Kuala_Lumpur`), and the result is converted back to `config('app.timezone')` before it becomes `resume_at`.
- **A moment already inside the window is untouched.**
- **It never skips weekends or holidays** — "business hours" here means only the daily window.
- **Only the wake moment is rolled, not the next step's clock.** The docblock (`:44-47`): *"The caller's clock for the NEXT step starts when the step actually runs, so '2am lands at 10am, and the next wait counts from the send' needs nothing more than this."*
- **The window can never be overnight**, which is why the `elseif` needs no wrap-around case: `Plan::hours()` rejects anything where `$start >= $end` (or malformed `H:i`) and falls back to `09:00`–`21:00` (`src/AppointmentEngine/Support/Plan.php:193-204`).
- **Nodes compiled before the window was per-plan carry no `window_start`/`window_end`** and fall back to the literal `'09:00'`/`'21:00'` in the handler (`Wait.php:25-34`) — the catalogue label still reads "Only count 9am–9pm" (`NodeCatalogue.php:446`).

The same `plan.hours` values are also stamped onto `action.ai_call` as `call_start`/`call_end` (`PlanCompiler.php:164-165`), so the dial window and the wait window are one setting.

---

### 5. `condition.replied` — `ConditionReplied`

`src/AppointmentEngine/Runner/Handlers/ConditionReplied.php`. Outputs `['yes', 'no']`. One config key: `within_hours`, number 1–168, **default 24** (`NodeCatalogue.php:546-557`).

Algorithm (`:16-38`), in order:

1. Read `context.replied_since`; if absent, set it to `now()` and persist with `$ctx + ['replied_since' => …]` (array **union**, so an existing key is never overwritten).
2. `replied($lead, $since)` → `yes`, message `'They replied'`. Checked **before** the wait, *"unless a reply is already in, which needs no waiting at all"* (`:22-23`).
3. If `since + within_hours` is still future → `wait($since + within_hours, "Waiting up to {$hours}h for a reply")` — the node re-runs at the window's close.
4. Otherwise → `no`, message `"No reply within {$hours}h"`.

`replied()` (`:40-48`) is `WhatsappContact::where('phone_e164', $lead->phone)->first()` plus an existence check on `WhatsappMessage` with `direction = 1` (`WhatsappMessage::DIRECTION_IN`, `src/Whatsapp/WhatsappMessage.php:22`) on any conversation of that contact, `created_at >= $since`. Note the literal `1` rather than the constant, and that "reply" means **any inbound WhatsApp message**, on any channel — not a reply to the message this workflow sent.

`PlanCompiler` never emits this node type; it survives from the canvas era.

---

### 6. `condition.booked` — `ConditionBooked`

`src/AppointmentEngine/Runner/Handlers/ConditionBooked.php`, 11 lines. Outputs `['yes', 'no']`. One config key `since`: `'run'` (default) = *appointments made since this workflow started*, `'any'` = *any appointment on record* (`NodeCatalogue.php:530-545`).

```php
$query = Appointment::where('ae_lead_id', $lead->id);

if ($node->config('since') !== 'any' && $run->started_at) {
    $query->where('created_at', '>=', $run->started_at);
}

$yes = $query->exists();

return StepResult::next($yes ? 'yes' : 'no', $yes ? 'Appointment is set' : 'No appointment yet');
```

It is **never** a wait — it answers instantly on whatever is in the book. It does not filter by `status` (a cancelled appointment still counts as booked) nor by `source`.

`PlanCompiler` emits one of these **before every message step** (`PlanCompiler.php:180-182`), wired `yes → $bookedEntry`, `no → the message`, so a lead the chat brain booked in the meantime never receives the next nurture.

---

### 7. `end.booked` — `EndBooked`, the whole booked chain

`src/AppointmentEngine/Runner/Handlers/EndBooked.php`. Despite the key, `NodeCatalogue.php:672-705` classifies it as **`GROUP_ACTION` with `outputs: ['default']` and `may_end: true`** — the comment explains: *"Kept under its original key — nodes already on canvases point at it — but it is an ACTION, not an end: booking is a step, with a confirmation and a reminder, and the closing stage follows it."*

#### 7.1 Config

| Key | Type | Catalogue default | Plan default (`Plan::defaults()` `:77`) |
|---|---|---|---|
| `channel_id` | channel, required | `null` | from `booked.channel_id` |
| `variables` | template params | `['{name}', '{link}', '{project}']` | `['{name}', '{date}', '{time}', '{venue}', '{venue_link}']` |
| `confirm_template_id` | template | `null` | from `booked.confirm_template_id` |
| `reminder_hours_before` | number 0–168 (`0` = no reminder) | `16` | `16` |
| `reminder_template_id` | template | `null` | from `booked.reminder_template_id` |
| `showroom_address` | *(not a catalogue field)* | — | stamped by `PlanCompiler.php:95`, ≤200 chars |
| `showroom_note` | *(not a catalogue field)* | — | stamped by `PlanCompiler.php:96`, ≤300 chars |
| `showroom_map_url` | *(not a catalogue field)* | — | stamped by `PlanCompiler.php:97`, ≤500 chars |

Publish-time validation is special-cased in `WorkflowNode::missingConfig()` (`:149-157`): a missing `confirm_template_id`, and a missing `reminder_template_id` **when `reminder_hours_before > 0`** ("or set the reminder to 0").

#### 7.2 The chain, step by step (`EndBooked.php:22-83`)

**1. Find the appointment** (`:24-28`)

```php
$appointment = Appointment::where('ae_lead_id', $lead->id)->latest('id')->first();

if ($appointment === null) {
    return StepResult::next('default', 'No appointment on record — nothing to confirm');
}
```

Newest by `id`, unfiltered by status or source.

**2. Lead stage** (`:30`) — `stage = Lead::STAGE_APPOINTMENT (4)`, written with `forceFill(...)->save()`.

**3. Ensure the Zoom meeting** (`:35`) — `app(ZoomMeetings::class)->ensure($appointment, $lead)`. The comment: *"A Zoom booking captured in the chat arrives link-less (the call path makes its meeting inside AiCall) — make it NOW, so the confirmation below carries the real join URL, not 'link to follow'."*

`src/AppointmentEngine/Services/ZoomMeetings.php:48-93`:

| Order | Behaviour |
|---|---|
| 1 | `Booking::channelFor($appointment->type) !== CHANNEL_ZOOM` → return `null` (no-op for a showroom visit) |
| 2 | `$appointment->meeting_link` already set → return it (never a second meeting) |
| 3 | Host = `$lead->assignedAdmin?->email` **else** `ZoomCredentialProvider::webinarHostEmail()`; blank → throws `'No Zoom host: no assigned agent, and no webinar host on the Zoom integration.'` |
| 4 | `ZoomServerService::createMeeting($host, ['topic' => "{project} — {name}", 'type' => 2, 'start_time' => scheduled_at in UTC 'Y-m-d\TH:i:s\Z', 'duration' => config('appointment_engine.zoom.meeting_minutes', 30), 'timezone' => app.user_timezone, 'settings' => ['join_before_host' => true, 'waiting_room' => false]])` |
| 5 | Empty `join_url` → throws `'Zoom created the meeting but returned no join link.'` |
| 6 | Success → `forceFill(['zoom_meeting_id' => (string) $meeting['id'], 'meeting_link' => mb_substr($link, 0, 500)])->save()` |
| 7 | **Any throwable** → `report($e)` + `Notify::team('ae.nudge', 'Zoom meeting not created', …, $lead)` and return `null` |

Failure is absorbed by design (`:25-27`): *"the appointment already exists and outlives any Zoom outage — channel=zoom with a NULL link is the Appointments screen's 'no link yet' marker."* That degraded state is named by `Booking::zoomLinkPending()` (`src/AppointmentEngine/Support/Booking.php:115-119`). `meeting_minutes` is `env('AE_ZOOM_MEETING_MINUTES', 30)` (`config/appointment_engine.php:43-47`) — deliberately a config constant, not a tenth field on the call drawer.

**4. Build the venue strings** (`:37-46`)

```php
$when = $appointment->scheduled_at?->timezone(config('app.user_timezone', 'Asia/Kuala_Lumpur'))->format('D j M, g:ia');
$isZoom = $appointment->type !== null && Booking::channelFor($appointment->type) === 'zoom';
$venue = $isZoom
    ? 'Zoom video call'
    : trim((string) ($node->config('showroom_address') ?: 'Our showroom')
        . (filled($node->config('showroom_note')) ? ' — ' . $node->config('showroom_note') : ''));
```

`Booking::channelFor()` (`Booking.php:58-61`) maps `Appointment::TYPE_VIDEO_CALL (2)` → `'zoom'` and **every other type** → `'showroom'`.

**5. Pin the run context** (`:50-58`) — the single most important write in this handler:

| Context key | Value | Why |
|---|---|---|
| `appointment_id` | `$appointment->id` | *"The appointment THIS confirmation is about, pinned so the reminder (hours later) renders the same booking even if a newer one exists."* |
| `link` | `$when ?? ''` | **Stays the DATE string, byte-for-byte as it always was** — old templates must not break |
| `meeting_link` | `(string) ($appointment->meeting_link ?? '')` | the Zoom join URL travels here instead |
| `venue` | as computed above | *"say WHERE per meeting type"* |
| `venue_link` | Zoom: `meeting_link ?: 'Join link to follow'`; showroom: `showroom_map_url ?: ($when ?? '')` | never blank |

The write is `array_merge($run->context ?? [], [...])` — existing keys (e.g. `wait_node`) survive.

**6. Send the confirmation** (`:60-65`) — through the same `WhatsappSender`, with `'mode' => 'template'` hard-coded:

```php
$error = $this->sender->send([
    'channel_id' => $node->config('channel_id'), 'mode' => 'template',
    'template_id' => $node->config('confirm_template_id'), 'variables' => $variables,
], $lead, $run);
```

Because it goes through `WhatsappSender`, the confirmation **also** stamps `last_activity_at`, advances the engagement to CONTACTING, and calls `ChatTakeover::begin()` (which will typically be refused by one of §3.1's four guards). The catalogue's help explains the hard-coded template: *"A template, because the booking may come from a call rather than a WhatsApp message, and only a template is guaranteed to deliver."*

**7. Schedule the reminder** (`:67-78`) — three conditions must all hold:

```php
$hours = (int) $node->config('reminder_hours_before');

if ($hours > 0 && $appointment->scheduled_at && $node->config('reminder_template_id')) {
    $at = $appointment->scheduled_at->copy()->subHours($hours);

    if ($at->isFuture()) {
        SendWorkflowWhatsapp::dispatch($run->id, [
            'channel_id' => …, 'mode' => 'template',
            'template_id' => $node->config('reminder_template_id'), 'variables' => $variables,
        ])->delay($at);
    }
}
```

A reminder time already in the past is silently skipped. `app/Jobs/AppointmentEngine/SendWorkflowWhatsapp.php` re-loads the run by id, sends via the same `WhatsappSender`, and appends `'Reminder sent'` / `"Reminder not sent: {$error}"` to the run log (`:25-35`). The job carries **no** `$tries` override and does not check whether the appointment still exists or was cancelled.

Because the reminder re-renders through `Text::fill()` at send time against the **pinned** `appointment_id`, an agent who pastes a meeting link in by hand after the confirmation still gets it into the reminder.

**8. Return** (`:80-82`) — `next('default', …)`, message `'Booked and confirmed'` or `"Booked; confirmation not sent: {$error}"`. Deliberately NEXT, not END: *"the closing stage follows. With nothing wired after it the runner finishes here anyway"* — and it does, via the no-edge branch at `WorkflowRunner.php:181-186`.

#### 7.3 What the compiler puts in front of it

`PlanCompiler::compile()` (`PlanCompiler.php:88-119`) builds the booked chain once, at `X_BOOKED = 40`:

- `end.booked` node named `'Appointment confirmed — confirm & remind'`;
- **when `plan.booked.assign` is true**, an `action.assign_agent` named `'Assign the closer'` is created *above* it and linked `default → end.booked`, and becomes `$bookedEntry`.

Every path that produces a booking is wired to `$bookedEntry`: `condition.call_outcome`'s `booked` branch (`:173`) and every message step's `condition.booked` `yes` branch (`:182`).

---

### 8. `action.assign_agent` — `AssignAgent`

`src/AppointmentEngine/Runner/Handlers/AssignAgent.php`. Outputs `['default']`. Its docblock is explicit about scope: *"the picking, the writes and the Telegram handshake all live in CloserRotation; this handler only turns the outcome into a step result the run log can read."*

Flow (`:26-52`):

1. `CloserRotation::configFromNode($node)` (`src/AppointmentEngine/Services/CloserRotation.php:118-137`) — merges the node's raw config over `CloserRotation::DEFAULTS` (`:87-96`): `pool_type = 'project'`, `team_id = null`, `admin_ids = []`, `strategy = 'round_robin'`, `require_zoom = true`, `accept_minutes = 15`, `notify = true`, `admin_id = null`; honours the legacy `mode` key as `strategy` when `strategy` is absent; clamps `accept_minutes` to 0–240.
2. `Appointment::where('ae_lead_id', $lead->id)->latest('id')->first()` — may be `null`.
3. **Exclusions** (`:32-35`) — `CloserHandoff` rows for this lead (and this appointment when one exists) with `status ∈ {STATUS_PASSED (3), STATUS_EXPIRED (4)}`; *"Closers who already passed on / ignored THIS booking are not asked twice."*
4. `$this->rotation->pick($lead, $appointment, $config, $exclude)` — pool → eligibility filters → strategy (`CloserRotation.php:148-168`).
5. **Nobody available** → `Notify::team('ae.team_alert', 'No closer available for a booked appointment', "…nobody in the closer pool can take it (empty pool, Zoom not connected, or a clash). Assign someone by hand.", $lead)` then `next('default', 'No closer available — left unassigned, team alerted')`. **The run continues to `end.booked`.**
6. Otherwise `$this->rotation->offer(...)` and `next('default', …)` with either `"Offered to {$name} — accept within {$config['accept_minutes']} min"` (when `$handoff->isPending()`) or `"Assigned to {$name}"`.

`offer()` (`CloserRotation.php:183-268`) is where the side effects are, in one transaction plus fail-soft tails:

- supersedes any PENDING handoff for the same (lead, appointment) as `STATUS_EXPIRED` with note `'Superseded by a new offer'`;
- creates the `CloserHandoff` — `STATUS_PENDING (1)` with `expires_at = now() + accept_minutes` when the closer is *askable*, otherwise `STATUS_ACCEPTED (2)` with `accepted_at = now()`. **Askable = `accept_minutes > 0` AND the closer has a Telegram destination subscribed to `ae.lead_assigned`** — *"the booking must never wait on a phone that will never buzz"*;
- writes `ae_leads.assigned_admin_id` (+ `team_id` from the closer's admin) and `appointments.assigned_admin_id`;
- then, outside the transaction and fail-soft: `CrmPipeline::syncCloser()` (writes the engagement's `closer` role) and, for a bridged lead, `LeadDistributionService::assign(..., REASON_APPOINTMENT)`;
- logs an `assign` line on the run with the handoff uuid;
- when `notify`, sends `ae.lead_assigned` to that one user with Accept / Pass URLs (`/manage/appointment-engine/handoffs/{uuid}/accept|pass`);
- when askable, `ExpireCloserHandoff::dispatch($handoff->id)->delay($handoff->expires_at)`.

`CloserRotation::nameOf()` (`:509-516`) returns `profile->full_name`, else `email`, else `'nobody'` — the GUIDELINES §4.8 fallback.

---

### 9. `end.stop` and `end.closed` — `EndStop`

One handler serves **both** node types (`HandlerRegistry.php:37-38`). `src/AppointmentEngine/Runner/Handlers/EndStop.php`:

| Branch | Condition | Effect |
|---|---|---|
| lost | `$node->type === 'end.stop'` **and** `$node->config('mark_lost')` is truthy | `stage = Lead::STAGE_LOST (6)`; then `CrmPipeline::advanceStatus($lead, Engagement::STATUS_LOST /* 9 */, 'Marked lost by the AI workflow (' . $node->displayName() . ')')` |
| closed | `$node->type === 'end.closed'` | `stage = Lead::STAGE_SHOWED_UP (5)` |
| always | — | `StepResult::end('done', $node->displayName())` → run status `done` |

The `mark_lost` comment (`:20-22`) carries the rule: *"mark_lost means the CONTACT is dead (an invalid number), not that a person gave up — so the pipeline follows, but only while the machine still owns the deal (advanceStatus's LOST guard)."*

That guard lives in `CrmPipeline::advanceStatus()` (`src/AppointmentEngine/Services/CrmPipeline.php:140-174`) and applies to **every** AE status write:

1. no engagement (unbridged lead, or no `aeProject->project_id`) → return;
2. current status ∈ `{BOOKED (6), FOLLOWING_UP (7), COMPLETED (8), LOST (9)}` → return, untouchable;
3. target is `LOST` → only allowed from `NEW (1)` or `CONTACTING (2)`;
4. otherwise `$target <= $current` → return (**monotonic**);
5. write via `ChangeEngagementStatus`, so `lost_at`/`won_at` and the CRM lead-status roll-up come for free.

Everything is `try/catch` + `report()` — pipeline bookkeeping never breaks a workflow step.

`end.closed` carries **no fields** (`NodeCatalogue.php:659-668`); `end.stop` carries one toggle `mark_lost`, default `false` (`:706-718`). `PlanCompiler` emits exactly two `end.stop` nodes per plan: `['mark_lost' => true]` named `'Dead number — stop, mark lost'` (`PlanCompiler.php:121`) and `['mark_lost' => false]` named `'List done — a person takes over'` (`:198`).

---

### 10. `action.email` — `SendEmail`

`src/AppointmentEngine/Runner/Handlers/SendEmail.php`. Outputs `['default']`. Fields: `subject` (text, required) and `body` (textarea, required) — `NodeCatalogue.php:326-341`.

| Condition | Result |
|---|---|
| `blank($lead->email)` | `next('default', 'No email address — skipped')` — *"Skipped, not failed: a lead with no address is a normal lead."* |
| `EmailSender::send()` returns `true` | `next('default', 'Email sent')` |
| returns `false` | `fail('The email could not be sent.')` |

The call is:

```php
app(EmailSender::class)->send(
    $lead->email,
    Text::fill($node->config('subject'), $lead, $run),
    nl2br(e(Text::fill($node->config('body'), $lead, $run))),
    $lead->name
);
```

Note the order — `Text::fill()` → `e()` → `nl2br()`, so placeholder values are HTML-escaped before line breaks become `<br />`. `Src\Common\Email\EmailSender` (`src/Common/Email/EmailSender.php:11-23`) is the provider-agnostic contract that **never throws** and returns a bool.

---

### 11. `action.notify_team` — `NotifyTeam`

`src/AppointmentEngine/Runner/Handlers/NotifyTeam.php`, 8 lines. One required field `message` (text). Always returns `next('default', 'Team alerted')` — it cannot fail the run.

```php
Notify::team('ae.team_alert', $lead->name, Text::fill($node->config('message'), $lead, $run), $lead);
```

The **title is the lead's name** and the body is the rendered message. `Src\AppointmentEngine\Runner\Notify::team()` (`src/AppointmentEngine/Runner/Notify.php:12-25`) builds a `NotifyMessage`, attaches `url("/manage/appointment-engine/leads/{$lead->uuid}")` labelled *"Open the lead"* when a lead is given, and sends through `Src\Common\Notify\Services\Notifier` inside `try/catch` + `report()` — *"A Telegram line to whoever subscribed. Never throws, never blocks."*

The three AE events are registered in `config/notify.php:117-137`:

| Event | Name | Default on? | Throttle |
|---|---|---|---|
| `ae.lead_assigned` | Appointment System — lead assigned to you | yes | `0` |
| `ae.team_alert` | Appointment System — workflow alert | yes | `0` |
| `ae.nudge` | Appointment System — something is waiting on you | yes | `env('NOTIFY_AE_NUDGE_THROTTLE_SECONDS', 3600)` |

`ae.team_alert` is also raised by `AssignAgent` (no closer) and `CloserRotation` (nobody accepted, `:317`); `ae.nudge` by `ZoomMeetings` (meeting not created), `AiCall` (daily budget) and `RecordOutcome`.

---

### 12. Triggers — `Passthrough`

`src/AppointmentEngine/Runner/Handlers/Passthrough.php` serves all five trigger types — `trigger.meta_lead_form`, `trigger.whatsapp_keyword`, `trigger.ctwa`, `trigger.manual`, `trigger.google_sheet` (`HandlerRegistry.php:14-18`). It ignores config entirely and returns `next('default', 'Lead arrived')`. Its whole comment: *"Triggers: the lead is already here by the time the runner sees the node."*

`WorkflowRunner::start()` (`WorkflowRunner.php:132-149`) sets `current_node_id` to the workflow's first node whose `group() === 'trigger'`, so the trigger node is always executed as step 1 (and a workflow with no trigger fails with `'The workflow has no trigger.'`).

---

### 13. The placeholder vocabulary — `Text`

`src/AppointmentEngine/Runner/Text.php`. `Text::fill(?string, Lead, WorkflowRun)` is a plain `strtr()` over `Text::values()` (`:49-52`) — used for free-text WhatsApp bodies, every template parameter, both email fields, and the notify-team message, *"so a template variable and a typed message mean the same thing."*

Resolution happens **at send time**, against the pinned appointment first (`:20-22`):

```php
$pinned = (int) ($run->context['appointment_id'] ?? 0);
$appointment = ($pinned ? Appointment::find($pinned) : null)
    ?? Appointment::where('ae_lead_id', $lead->id)->latest('id')->first();
```

| Placeholder | Value | Fallback |
|---|---|---|
| `{name}` | `$lead->name` | `'there'` |
| `{project}` | `$lead->project` | `'the project'` |
| `{phone}` | `$lead->phone` | `''` |
| `{link}` | `context.link` (the **date string** written by `EndBooked`) | `''` |
| `{meeting_link}` | `$appointment->meeting_link` → `context.meeting_link` → `context.link` | — (*"never empty, because Meta refuses empty template params"*) |
| `{agent}` | `assignedAdmin->profile->full_name` → `assignedAdmin->email` | `'our team'` |
| `{date}` | `scheduled_at` in `app.user_timezone`, `'l, j M Y'` | `'to be confirmed'` |
| `{time}` | `scheduled_at` in `app.user_timezone`, `'g:ia'` | `'to be confirmed'` |
| `{venue}` | `context.venue` | `'our showroom'` |
| `{venue_link}` | `context.venue_link` → `$appointment->meeting_link` → `context.link` | `'details to follow'` |

`{link}` staying the **date** is a compatibility decision, not an oversight — `EndBooked.php:38-41` spells it out: *"`{link}` stays the DATE string, byte-for-byte as it always was; the Zoom join URL travels as `{meeting_link}` so old templates never break."*

---

### 14. Branch cheat-sheet

| Node type | Handler | Branches returned | Can it FAIL the run? |
|---|---|---|---|
| `trigger.*` (5 types) | `Passthrough` | `default` | no |
| `action.whatsapp` | `SendWhatsapp` | `default` | **yes** — channel, 24h window, template |
| `action.whatsapp_ai` | `WhatsappAiTakeover` | `booked`, `no_booking` (+ `wait` / `park`) | **yes** — channel, flow, no phone |
| `action.wait` | `Wait` | `default` (+ `wait`) | no |
| `action.email` | `SendEmail` | `default` | **yes** — transport refused |
| `action.notify_team` | `NotifyTeam` | `default` | no |
| `action.assign_agent` | `AssignAgent` | `default` (both outcomes) | no |
| `condition.booked` | `ConditionBooked` | `yes`, `no` | no |
| `condition.replied` | `ConditionReplied` | `yes`, `no` (+ `wait`) | no |
| `end.booked` | `EndBooked` | `default` (never `end`) | no — a failed confirm is reported in the message |
| `end.stop` / `end.closed` | `EndStop` | `end('done')` | no |

---

## 6. Assigning the closer

`Src\AppointmentEngine\Services\CloserRotation` is **the one place the engine picks a person** for a booked appointment. Everything else — the workflow handler, the Telegram links, the expiry job — is a thin caller around it (`src/AppointmentEngine/Services/CloserRotation.php:32-57`).

It runs in four layers, in this order:

| # | Layer | What it decides | Method |
|---|-------|-----------------|--------|
| 1 | **Pool** | which people are candidates at all | `candidates()` — `CloserRotation.php:335` |
| 2 | **Filters** | which of them can take *this* booking | `eligible()` — `CloserRotation.php:392` |
| 3 | **Strategy** | which single one gets asked | `roundRobin()` / `leastBusy()` / `byRules()` — `CloserRotation.php:429`, `:447`, `:468` |
| 4 | **Handshake** | offer on Telegram with a clock, or assign directly | `offer()` — `CloserRotation.php:183` |

Layers 1–3 are `pick()` (`CloserRotation.php:148`), which returns a `User` or `null`. `pick()` never writes; `offer()` is the only writer. That split is deliberate — it is what lets the pool be previewed without moving anything.

#### Where the rotation is entered

| Entry point | File | What it does |
|---|---|---|
| The booked chain's first step | `src/AppointmentEngine/Runner/Handlers/AssignAgent.php:26-52` | `pick()` → `offer()`; registered as `action.assign_agent` in `src/AppointmentEngine/Runner/HandlerRegistry.php:19` |
| Closer taps **Pass** | `app/Http/Controllers/Manage/AppointmentEngine/HandoffsController.php:51-65` | `advance($handoff, STATUS_PASSED, …)` |
| The accept clock runs out | `app/Jobs/AppointmentEngine/ExpireCloserHandoff.php:39-59` | `advance($handoff, STATUS_EXPIRED, …)` |
| A person overrides by hand | `resources/js/Pages/Manage/AppointmentEngine/Partials/LeadsTable.vue:340-348` → `PUT /manage/engagements/{id}/assign` (`routes/web.php:348`) | writes the engagement's `closer` role; `EngagementRepository::syncAppointmentEngineAgent()` cascades it back onto `ae_leads.assigned_admin_id` and fills still-unassigned appointments (`src/Engagement/Repositories/EngagementRepository.php:432-461`) |

The booked chain is compiled by `PlanCompiler`: any call whose outcome is `booked`, and any nurture step whose "Already booked?" check says yes, is wired to the assign node, which then links to `end.booked` (`src/AppointmentEngine/Services/PlanCompiler.php:102-119`, `:173`, `:182`). `AssignAgent` returns `StepResult::next('default')` (`AssignAgent.php:49-51`), so **the run walks straight on to the confirm-and-remind step; it never waits for the handshake.**

#### Configuration

`configFromNode()` (`CloserRotation.php:118-137`) is how the step's stored `config` becomes the array every method reads.

| Key | Default (`CloserRotation::DEFAULTS`, `:88-97`) | Notes |
|---|---|---|
| `pool_type` | `'project'` | one of the four `POOL_*` values |
| `team_id` | `null` | cast to int, `0`/empty → `null` |
| `admin_ids` | `[]` | `intval`-mapped, falsy entries dropped |
| `strategy` | `'round_robin'` | |
| `require_zoom` | `true` | cast to bool |
| `accept_minutes` | `15` | clamped `max(0, min(240, …))` at `:132` |
| `notify` | `true` | cast to bool |
| `admin_id` | `null` | only used by `STRATEGY_SPECIFIC` |

Two rules that matter:

- Only keys present in `DEFAULTS` are read off the node — `array_merge(self::DEFAULTS, array_intersect_key($stored, self::DEFAULTS))` (`:121`). Anything else on the node config is ignored here.
- **A node compiled before the redesign carries `mode`, not `strategy`.** If `strategy` is absent and `mode` is present, `mode` is used verbatim (`:125-127`). This is why `WorkflowTemplates`' hand-built nodes (`'mode' => 'rules'`, `src/AppointmentEngine/WorkflowTemplates.php:47`, `:104`, `:140`, `:200`, `:233`) still route through the legacy rules engine.

The plan editor writes these under `plan.booked.assignment`, whitelisted and clamped by `Plan::normalizeAssignment()` (`src/AppointmentEngine/Support/Plan.php:243-252`) with `Plan::ASSIGNMENT_DEFAULTS` at `:40-43`; `PlanCompiler` then stamps **both** `mode` and `strategy` onto the node with the same value plus `'notify' => true` (`PlanCompiler.php:107-118`). The UI is the "Auto-assign a closer" section of the Once-booked drawer (`resources/js/Pages/Manage/AppointmentEngine/Projects/Partials/WorkflowTab.vue:1721-1775`); the raw node fields are declared in `src/AppointmentEngine/NodeCatalogue.php:265-315`.

---

#### Layer 1 — the pool

Every pool starts from the same base query (`CloserRotation.php:337-341`): `users.status = User::STATUS_ACTIVE` (= `1`, `src/People/User.php:37`), holding a role in `Role::manageRoles()`, minus `$excludeUserIds`, eager-loading `profile` and `admin`. `Role::manageRoles()` is `super-admin`, `group-super-admin`, `sales-leader`, `sales-agent` **plus every custom role row** (`src/Auth/Role.php:80-88`) — note the legacy `admin` role is not in `Role::ROLES`, so it is returned by `customRoles()` and therefore *is* a manage role here.

| Constant | Value | Label (`CloserRotation::POOLS`, `:65-70`) | The query it adds (`:343-354`) |
|---|---|---|---|
| `POOL_PROJECT` | `'project'` | "This project's closers (set on the Leads tab)" | `whereIn('id', projectCloserIds($lead) ?: [0])` |
| `POOL_GROUP` | `'group'` | "Everyone assignable in the agency" | `->permission(Permission::SALES_EXECUTION)` + `whereHas('admin', group_id = $lead->group_id)` when the lead has a group |
| `POOL_TEAM` | `'team'` | "A team" | `whereIn('id', Team::memberUserIds($config['team_id']))` — a sub-select of `admins.user_id where team_id = ?` (`src/People/Team.php:70-73`) |
| `POOL_ADMINS` | `'admins'` | "These people" | `whereIn('id', $config['admin_ids'] ?: [0])` |

`POOL_GROUP` is the `default` arm of the `match`, so **any unrecognised `pool_type` silently becomes the agency pool.** `SALES_EXECUTION` is `'sales-execution'` (`src/Auth/Permission.php:143`) — the same "is sales staff" flag the CRM's `ResolvesAssignableManagers::assignableManagerQuery()` uses (`app/Http/Controllers/Concerns/ResolvesAssignableManagers.php:47-52`), so the agency pool is identical to every other sales-person picker.

Results are `orderBy('id')` (`:358`), which is the stable base ordering the strategies sort on top of.

**The project pool** (`projectCloserIds()`, `:368-382`), reading `ae_project_closers`:

1. No `ae_project_id` on the lead → `[]` → the `?: [0]` makes the pool empty.
2. Rows for the project are read `orderBy('position')`.
3. Keep the rows whose `group_id` equals the lead's `group_id` (or `NULL` when the lead has no group).
4. **If that agency's list is empty and the lead has a group, fall back to the rows with `group_id IS NULL`** — the platform's own list for that project.

`ae_project_closers` is config, not runtime state: blame columns, no soft delete, `position` is the leader's chosen order (`database/migrations/2026_09_08_120000_create_ae_project_closers_table.php:20-32`; model `src/AppointmentEngine/ProjectCloser.php`). It is edited from the project's Leads tab via `PUT /manage/appointment-engine/projects/{id}/closers` (`routes/web.php:1982`) → `ProjectsController::updateClosers()` (`app/Http/Controllers/Manage/AppointmentEngine/ProjectsController.php:372-415`), which requires `Permission::MANAGE_APPOINTMENTS`, checks `AeScope::allows($viewer, $groupId)`, intersects the submitted ids against `closerCandidates($groupId)` (`:1037-1049`) and then **deletes and re-inserts that one (project, group) list inside a transaction**, `position` = array index.

#### Layer 2 — the eligibility filters

`eligible(User $user, ?Appointment $appointment, array $config)` (`:392-420`), guards in order:

1. **No appointment → always eligible** (`:394`). `AssignAgent` looks up `Appointment::where('ae_lead_id', $lead->id)->latest('id')->first()` (`AssignAgent.php:29`), so a lead with no appointment row skips both filters entirely.
2. **Zoom.** If `require_zoom` and `Booking::channelFor($appointment->type) === Booking::CHANNEL_ZOOM` and `! ZoomServerService::isAccountUser($user->email)` → not eligible (`:398-401`). `channelFor()` returns `'zoom'` only for `Appointment::TYPE_VIDEO_CALL = 2`; every other type reads as `'showroom'` (`src/AppointmentEngine/Support/Booking.php:58-61`). `isAccountUser()` lowercases/trims and looks the email up in the cached Zoom account user list (`src/Zoom/Services/ZoomServerService.php:431-438`). The reason is in the node's own help text: *so the meeting is hosted by the closer, not the webinar host*.
3. **Clash.** If the appointment has a `scheduled_at`, anybody with another `appointments` row where `assigned_admin_id = $user->id`, `id != $appointment->id`, `status = Appointment::STATUS_SCHEDULED` (= `1`) and `scheduled_at` between **−60 and +60 minutes** of the new one is not eligible (`:404-417`). The window is `CloserRotation::CLASH_MINUTES = 60` (`:86`), applied on *both* sides.

The **fourth filter is not in `eligible()`** — it is the `$excludeUserIds` argument threaded through `candidates()`: every closer who already **passed on or timed out on this booking**, collected as the `admin_id`s of that (lead, appointment)'s hand-offs whose status is `STATUS_PASSED` or `STATUS_EXPIRED` (`AssignAgent.php:32-35`, and again in `advance()` at `:308-311`).

#### Layer 3 — the strategy

| Constant | Value | In `STRATEGIES` (`:77-80`) | Behaviour in `pick()` (`:162-167`) |
|---|---|---|---|
| `STRATEGY_ROUND_ROBIN` | `'round_robin'` | "Round-robin — everyone in turn" | `roundRobin($eligible)` — also the `default` arm |
| `STRATEGY_BALANCE` | `'balance'` | "Least busy — fewest upcoming appointments" | `leastBusy($eligible)` |
| `STRATEGY_SPECIFIC` | `'specific'` | *not listed* | `$eligible->firstWhere('id', $config['admin_id'])` |
| `STRATEGY_RULES` | `'rules'` | *not listed* | `byRules($lead, $eligible) ?? roundRobin($eligible)` |

Only the first two appear in `STRATEGIES` and in the plan editor's dropdown (`WorkflowTab.vue:1757-1760`); `specific` and `rules` survive only on hand-built canvas nodes and the legacy `mode` key.

**Round-robin reads the hand-off ledger** (`:429-439`):

```
SELECT admin_id, max(created_at) AS last_at
  FROM ae_closer_handoffs
 WHERE admin_id IN (pool)
 GROUP BY admin_id
```

then sorts the pool by `[last_at as unix timestamp, or 0 when never offered, then user id]` and takes the first. So: **never-offered people come first (key `0`), then the person whose last *offer* is oldest, ties broken on `users.id`.** It is "least recently **offered**", not "least recently accepted" — a closer who passes still counts as offered. There is no rotation pointer to drift, which is exactly why it self-heals as people join and leave the pool (`database/migrations/2026_09_08_100000_create_ae_closer_handoffs_table.php:7-14`). The query is **not** scoped by project, agency or appointment — one global ledger per person.

**Least busy** (`:447-459`) counts `appointments` rows with `status = STATUS_SCHEDULED` and `scheduled_at >= now()` per `assigned_admin_id`, and sorts `[count, user id]`.

**Rules** (`byRules()`, `:468-480`) loads `ae_routing_rules` for the lead's `group_id` with `is_active = true`, ordered by `position`, builds a throw-away `Appointment` carrying the lead as its `lead` relation, and asks `RoutingEngine::decide()`. `RoutingEngine` is **first match wins in `position` order** (`src/AppointmentEngine/Services/RoutingEngine.php:40-67`) — a scoring scheme was rejected because a leader has to be able to read the list top to bottom and predict the outcome. A matching rule either names `assign_admin_id` or asks for `balance_by_load` (ties on user id, `:80-85`). If the named agent is not in the passed pool the engine returns `null` **and stops rather than falling through to the next rule** (`:59-62`) — quietly changing routing a leader believes is in force is worse than leaving it manual. `RoutingRule::matches()` (`src/AppointmentEngine/RoutingRule.php:63-94`) checks `journey_stage`, `intent` and the `budget_min`/`budget_max` band; a NULL condition does not constrain (so an all-null rule is a legitimate catch-all), and **a NULL lead field fails any condition that names it** — otherwise "budget over 1M" becomes the catch-all for every un-qualified lead. If no rule matched, `byRules()` returns null and `pick()` falls back to round-robin.

If `strategy === 'specific'` and `admin_id` is set, `candidates()` additionally narrows the whole pool to that one id (`:356-358`).

`pick()` returns `null` when the pool is empty *or* when nothing survives the filters (`:152-160`) — and for `STRATEGY_SPECIFIC`, also when the named person was filtered out.

---

#### Layer 4 — the handshake

##### Askable, or assigned directly

```php
$askable = $config['accept_minutes'] > 0 && $this->hasTelegram($closer);   // :188
```

`hasTelegram()` (`:489-503`) is true only when **all** of these hold:

1. `config('notify.enabled')` is on;
2. `NotifyManager::isConfigured(NotifyDestination::TRANSPORT_TELEGRAM)` — a bot token exists on this install (`TRANSPORT_TELEGRAM = 1`, `src/Common/Notify/NotifyDestination.php:38`);
3. the user has a `notify_destinations` row of `kind = KIND_PERSONAL` (= `1`, `:47`) with `is_active = true`, carrying an **active subscription to `ae.lead_assigned`**.

*A closer with no Telegram cannot be asked, so they are assigned directly* — the booking must never sit waiting on a phone that will never buzz. That path writes the hand-off as already `ACCEPTED`, with `note` = `'Assigned directly — no Telegram to ask on'` when `accept_minutes > 0`, or `'Assigned directly'` when the admin set 0 minutes (`:206`).

Because subscriptions gate this, `database/migrations/2026_09_08_110000_backfill_appointment_engine_subscriptions.php` back-fills `ae.lead_assigned`, `ae.team_alert` and `ae.nudge` for every pre-existing personal destination: `default => true` only pre-ticks destinations created *after* an event is registered, and the three AE events shipped without a backfill, so every admin's personal chat was unticked and the whole handshake was silently unaskable.

##### What `offer()` writes in ONE transaction

`CloserRotation.php:190-214`, in order:

| # | Write | Detail |
|---|---|---|
| 1 | Supersede | every `PENDING` `ae_closer_handoffs` row for this `ae_lead_id` (and this `appointment_id`, when there is an appointment) → `status = STATUS_EXPIRED`, `expired_at = now()`, `note = 'Superseded by a new offer'` |
| 2 | Create the offer | one `CloserHandoff` with `group_id` (lead's), `ae_lead_id`, `appointment_id`, `ae_workflow_run_id`, `admin_id`, `status` = `PENDING` when askable else `ACCEPTED`, `attempt`, `expires_at = now()+accept_minutes` (null when not askable), `accepted_at = now()` when not askable, and the note above |
| 3 | The lead | `assigned_admin_id = $closer->id`, and `team_id = $closer->admin?->team_id ?: $lead->team_id` — the lead joins the closer's team, because a project page reads per team (`database/migrations/2026_09_08_130000_add_team_id_to_ae_leads_table.php:7-12`) |
| 4 | The appointment | `assigned_admin_id = $closer->id` (skipped when there is no appointment) |

Note that when `$appointment` is null, step 1's `when()` clause is skipped and **all** pending hand-offs for the lead are superseded.

##### What it writes fail-soft, afterwards

Both inside one `try { … } catch (\Throwable $e) { report($e); }` (`:219-227`) — *a CRM hiccup never un-books anyone*:

1. **`CrmPipeline::syncCloser($lead, $closer->id)`** (`src/AppointmentEngine/Services/CrmPipeline.php:93-116`) — writes the closer onto the engagement's `closer` role (`CrmPipeline::AGENT_ROLE = 'closer'`, `:45`) via `EngagementRepository::assign()`, passing **only** that key so every other pipeline role is left untouched. It is itself fail-soft, and no-ops when the lead is unbridged (`engagementFor()` needs both `lead_id` and `aeProject->project_id`). The engine's single "agent" IS the pipeline's closer — one person, named once, visible from both sides.
2. **`LeadDistributionService::assign($crmLead, $closer->id, LeadAssignment::REASON_APPOINTMENT)`** — only when `$lead->lead_id` resolves to a real `Src\Lead\Lead` (`:222-223`). The reason constant is `LeadAssignment::REASON_APPOINTMENT = 7`, labelled **"Booked appointment"** with colour `emerald`, and its comment says why it exists: *the appointment engine handed a BOOKED appointment to a closer — a hot lead pushed, not a cold one pulled* (`src/LeadDistribution/LeadAssignment.php:21-38`). The service (`app/Services/LeadDistribution/LeadDistributionService.php:40-73`) computes `newCount = times_assigned + 1`, resolves the tier from that count (`LeadTierResolver::resolve()`), calls the repository, then logs `ActivityLog::TYPE_LEAD_ASSIGNED` (= `60`) with `reason`, `times_assigned` and `tier` in the detail. The repository write (`src/LeadDistribution/Repositories/LeadDistributionRepository.php:33-64`) is its own transaction: close the open `lead_assignments` span (`unassigned_at = now()`), insert a new row (recording the **source** tier), then set `assigned_admin_id`, `times_assigned`, `lead_tier_id`, `distribution_status = Lead::DIST_ASSIGNED` (= `2`) and `last_assigned_at`.

Then, still outside the transaction:

3. **The run log** — `$run?->log('assign', …)` with either `"Offered to {name} — accept within {n} min (attempt {a})"` or `"Assigned to {name}"`, detail `['handoff' => $handoff->uuid]` (`:233-235`).
4. **The Telegram message** (only when `$config['notify']`, `:237-261`). Title `'New appointment for you — accept?'` or `'New appointment for you'`; body `"{name} · {phone}"` plus `" · {project}"`; lines **When** (`scheduled_at` in `config('app.user_timezone', 'Asia/Kuala_Lumpur')`, format `D j M, g:ia`) and **Where** (`Booking::CHANNELS[…]['name']` — `Showroom` or `Zoom`); `subject($lead)`; `throttleScope('handoff:' . $handoff->id)` so one offer can never mute the next. Askable messages add a *Reply within* line, a **"Can't take it?"** line pointing at `/manage/appointment-engine/handoffs/{uuid}/pass`, and the primary button `Accept this appointment` → `…/{uuid}/accept`. Non-askable messages instead link to the lead. Delivery is `Notifier::sendToUsers('ae.lead_assigned', $message, [$closer->id])`, which keeps only the addressed users' **personal** destinations that are subscribed (`src/Common/Notify/Services/Notifier.php:89-101`, `:200-216`) — a shared team chat is never an addressee for "assigned to you".
5. **Arm the clock** — `ExpireCloserHandoff::dispatch($handoff->id)->delay($handoff->expires_at)`, only when askable (`:263-265`). Dispatching *after* the commit is what stops the job racing the row into existence.

---

#### The hand-off state machine

`ae_closer_handoffs` (`src/AppointmentEngine/CloserHandoff.php`, table at `:30`):

| Constant | Value | Label | Colour |
|---|---|---|---|
| `STATUS_PENDING` | `1` | Waiting for accept | amber |
| `STATUS_ACCEPTED` | `2` | Accepted | emerald |
| `STATUS_PASSED` | `3` | Passed | slate |
| `STATUS_EXPIRED` | `4` | No answer | rose |

Columns: `uuid` (auto-generated on create, `:46-51`; the public id in the accept/pass links), `group_id`, `ae_lead_id`, `appointment_id`, `ae_workflow_run_id`, `admin_id`, `status` (default `1`), `attempt` (default `1`), `expires_at`, `accepted_at`, `passed_at`, `expired_at`, `note` (`string(200)`), timestamps. Runtime child table — no `HasUuid`/`RecordsBlame` traits, no soft deletes (`create_ae_closer_handoffs_table.php:20-41`). `isPending()` at `:80-83` is the single guard every settle path uses.

**Transitions**

| From | Trigger | To | Side effects |
|---|---|---|---|
| — | `offer()` | `PENDING` (askable) or `ACCEPTED` (direct) | lead + appointment assignment, CRM writes, message, expiry job |
| `PENDING` | a newer `offer()` for the same (lead, appointment) | `EXPIRED`, note `Superseded by a new offer` | — |
| `PENDING` | `GET …/accept` | `ACCEPTED`, `accepted_at = now()`, note `Accepted` | run log `"{name} accepted the appointment"`; flash *"It's yours — {lead} is on your calendar."* |
| `PENDING` | `GET …/pass` | `PASSED`, `passed_at = now()`, note `Passed by {actor}` | `advance()` → next offer, or the dry-pool alert |
| `PENDING` | `ExpireCloserHandoff` fires at/after `expires_at` | `EXPIRED`, `expired_at = now()`, note `No answer in {n} min` | `advance()` → next offer, or the dry-pool alert |
| any settled state | a second tap on either link | unchanged | a warning flash only (`HandoffsController::settledSentence()`, `:90-97`) |

**`advance(CloserHandoff $handoff, int $status, string $note)`** (`:282-325`), in order:

1. Force-fill the status, the matching timestamp (`passed_at` for PASSED, `expired_at` for EXPIRED) and the note, then save (`:284-289`).
2. If the lead is gone or soft-deleted, stop and return `null` (`:295-297`).
3. Log `assign`: `"{note} — {name} did not take it"`.
4. Re-resolve the config: the run's `currentNode` when it is an `action.assign_agent`, else the **first** `action.assign_agent` node on the run's workflow, else `CloserRotation::DEFAULTS` (`:303-306`) — a hand-off started outside a run, or whose node has been recompiled away, still has settings.
5. Build the exclusion list from every `PASSED`/`EXPIRED` hand-off for this (lead, appointment) (`:308-311`). Because superseding marks rows `EXPIRED`, a superseded closer is also excluded from further offers for that booking.
6. `pick()` again; on a hit, `offer(…, attempt: $handoff->attempt + 1)`.

**The expiry job** (`ExpireCloserHandoff.php`) is deliberately defensive:

- missing row, or `! isPending()` → return, so *a late or duplicate firing can never steal an accepted appointment* (`:43-45`);
- `expires_at` still in the future (someone re-armed with a longer window after the job was queued) → re-dispatch itself with the new delay and return (`:47-52`);
- otherwise `advance(…, STATUS_EXPIRED, "No answer in {n} min")`, where `n = round(created_at→expires_at in minutes)`, floor 1, or the bare `'No answer'` when either timestamp is missing (`:54-58`).

**The two endpoints** (`routes/web.php:1987-1988`), both `GET` *on purpose*: they are tapped from a phone notification, where only a link can be followed, and both are idempotent (`HandoffsController.php:13-19`).

| Method | URI | Route name |
|---|---|---|
| `GET` | `/manage/appointment-engine/handoffs/{id}/accept` | `manage.appointment-engine.handoffs.accept` |
| `GET` | `/manage/appointment-engine/handoffs/{id}/pass` | `manage.appointment-engine.handoffs.pass` |

`{id}` is the hand-off **uuid**. Both redirect to the lead with `redirectWithSuite('manage.appointment-engine.leads.show', $handoff->lead?->uuid)` so the closer lands on what they just took or gave up, with `?suite=` preserved.

**Authorisation** is two-tiered:

1. The suite's route group: `['auth', 'admin', 'permission:view-appointment-engine', 'ae.agency']` (`routes/web.php:1954-1957`). `ae.agency` (`app/Http/Middleware/EnsureAeAgencyChosen.php:31-46`) exempts only the agency chooser and four lead endpoints, so **platform staff with no group and no chosen agency are redirected to the chooser before either link runs**; `AeScope::needsChoice()` is true only for a user with no `groupId()` and no session key (`src/AppointmentEngine/Support/AeScope.php:57-60`).
2. `handoffFor()` (`HandoffsController.php:76-84`): the row is found by uuid, then `abort_unless($user->id === $handoff->admin_id || $user->can(Permission::MANAGE_APPOINTMENTS), 404)`. **404, not 403** — whether an offer exists is not a stranger's to learn. `MANAGE_APPOINTMENTS` is `'manage-appointments'` (`src/Auth/Permission.php:132`), so a leader may accept or pass on a closer's behalf; the hand-off's `admin_id` (and therefore the assignment) stays the original closer, and a `pass` records `"Passed by {the leader}"`.

---

#### When the pool runs dry

Two different places, two different outcomes:

| Where | Code | What happens |
|---|---|---|
| **First offer** — `pick()` returned null | `AssignAgent.php:39-44` | Nothing is assigned. `Notify::team('ae.team_alert', 'No closer available for a booked appointment', "{lead}{ · project} — nobody in the closer pool can take it (empty pool, Zoom not connected, or a clash). Assign someone by hand.", $lead)`, and the step returns `next('default', 'No closer available — left unassigned, team alerted')` — the run still goes on to confirm and remind. |
| **Mid-rotation** — everyone passed or timed out | `CloserRotation.php:315-322` | Run log `'Nobody left in the closer pool — the team was alerted'`, then `Notify::team('ae.team_alert', 'Nobody accepted a booked appointment', "{lead}{ · when} — every closer passed or did not answer. It stays with {last closer} until someone reassigns it.", $lead)`, and `advance()` returns `null`. |

The second row is the load-bearing one: **passing does not un-assign anybody.** `offer()` is the only writer of `ae_leads.assigned_admin_id` / `appointments.assigned_admin_id` inside the engine, and nothing clears them, so when the pool is exhausted the booking simply stays with the last person asked — an appointment is never orphaned. The alert says so in words.

`Notify::team()` (`src/AppointmentEngine/Runner/Notify.php:12-25`) fans out through `Notifier::send()` to **every** destination subscribed to the event (personal and shared), attaches an "Open the lead" button, and never throws.

#### The two notify events

| Event key | Registered at | Default | Throttle | Used by |
|---|---|---|---|---|
| `ae.lead_assigned` — "Appointment System — lead assigned to you" | `config/notify.php:117-123` | on | `0` | `offer()`'s addressed message (`:257`); also the subscription `hasTelegram()` requires (`:501`) |
| `ae.team_alert` — "Appointment System — workflow alert" | `config/notify.php:124-130` | on | `0` | both dry-pool alerts |

Both have `throttle => 0`, so `Notifier::isThrottled()` returns false immediately and the per-hand-off `throttleScope` is belt-and-braces (`src/Common/Notify/Services/Notifier.php:254-260`).

#### Invariants to preserve when changing this

- **`pick()` decides, `offer()` writes.** Keep them apart; that separation is what lets a screen preview the pool without moving anything (`RoutingEngine.php:15-21` argues the same for its layer).
- **Every offer is a row.** The ledger is both the accept/pass link target *and* the rotation's memory. Adding a "last assigned" pointer to `users` would reintroduce exactly the drift the ledger removes.
- **Four facts, one transaction.** The hand-off, the AE lead, the appointment and (fail-soft) the CRM's closer role + distribution history must not be able to disagree about who has the deal.
- **Never let CRM bookkeeping throw into the engine.** `CrmPipeline` and the `LeadDistributionService` call are both wrapped and `report()`ed.
- A cleanup path already exists: `LeadEraser::erase()` deletes a lead's hand-offs inside its transaction (`src/AppointmentEngine/Services/LeadEraser.php:62`).

---

## 7. Scoping — who sees what

The AE is **agency-first**. Every read and every write in the suite is partitioned by an agency (`groups.id`), and the partition is resolved in exactly one place: `Src\AppointmentEngine\Support\AeScope`. The design note is in the class docblock — "an agency member always works inside their own agency, and platform staff (no group) CHOOSE the agency they are working in when they enter the suite — after which every project, lead, appointment, workflow, closer list and setting they see belongs to that agency, exactly as it would to one of its leaders" (`src/AppointmentEngine/Support/AeScope.php:11-23`, owner decision 2026-09-08).

Access is therefore three independent layers, and all three must pass:

| Layer | Enforced by | Failure mode |
|---|---|---|
| Can you enter the portal at all? | `auth` + `admin` (`App\Http\Middleware\EnsureUserIsAdmin`) | member/non-member is flashed and redirected to `main.dashboard` (`app/Http/Middleware/EnsureUserIsAdmin.php:36-40`) |
| Can you open this suite? | `permission:view-appointment-engine` | 403 |
| Which agency's rows are these? | `AeScope` (tenancy, not permission) | rows simply do not exist for you |

---

#### 1. Resolving the acting agency

`AeScope::groupId(?User $user): ?int` (`src/AppointmentEngine/Support/AeScope.php:35-48`) is the single source of truth. The order of the three branches is load-bearing:

1. `$user === null` → `null`.
2. `$user->groupId()` truthy → `(int) $user->groupId()`. **An agency member can never act as another agency**; the session key is not even read. `User::groupId()` reads `$this->admin?->group_id` (`src/People/User.php:450-453`), i.e. the staff record, so platform staff have `null`.
3. Otherwise read `session('ae.agency_id')` and return it only when `is_numeric($chosen) && (int) $chosen > 0`; anything else → `null`.

`null` means **the platform view — all agencies, unscoped**, not "no access".

| Member | Value / behaviour | Line |
|---|---|---|
| `AeScope::SESSION_KEY` | `'ae.agency_id'` | `AeScope.php:26` |
| `groupId(?User)` | own group → session choice → `null` | `AeScope.php:35-48` |
| `needsChoice(?User)` | `$user !== null && ! $user->groupId() && ! session()->exists(SESSION_KEY)` | `AeScope.php:57-60` |
| `choose(?int)` | `session(['ae.agency_id' => $groupId])` — `null` stores "all agencies" | `AeScope.php:68-71` |
| `agency(?User)` | `Group::find(groupId())`, else `null` | `AeScope.php:79-84` |

**`session()->exists()`, not `has()`, and that is deliberate.** "All agencies" is stored as the literal `null` (`choose(null)`), and Laravel's `Store::has()` treats a null value as absent while `Store::exists()` only tests key presence (`vendor/laravel/framework/src/Illuminate/Session/Store.php`). Using `has()` in `needsChoice()` would put a platform admin who deliberately picked "All agencies" back on the chooser on every request. The chooser page uses the same distinction to render its "Current" badge: `'current' => session()->exists(AeScope::SESSION_KEY) ? session(AeScope::SESSION_KEY) : false` — `false` = never chosen, `null` = all agencies, a number = a group (`app/Http/Controllers/Manage/AppointmentEngine/AgencyController.php:50`, mirrored in `resources/js/Pages/Manage/AppointmentEngine/Agency.vue:15-17`).

The choice is **per session**, not per user and not persisted to the database.

##### The chooser

`Manage\AppointmentEngine\AgencyController` serves two routes inside the AE group (`routes/web.php:1961-1962`):

| Method | Route | Name | Behaviour |
|---|---|---|---|
| `GET /manage/appointment-engine/agency` | `AgencyController@index` | `manage.appointment-engine.agency.index` | An agency member (`$viewer?->groupId()`) is redirected straight to `/manage/appointment-engine/dashboard` — they have nothing to choose (`AgencyController.php:29-32`). Otherwise renders `Manage/AppointmentEngine/Agency` with every `Group::STATUS_ACTIVE` (= `1`, `src/People/Group.php:21`) group, its `teams_count`, `admins_count`, its `ae_projects` count, plus `platformProjects` (the `group_id IS NULL` project count) (`AgencyController.php:34-51`). |
| `POST /manage/appointment-engine/agency` | `AgencyController@select` | `manage.appointment-engine.agency.select` | Validates `agency_id` as `nullable|integer|exists:groups,id`, calls `AeScope::choose()`, flashes "You are now working in {name}." (or "all agencies") and redirects to the dashboard (`AgencyController.php:58-71`). |

Switching later is a link in the Dashboard hero, shown only to platform staff: `'can_switch' => $viewer !== null && ! $viewer->groupId()` (`app/Http/Controllers/Manage/AppointmentEngine/DashboardController.php:146`) rendered as "Switch agency" → `/manage/appointment-engine/agency` (`resources/js/Pages/Manage/AppointmentEngine/Dashboard.vue:140`).

---

#### 2. The middleware: `ae.agency`

Registered as a route alias: `'ae.agency' => \App\Http\Middleware\EnsureAeAgencyChosen::class` (`app/Http/Kernel.php:55`), and applied to exactly one route group — the AE suite (`routes/web.php:1957`), which is itself wrapped in `if (config('features.appointment_engine_enabled'))` (`routes/web.php:1954`; the flag defaults to `true`, `config/features.php:31`, and is shared to Inertia as `features.appointment_engine`, `app/Http/Middleware/HandleInertiaRequests.php:127`).

```php
->middleware(['auth', 'admin', 'permission:' . Permission::VIEW_APPOINTMENT_ENGINE, 'ae.agency'])
```

The order matters: the agency gate is **last**, so an unauthenticated or unpermitted user never reaches the chooser.

`EnsureAeAgencyChosen::handle()` (`app/Http/Middleware/EnsureAeAgencyChosen.php:25-49`) is two checks in order:

**a) Exemptions — checked first, before anything else.**

```php
if ($request->routeIs(
    'manage.appointment-engine.agency.*',
    'manage.appointment-engine.leads.flow',
    'manage.appointment-engine.leads.stop-*',
    'manage.appointment-engine.leads.chat',
)) { return $next($request); }
```

| Exempt pattern | Routes it covers | Why (comment at `EnsureAeAgencyChosen.php:27-30`) |
|---|---|---|
| `…agency.*` | `agency.index`, `agency.select` | The chooser cannot redirect to itself. |
| `…leads.flow` | `GET leads/{id}/flow` | Called by the **Messages inbox** drawer's "View the flow" |
| `…leads.stop-*` | `leads.stop-sequence`, `leads.stop-chat`, `leads.stop-automation` | The inbox card's three stop buttons |
| `…leads.chat` | `GET leads/{id}/chat` | The lead row's "open in inbox" jump |

The reason given is precise: these endpoints "are reached from outside the suite, scoped by the lead, and must never bounce an inbox user to a chooser." They are safe to exempt because each one re-resolves the lead through `LeadsController::scoped()`, which applies `AeScope` itself (`app/Http/Controllers/Manage/AppointmentEngine/LeadsController.php:718, 750, 805, 829, 855`) — the exemption skips the *chooser*, never the *scope*. For a platform admin who has made no choice, `AeScope::groupId()` returns `null` and these endpoints run unscoped, which is the same platform view the chooser's "All agencies" option grants.

**b) The gate.**

```php
if (AeScope::needsChoice($request->user()) && Group::query()->exists()) {
    if ($request->expectsJson()) { abort(409, 'Choose the agency you are working in first.'); }
    return redirect()->route('manage.appointment-engine.agency.index');
}
```

Two guards, both required. `Group::query()->exists()` is the "an install with no agencies yet has nothing to choose" case (`EnsureAeAgencyChosen.php:16`) — on a single-tenant install the whole mechanism is inert. `expectsJson()` → **409**, not a redirect, because the AE pages make XHR calls (`dashboard/chat`, `ai-agent/workflows/{id}/sheet-check`, `leads/{id}/flow`, the AI-profile copilot endpoints) that would otherwise silently receive an HTML chooser page.

---

#### 3. `apply` vs `applyShared` — and `GroupScope`

`AeScope` mirrors `Src\Auth\Support\GroupScope` method-for-method, adding only the session source. `GroupScope` is described as "a tenancy partition, not a permission level — the module's view/manage permissions still gate access on top" (`src/Auth/Support/GroupScope.php:8-13`); the same holds for `AeScope`.

| Method | SQL effect when acting group is `N` | When acting group is `null` |
|---|---|---|
| `apply($q, $user, $col)` (`AeScope.php:92-97`) | `WHERE {col} = N` | untouched builder |
| `applyShared($q, $user, $col)` (`AeScope.php:107-114`) | `WHERE ({col} IS NULL OR {col} = N)` | untouched builder |
| `allows($user, ?int $groupId)` (`AeScope.php:121-126`) | `true` iff acting group is `null` **or** `=== $groupId` (strict `===`, so `null !== 0`) | always `true` |
| `allowsShared($user, ?int $groupId)` (`AeScope.php:133-136`) | `true` when `$groupId === null`, else defers to `allows()` | always `true` |

**The rule, stated in the code:** `applyShared` is the *shared read partition* — "platform rows (NULL) plus the acting agency's" (`AeScope.php:100`) — and **writes stay behind the strict `apply()`/`allows()`** (`GroupScope.php:60-63`; `ProjectsController.php:1052-1057`).

`applyShared` is used on **`ae_projects` and nothing else**:

| Call site | Purpose |
|---|---|
| `ProjectsController::sharedProjects()` (`ProjectsController.php:1059-1069`) → the hub list (`:89`), `show()` (`:213`), `updateClosers()` lookup (`:377`), the lead form's project picker (`:794`) | reading a deal |
| `LeadsController::projectOptions()` (`:682`), `resolveProject()` (`:702`) | filing a lead under a platform deal |
| `DashboardController` setup steps + project rows (`:74`, `:119`, `:441`) | |
| `ShowroomController::projectLabels()` (`:353`) | the appointments filter drawer |
| `WorkflowsController::projects()` (`:542-551`) | the workflow's project picker |

Strict `apply()` guards **every AE write** and every other read. On `ae_projects` the strict variant is `ProjectsController::projects()` (`:1072-1082`), used by `store()`'s duplicate-name check (`:178`), `update()` (`:429`), `destroy()` (`:486`), `sales()` (`:577`) and `updateSheet()` (`:612`) — so an agency can *read* and *use* a platform project but can never rename, archive or delete it. The one object-level check in the suite is `abort_unless(AeScope::allows($viewer, $groupId), 403)` in `updateClosers()` (`ProjectsController.php:386`), which is what lets a platform viewer write any agency's closer list (and the platform's own, `group_id NULL`) while an agency user may write only their own.

##### Why projects are shared downward

An AE project is "shared downward the way FLG projects are (`group_id NULL` = the platform's, readable by every agency); each agency brings its own leads to it and names its own closers for it" (`database/migrations/2026_09_08_120000_create_ae_project_closers_table.php:7-15`). The same product — a developer's launch — is sold by many agencies, so the platform can seed the deal once and every agency works its own leads against it. The consequences are structural:

- **Closers are per (project, agency).** `ae_project_closers` carries its own `group_id`; `NULL` is the platform's list for that project (`ae_project_closers` migration `:23-24`). `ProjectsController::closerBoard()` renders one row per agency the viewer may see, plus a platform row when `AeScope::groupId($viewer) === null` (`ProjectsController.php:1013-1024`).
- **The rotation falls back to the platform's list.** `CloserRotation::projectCloserIds()` takes rows whose `group_id` matches the lead's; "if that agency has none **and** the lead has a group", it falls back to `whereNull('group_id')` (`src/AppointmentEngine/Services/CloserRotation.php:367-381`). This is the pool for `POOL_PROJECT = 'project'`, which is `CloserRotation::DEFAULTS['pool_type']` (`:88-90`).
- **A platform staffer in "All agencies" creates platform-owned rows.** `Project::create(['group_id' => AeScope::groupId($viewer), …])` (`ProjectsController.php:188`) stamps `NULL`, which is exactly how a shared deal is minted.

---

#### 4. Where `group_id` is stamped on write

The acting agency is stamped at creation and then inherited down the graph; nothing re-derives it later.

| Row | Stamped from | Line |
|---|---|---|
| `ae_projects` (hub, and Ingest's auto-create) | `AeScope::groupId($viewer)` | `ProjectsController.php:188`, `IngestController.php:359` |
| `ae_leads` (added by hand) | `AeScope::groupId($viewer)` | `LeadsController.php:145` |
| `ae_leads` (CSV import) | `AeScope::groupId($viewer)` | `LeadsController.php:228` |
| `ae_leads` (engine triggers) | `$workflow->group_id` | `src/AppointmentEngine/Runner/Triggers.php:596` |
| `ae_workflows` | `AeScope::groupId($viewer)` | `WorkflowsController.php:132` |
| `ae_workflow_runs` | `$lead->group_id` | `Triggers.php:631` |
| `ae_calls`, and the AE appointment row | `$lead->group_id` | `Runner/Handlers/AiCall.php:60, 204, 396` |
| `ae_closer_handoffs` | `$lead->group_id` | `CloserRotation.php:197` |
| `ae_documents`, `ae_content_items` | `AeScope::groupId($viewer)` | `IngestController.php:138, 165` |
| `ai_call_profiles` (new) | `['group_id' => AeScope::groupId(auth()->user())]` via the `newProfileAttributes()` seam | `AppointmentEngine/AiProfilesController.php:78-81`, applied at `Calls/AiProfilesController.php:195-197` |

---

#### 5. The team filter on the project page

`ae_leads.team_id` was added 2026-09-08: "A lead's TEAM inside its agency (owner: a project page reads per team — Alpha's leads and appointments are not Bravo's). Stamped from the closer's team when the engine assigns one (`CloserRotation::offer`), or the actor's team when a person adds the lead by hand; NULL = not yet on a team. **Appointments and runs follow their lead, so only the lead carries it.**" (`database/migrations/2026_09_08_130000_add_team_id_to_ae_leads_table.php:7-12`). Column: `unsignedBigInteger('team_id')->nullable()->index()->after('group_id')` (`:19`), fillable at `src/AppointmentEngine/Lead.php:94`.

**Where it is stamped**

| Path | Value | Line |
|---|---|---|
| Manual add on the Leads page | `$viewer?->admin?->team_id` — "A lead added by hand starts on the adder's own team" | `LeadsController.php:146-147` |
| Closer assignment | `$closer->admin?->team_id ?: $lead->team_id` — "The lead joins the closer's team", written with `forceFill(...)->save()` alongside `assigned_admin_id` inside the offer transaction | `CloserRotation.php:210` |

**What it narrows.** `?team={id}` on `GET projects/{id}` is resolved once by `ProjectsController::teamFilter()` (`:955-969`) and then threaded through every panel of the page, so one switch narrows the whole page:

| Consumer | Effect |
|---|---|
| `ProjectsController::leadQuery(..., $teamId)` (`:888-906`) | `where('ae_leads.team_id', $teamId)` — the Leads tab's paginator (`projectLeads()`, `:754-780`) |
| The Appointments tab and calendar | indirectly: both are `Appointment::whereIn('ae_lead_id', $leadIds)` where `$leadIds` came from the narrowed `leadQuery` (`:236`, `:243`, `:251`) |
| `ProjectFunnel::byProject(..., $teamId)` (`src/AppointmentEngine/Support/ProjectFunnel.php:57, 75`) | the four leads/spoke/booked/attended numbers, on both the lead aggregate and the appointment aggregate (the latter joins `ae_leads` to reach `team_id`) |
| `FunnelSummary::build(..., $teamId)` (`src/AppointmentEngine/Support/FunnelSummary.php:37, 166-171`) | the summary band |

**The guard.** `teamFilter()` honours `?team=` only when the id resolves against `Team::where('id', $teamId)->when($groupId, fn ($q) => $q->where('group_id', $groupId))->exists()`; `$teamId <= 0` or no viewer returns `null` (`:957-969`). So an agency user cannot borrow another agency's team id — but note the `when($groupId, …)`: **in the platform view (`groupId === null`) any team id is accepted**, which the docblock states outright ("any team in the platform view", `:951`).

**The switcher's vocabulary.** `teamOptions()` (`:923-945`) returns `[]` when `AeScope::groupId($viewer) === null` — "Empty in the platform view (no single agency to split)" — otherwise every `Team` in the acting agency with `members` (`admins_count`) and `leads` (this project's leads on that team, from one grouped aggregate). The page also receives `teamTotal`, the un-narrowed lead count, so the "All teams" pill can show a number the paginator no longer reflects (`:291-293`).

---

#### 6. Permissions

Two permissions belong to the suite, both declared in `Src\Auth\Permission` and catalogued under the group `'AI Appointment System'` (`src/Auth/Permission.php:303`):

| Constant | Value | Line | What it gates |
|---|---|---|---|
| `Permission::VIEW_APPOINTMENT_ENGINE` | `'view-appointment-engine'` | `Permission.php:189` | The whole route group (`routes/web.php:1957`) and every AE sidebar entry (`resources/js/Layouts/ManageLayout.vue:208-213`) |
| `Permission::MANAGE_APPOINTMENT_ENGINE` | `'manage-appointment-engine'` | `Permission.php:190` | The suite's **Setting** sidebar entry (`ManageLayout.vue:246-249`) and one server-side widening: it lifts the sales-agent own-leads narrowing in `LeadsController::scoped()` (`:502`) |

The docblock explains the split: "View reads the console (coverage, leads, calls, threads); manage edits what the AI says and does (workflow scripts, knowledge, routing rules, campaign controls)." It also warns that this is **not** `VIEW_APPOINTMENTS` — "this gates the SUITE, not the entity" (`Permission.php:182-188`).

**Who holds them, by default seed:**

| Role (`Src\Auth\Role`) | `view-appointment-engine` | `manage-appointment-engine` | Source |
|---|---|---|---|
| `super-admin` | yes | yes | `syncPermissions(Permission::allPermissions())`, `database/seeds/RolesSeeder.php:37` |
| `admin` (legacy platform-staff role) | yes | yes | all permissions except `manage-roles`/`manage-admins`, `RolesSeeder.php:43-46` |
| `group-super-admin` | yes | no | `$oversight`, `app/Actions/SeedCommonRolesAction.php:107` (merged at `:110`) |
| `sales-leader` | yes | no | `$oversight`, `SeedCommonRolesAction.php:107` (merged at `:122`) |
| `sales-agent` | yes | no | `$agent`, `SeedCommonRolesAction.php:143` |
| `member` / `non-member` | never (blocked by `admin` middleware) | — | `EnsureUserIsAdmin.php:36-40` |

The reasoning is written into the seeder. For the oversight roles: "VIEW only: the agent leader is the persona the console is built for and reads it daily, but editing what the AI SAYS to a customer — its scripts, knowledge and routing rules — stays with whoever holds MANAGE. Agents hold VIEW too since 2026-09-08 (the console is their agency's activity)." (`SeedCommonRolesAction.php:102-107`). For agents: "The AI Appointment System is group-based (owner, 2026-09-08): an agent reads their agency's projects, leads and appointments there — every AE read is GroupScope'd, so VIEW is the whole grant." (`:140-143`). The action is idempotent — defaults are applied only when a role is newly created or holds zero permissions, so a tuned matrix is never overwritten (`SeedCommonRolesAction.php:42-47`).

**Existing installs are patched by migration, not by the seeder**, because "deploys run `migrate` and never `db:seed`" (`database/migrations/2026_08_29_100001_grant_appointment_engine_permissions.php:10-17`). That migration creates both permission rows (MANAGE must exist "so the Roles page can offer it and so `can()` resolves rather than throwing") and grants **VIEW only**, to `['group-super-admin', 'sales-leader']` — its own comment says "Agents are deliberately absent" (`:31`), which predates the 2026-09-08 decision to give agents VIEW. Names are hard-coded on purpose: "a historical migration must replay the same database change years later even if the application constants are renamed or deleted" (`:21-23`). Its `down()` refuses to drop a permission row still granted to anything (`:110-118`).

**Permissions from other modules that the AE relies on:**

| Permission | Where it bites |
|---|---|
| `manage-appointments` | `updateClosers()` — `abort_unless($viewer !== null && $viewer->can(...), 403)` (`ProjectsController.php:375`); the closer board's `can_manage` flag (`:1026`); the hand-off override in `HandoffsController::handoffFor()` (`:81`) |
| `manage-projects` | writing prices / commission / VP date onto the bridged CRM project; without it the name is saved and a warning is flashed (`ProjectsController.php:532-536`) |
| `view-projects` | the deal's Sales tab, which delegates to the CRM's own `SalesProjectsController` (`ProjectsController.php:580-584`) |
| `manage-leads` | the lead page's `can_manage` flag (`LeadsController.php:448`) |
| `sales-execution` | the `POOL_GROUP` candidate filter — "the agency's assignable sales staff", `->permission(Permission::SALES_EXECUTION)` scoped to `$lead->group_id` (`CloserRotation.php:350-353`) |

**One extra narrowing inside the tenancy partition.** `LeadsController::scoped()` applies `AeScope` and then, for a sales agent without MANAGE, restricts to their own leads:

```php
AeScope::apply($query, $viewer, 'ae_leads.group_id');
if (! $viewer->can('manage-appointment-engine') && $viewer->hasRole('sales-agent')) {
    $query->where('ae_leads.assigned_admin_id', $viewer->id);
}
```
(`LeadsController.php:494-507`) — with the reason: "the spec is explicit that an agent reaching team-wide data is a breach, not a UI slip, so it is enforced here rather than by hiding a nav entry" (`:485-488`).

**Who may be a closer** is a separate, role-based list. `ProjectsController::closerCandidates(?int $groupId)` (`:1037-1051`) takes `User::STATUS_ACTIVE` users whose `admin.group_id` matches the bucket (`whereNull` for the platform bucket) and who hold one of `[Role::SALES_LEADER, Role::SALES_AGENT, Role::GROUP_SUPER_ADMIN]` for an agency, or `Role::manageRoles()` (= `super-admin`, `group-super-admin`, `sales-leader`, `sales-agent` + custom roles, `src/Auth/Role.php:80-88`) for the platform bucket. `updateClosers()` then intersects the submitted ids against that list, so a foreign user id is dropped rather than rejected (`ProjectsController.php:388-393`).

---

#### 7. Which tables carry `group_id`, and which are scoped through a parent

The base migration states the principle: "`group_id` on every table because that is how this codebase already partitions an agency's data (`Src\Auth\Support\GroupScope`), and the spec's §4A is explicit that tenancy binds to what exists rather than inventing a parallel scheme" (`database/migrations/2026_08_29_160000_create_appointment_engine_tables.php:22-24`). Every one is `unsignedBigInteger(...)->nullable()->index()` with **no** schema-level foreign key.

**Tables carrying their own `group_id`**

| Table | Uniqueness / composite index on `group_id` | Migration |
|---|---|---|
| `ae_leads` (+ `team_id`) | `(group_id, stage)`, `(group_id, assigned_admin_id)` | `2026_08_29_160000…:33, 70-71`; `2026_09_08_130000…:19` |
| `ae_calls` | `(group_id, created_at)`, `(group_id, ae_lead_id)` | `2026_08_29_170000…:37, 64-65` |
| `ae_threads` | `(group_id, last_message_at)` | `2026_08_29_180000…:25, 45` |
| `ae_projects` | — (`NULL` = platform, shared downward) | `2026_08_29_210000…:27` |
| `ae_content_items` | `(group_id, kind, status)` | `2026_08_29_210000…:37, 61` |
| `ae_documents` | `(group_id, created_at)` | `2026_08_29_220000…:27, 46` |
| `ae_connections` | **`UNIQUE (group_id, provider)`** — one Retell/etc. account per agency | `2026_08_29_230000…:26, 45` |
| `ae_settings` | **`group_id` UNIQUE** — one settings row per agency | `2026_08_29_233000…:21` |
| `ae_routing_rules` | — | `2026_08_29_234000…:26` |
| `ae_flow_nodes` | **`UNIQUE (group_id, node_key)`** | `2026_08_29_240500…:26, 51` |
| `ae_workflows` | — | `2026_08_30_100000…:27` |
| `ae_workflow_runs` | — (stamped from `$lead->group_id`) | `2026_08_30_140000…:26` |
| `ae_blocked_numbers` | **`UNIQUE (group_id, phone)`** | `2026_08_30_110000…:38, 51` |
| `ae_visits` | — | `2026_08_30_120000…:27` |
| `ae_sheet_cursors` | — (`ae_workflow_node_id` is UNIQUE) | `2026_09_01_100000…:24-25` |
| `ae_closer_handoffs` | — (stamped from `$lead->group_id`) | `2026_09_08_100000…:24` |
| `ae_project_closers` | — ; here `group_id` means **whose list this is** for a shared project, `NULL` = the platform's | `2026_09_08_120000…:23-24` |
| `ae_appointments` *(superseded)* | `(group_id, scheduled_at)`, `(group_id, assigned_admin_id)` | `2026_08_29_200000…:30, 46-47` |

**Host tables given a `group_id` so the AE can partition them**

| Table | Column added | Why | Migration |
|---|---|---|---|
| `appointments` | `group_id` (+ `ae_lead_id`, `ae_call_id`, `wa_flow_run_id`, `assigned_admin_id`, `source`, `zoom_meeting_id`, `meeting_link`) | The 2026-09-06 appointment merge: the engine "stops keeping its own `ae_appointments` book and writes THE book". AE reads are `AeScope::apply($q->whereNotNull('appointments.ae_lead_id'), $viewer, 'appointments.group_id')` — the `ae_lead_id` predicate is what keeps human CRM rows out | `2026_09_06_170000…:7-30`; `ShowroomController.php:544`, `DashboardController.php:680` |
| `ai_call_profiles` | `group_id` | "The CRM holds profiles per install; the AI Appointment System is sold per agency… A nullable `group_id` keeps every existing (install-wide) profile exactly as it is" | `2026_08_30_170000…:7-19`; scoped at `AppointmentEngine/AiProfilesController.php:70` |

**Tables with NO `group_id` — scoped only through their parent**

| Table | Parent it inherits from |
|---|---|
| `ae_workflow_nodes` | `ae_workflow_id` → `ae_workflows.group_id` |
| `ae_workflow_edges` | `ae_workflow_id` → `ae_workflows.group_id` |
| `ae_workflow_run_logs` | `ae_workflow_run_id` → `ae_workflow_runs.group_id` |
| `ae_call_turns` | `ae_call_id` → `ae_calls.group_id` |
| `ae_messages` | `ae_thread_id` → `ae_threads.group_id` |

The practical consequence: **there is no query you can write against a child table that is safe on its own.** Every read of one must start from the scoped parent (e.g. `WorkflowsController::planProps` reaches nodes only through a workflow already narrowed by `workflows()`, `WorkflowsController.php:557-566`). `WorkflowRun` rows *do* carry `group_id`, but `LeadsController::flow()` still reaches them via a lead resolved through `scoped()` first (`LeadsController.php:750-755`).

---

#### 8. Every AE `AeScope` call site, at a glance

| Controller / service | Table + column | Variant |
|---|---|---|
| `ProjectsController::sharedProjects` / `projects` | `ae_projects.group_id` | shared (reads) / strict (writes) — `:1059`, `:1072` |
| `ProjectsController::leadQuery` | `ae_leads.group_id` | strict — `:895` |
| `ProjectsController::updateClosers` | object-level | `AeScope::allows` — `:386` |
| `ProjectsController` (`closerBoard`, `teamOptions`, `teamFilter`, Retell connection) | — | `AeScope::groupId` — `:739`, `:925`, `:963`, `:1013`, `:1019` |
| `LeadsController::scoped` | `ae_leads.group_id` | strict + agent own-leads — `:500-504` |
| `LeadsController::resolveWorkflow` / `liveWorkflows` | `ae_workflows.group_id` | strict — `:308`, `:324` |
| `LeadsController::projectOptions` / `resolveProject` | `ae_projects.group_id` | shared — `:682`, `:702` |
| `LeadsController::agents` | `users` via `admin.group_id` | `whereHas` on `groupId()` — `:538-539` |
| `DashboardController::scope` | any (`ae_leads`, `ae_calls`, `ae_threads`, `appointments`, `ae_workflows`) | strict — `:695` |
| `DashboardController` (setup steps, project rows) | `ae_projects.group_id` | shared — `:74`, `:119`, `:441` |
| `ShowroomController::appointmentQuery` | `appointments.group_id` + `whereNotNull(ae_lead_id)` | strict — `:544` |
| `ShowroomController::leadQuery` / `projectLabels` | `ae_leads.group_id` / `ae_projects.group_id` | strict / shared — `:599`, `:353` |
| `WorkflowsController::workflows` / `projects` / `profiles` | `ae_workflows.group_id` / `ae_projects.group_id` / `ai_call_profiles.group_id` | strict / shared / strict — `:565`, `:550`, `:461` |
| `ContentController::items` / `nodeFor` / `projects` | `ae_content_items` / `ae_flow_nodes` / `ae_projects` | strict — `:435`, `:326`, `:450` |
| `ContentController` content-picker validation | `ae_content_items` via `Rule::exists` | `groupId()` — "an id belonging to another agency simply does not exist as far as this rule is concerned" (`:163-172`) |
| `IngestController::documents` / `projectOptions` / `resolveProject` | `ae_documents` / `ae_projects` | strict — `:315`, `:328`, `:346` |
| `AiProfilesController::profiles` / `newProfileAttributes` | `ai_call_profiles.group_id` | strict — `:70`, `:80` |
| `FunnelSummary::leadQuery` | `ae_leads.group_id` | strict — `:166` |
| `ProjectFunnel::byProject` | `ae_leads.group_id`, `appointments.group_id` | strict — `:56`, `:73` |

A consistent defensive pattern accompanies it: **every helper collapses a null viewer to `whereRaw('1 = 0')`** rather than returning an unscoped builder (e.g. `ProjectsController.php:1063`, `LeadsController.php:496-497`, `ShowroomController.php:538`, `DashboardController.php:691-693`, `FunnelSummary.php:163`). Copy that when adding a helper.

---

#### 9. The boundary: host pages entered with `?suite=appointment`

Since the 2026-09-07 reform the suite owns no Settings, Team or Conversations pages of its own — `GET settings/{any?}` is a bare redirect to `/manage/messages/channels?suite=appointment` (`routes/web.php:2136`), and the sidebar's Channel / Team / Setting entries point at host modules with the suite token (`ManageLayout.vue:224-249`). Those routes are **outside** the AE group and therefore outside both `ae.agency` and `AeScope` — `grep -n "ae.agency" routes/` returns exactly one line (`routes/web.php:1957`).

So the session's chosen agency does **not** follow an admin onto the host pages. Two verified examples:

- WhatsApp channels offered inside AE come from `HostOptions::channels()`, which uses `AccountVisibility::applyWhatsappChannels()` (`src/AppointmentEngine/Support/HostOptions.php:32`) — a permission-level ladder (`view-whatsapp-channels-all|group|team|own`, `src/Auth/Support/AccountVisibility.php:109-119`), not the AE session choice.
- The AI call ledger at `/manage/calls/ai-calls` reads `ae_calls` through `AiCallBook`, which calls `GroupScope::apply($query, $viewer, 'ae_calls.group_id')` directly (`src/AppointmentEngine/Support/AiCallBook.php:193`).

For an agency member the two agree (both resolve to their own group). For **platform staff acting as an agency**, the host page shows everything while the AE page shows one agency. Keep that in mind before moving a page into or out of the AE route group.

---

## 8. Two hazards that cross the module boundary

### A nightly reaper can silently take a lead back off the closer the rotation just gave it to

Three lead-distribution commands run daily — `ReapNoAppointment` (02:00), `ReapAiFailed` (02:10) and `ReapNoBooking` (02:20), scheduled in `app/Console/Kernel.php:350-364`. Each decides whether to return a CRM lead to the pool by asking whether an appointment exists **on `appointments.lead_id`**:

| Command | The query it trusts |
|---|---|
| `ReapNoAppointment.php:44` | `Appointment::where('lead_id', $lead->id)->where('created_at','>=',$lead->last_assigned_at)->exists()` |
| `ReapAiFailed.php:63` | `Appointment::where('lead_id', $lead->id)->exists()` |
| `ReapNoBooking.php:40-47` | the latest `Appointment` by `lead_id` |

Now put that beside two facts from this module: `CloserRotation` assigns **through** `LeadDistributionService::assign($crmLead, $closer->id, LeadAssignment::REASON_APPOINTMENT)` (`CloserRotation.php:223`), and an AE booking stamps `'lead_id' => $lead->lead_id` **only at create time** (`AiCall.php:396`) — which is `null` whenever `CrmIdentity` has not linked the person yet, and is never backfilled.

**So a lead the rotation just handed to a closer can be pooled away from that closer by a nightly job**, because the very booking that justified the assignment is invisible to the reaper's query. Nothing errors; the closer simply loses the lead. Anyone touching either side should read this first. The fix is a backfill of `appointments.lead_id` when `CrmIdentity` later links the person — see [bridges.md](/docs/modules_handbook/manage/appointment-engine/bridges.md).

### Which dial is capped by what

Two different budget mechanisms, with different defaults, easy to confuse:

| Dial | Cap | Live? |
|---|---|---|
| A **workflow** dial (`AiCall`) | `ae_settings.daily_call_budget_usd` via `Setting::forGroup()` | **No** — nothing can write an `ae_settings` row, so the fuse never fires |
| A **Test call / web call** from the AI-profile screen | `config('services.retell.daily_budget_usd', 15)` and `services.retell.combined_per_minute_usd` (0.25) — `App\Actions\PlaceAiVoiceCall.php:160,180` | **Yes** |

A sentence saying "the budget fuse is off" is only true of the first row.
