# Appointment Engine — changelog

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

How the engine was built, in the order it happened — and, as important, **what was deliberately removed**. Grouped by theme rather than one line per commit; commit hashes are on `dev-wk`.

> This is the "why did it end up like this" doc. For what the code does today, start at [readMe.md](/docs/modules_handbook/manage/appointment-engine/readMe.md).

Eleven days of work, 2026-08-29 → 2026-09-08, across ~112 commits touching `src/AppointmentEngine`, `app/Http/Controllers/Manage/AppointmentEngine`, `resources/js/Pages/Manage/AppointmentEngine`, `app/Jobs/AppointmentEngine`, `app/Console/Commands/AppointmentEngine`, `app/Listeners/AppointmentEngine` and `database/migrations/*ae_*`.

Read this alongside the module's [readMe.md](docs/modules_handbook/manage/appointment-engine/readMe.md). Commit *bodies* in this module are unusually load-bearing — most of them state the product reason for a decision, and where they do, the reason is quoted below rather than paraphrased away.

---

### 2026-09-18 — an owner is not a customer: the listing stops writing leads

The entry below reports "794 bridged to a CRM lead" as a success. It was not. That same import, run for real on 2026-09-18 05:26, put **792 thin Non-Member accounts + leads into the Leads list in one minute** — LIM JEOK LAI, ZULAIKA JASMIN BINTI ZULKIFLI and 790 more, no email, one phone, status New — and the founder saw them on the Leads page and asked the obvious question: "why now i can see so many new names in lead table". His instruction: *"by right it should never be in lead CRM system database. this owner listing database is different thing."*

**Why it happened.** `ProcessOwnerListingChunkJob::resolveLeadId()` resolved every owner through `LeadRepository::firstOrCreateForIdentity()`, whose contract is to CREATE when nothing matches — correct for a funnel registration, a payment or a magic-link sign-in, and wrong for a strata roll. A property owner on a supplier's list has not asked us for anything.

**The fix — link only, never create.** New `LeadRepository::findForIdentity(email, phone, idNumber)`: the read-only half of the identity gate (canonical phone, canonical IC, staff are never customers), returning the lead behind an identity or null. `resolveLeadId()` now calls it, so an owner who IS already a lead is recognised — which is what the Owners table's Lead column and the AE workflow lookup read — and an owner we do not know stays a listing row and nothing more. Nothing is lost by that: the listing holds its own `owner_name` / `owner_phone` / `owner_email`, and per-owner outreach is addressed by `flg_owner_outreach_recipients.owner_listing_row_id` + `phone_e164`, never by a lead.

**The cleanup.** 1,153 leads carried the Owner Listing source (792 from import #16, 354 from import #14 on 2026-09-09, 2 strays). Two were real people and were KEPT — one with a booking, a pipeline, a wealth plan and an AE lead, one that also arrived through a second lead source. The other **1,151 were hard-deleted** with their thin accounts through the same cascade the Leads page's Delete uses (`UserRepository::delete`), after checking each against `DELETE_BLOCKERS` (payments, memberships, bookings, pipelines) and two dozen activity tables; `flg_owner_listing_rows.lead_id` was nulled first so nothing dangles. Leads 13,457 → 12,306, owner rows 4,415 → 4,415. The deleted ids + names + phones were written out before the delete, and the names all still exist in the listing rows, which is where they belong.

Pinned by `tests/Feature/FacebookLeadGenerator/OwnerListingNeverCreatesLeadsTest.php`: an import creates no lead and no account; an owner we already know is linked to their existing lead and that lead gains the owner-listing attribution; a staff member who owns a unit is never linked as a customer; and `findForIdentity()` invents nothing, including for an empty identity.

---

### 2026-09-18 — an owner listing does not have to be a strata roll

A supplier sent the MH Platinum 2 list: 801 owners, three columns — `NAME:`, `CONTACT NUMBER`, `ADDRESS` — with the same building address on every row and **no unit number anywhere**. The import failed outright ("No row in the file carries a unit number"), because `ProcessOwnerListingJob` dropped every unit-less row and `unique(project_id, unit_no)` was the row's identity. Two independent faults, both proven by running the real job against the file: zero of 801 rows survived, and the headings matched nothing either — `NAME:` is not `name`, `CONTACT NUMBER` is not `phone`, so even a fixed-up file would have imported 801 nameless, phoneless rows in silence.

**Identity moves off the unit.** `flg_owner_listing_rows.row_key` (`2026_09_18_100000_add_row_key_to_flg_owner_listing_rows`) is what a re-import upserts on: the unit number when the listing has one, else `"<phone>|<OWNER NAME>"`. `unique(project_id, unit_no)` becomes `unique(project_id, row_key)` and `unit_no` becomes nullable and display-only. Existing rows are all strata-roll rows, so the backfill is `row_key = unit_no` and nothing already imported changes identity. The name is in the key on purpose: two joint owners legitimately share one phone (9 such numbers in this one file), and keying on the phone alone would have deleted a person from the listing.

**A row survives if it has a unit OR a person**, and the import fails only when no row has either — the message now names both columns and points at the sample file.

**No unit means no floor plan, said out loud.** Suffix matching and the Gemini classification are skipped for a unit-less row (`matchRule('')` reads a blank unit as unit **0** and would have handed every owner the first rule whose range covers zero), `RematchOwnerListingJob` skips them, and `unmatchedOwnerCount` excludes them — otherwise the page offers a permanent "Re-match (795 unmatched)" button that can never change a row. The owners table carries an amber banner saying what the listing can and cannot do, and what to ask the supplier for.

**Per-owner outreach is addressed by row id.** `draftMessage` / `sendMessage` took `unit_no`; with no units that resolves to the wrong owner or none. `Show.vue` now posts `row_id` and its table keys on `row.id`. Both endpoints accept **either** (`required_without`, row id preferred) through the one resolver `OwnerListingRow::locate()` — the unit is still unique per project on a strata roll, an existing caller was already using it (`FlgCanonicalFactsTest`), and accepting it means a page served minutes before a deploy does not 422. A **blank** unit is refused outright (404) rather than matching whichever unit-less row comes first.

**Headings are matched through their punctuation** — `OwnerListingRow::canonicalHeaders()` strips trailing `: * . # -`, folds `h/p` to `hp`, collapses whitespace, and **never touches underscores** (collapsing them would have quietly broken every `owner_phone` heading, including the Google Sheet trigger's, which shares `LeadRowMapper`'s conventions). Aliases gained `contact number`, `contact no`, `contact`, `phone number`, `hp`, `owner`.

Verified against the supplier's untouched `.xlsx` through the real job: **795 rows, every one with a name and a phone, 794 bridged to a CRM lead, 0 floor plans** (that bridging was reverted the same day — see the entry above), joint owners both present; re-importing it upserts in place (795 → 795). The Residensi Teja strata roll is unchanged — 237 rows, `row_key = unit_no` on every one, re-import upserts on the unit. Pinned by `tests/Feature/FacebookLeadGenerator/OwnerListingWithoutUnitsTest.php` (8 tests, the job and the parsing) and `OwnerListingUploadRouteTest.php` (5 tests, the HTTP edges: the upload route, the job reading the STORED file, and both owner-message endpoints). Also driven in a real headless browser against the local site with the 795 unit-less rows on screen: the banner renders, the unit column is all `—`, no Re-match button, and the per-row **Message** button posts `{project_uuid, row_id: 7601, message_type}`.

Two pre-existing failures in these directories, both verified NOT from this change (`git stash`, same failure at HEAD): `OwnerListingFlowTest::test_linking_from_a_read_only_catalogue_domain_does_not_write_master_floor_plans`, and `FlgCanonicalFactsTest::test_flg_leads_index_sorts_labels_and_rows_by_catalogue_name` — the latter because `FlgLeadsController:57` calls `Project::canonicalNameOrderSql()`, **which is not defined anywhere**, so the Subsale leads index throws. Worth its own fix; out of scope here.

---

### 2026-09-10 — the Subsale owner listing: import owners after create, work them owner by owner

The owner's brief: after the create-project pop-up, ask whether to import an owner listing; if yes, run the Subsale module's own import process (pick from the database or upload a spreadsheet); add a flow like the Google Sheet one that is started by hand for every owner, with a per-owner execution history and pause / resume; the rest — foundation steps, extra AI-caller and WhatsApp steps from the rail, a brain with an objective on each caller — as it already is.

**The question after create.** `ProjectsController@store` now lands on `GET projects/{id}/owner-listing` (`OwnersController@onboarding`, `Pages/Manage/AppointmentEngine/Projects/OwnerListing.vue`): *Import an owner listing for this project?* with Skip. Yes needs the catalogue entry — the listing is classified against **catalogue-owned** floor plans, so a hand-named engine deal must first say which catalogue project it is (the same `/manage/property/catalog/search` picker the Subsale create uses). `POST projects/{id}/owner-listing` (`@enable`) calls the new `ProjectRepository::linkCatalogue()` (sets `catalog_project_id`, syncs the cache from the catalogue — which renames the deal to the catalogue's canonical name — and seeds the floor plans) and `FocusProjectRepository::create()` for the existing project, making it a Subsale project as well. Since the 2026-09-09 merge a deal IS the CRM project, so the listing hangs off the same row.

**The listing page is the Subsale module's, at this suite's URL.** Once enabled, the same route delegates to `FacebookLeadGenerator\ProjectsController::renderShow($uuid, TYPE_SUBSALE_SUITE)` — the exact "Get started with your owner listing" onboarding (floor plan types → rules → From database / Upload file), then the owner table and outreach tools — `->with('suite', 'appointment-engine')` and a `module` override that points its back link and labels at the project page. Same delegation the Sales tab and the lead page use; gated on `view-projects` like the Sales tab.

**The `owners` entry.** `Plan::ENTRIES` gains `owners` → `trigger.owner_listing` (`NodeCatalogue`, `HandlerRegistry` → Passthrough), with one setting, `pace_per_minute` (the sheet's dial stagger). Nothing polls it: `Triggers::startOwner($workflow, $row, $startAt)` turns one `flg_owner_listing_rows` row into (or onto) an engine lead — `source = owners`, new column `ae_leads.owner_listing_row_id`, `lead_id` copied from the row the import already resolved (`CrmIdentity::TRUST_BY_SOURCE` maps `owners` to TYPED) — and opens a PENDING run at `$startAt`. Re-startable after a finished or stopped run; only an open or paused run blocks. The Workflow tab offers a third entry card ("Subsale owner listing flow"); `WorkflowsController@store` seeds it as a **copy of the project's Google Sheet flow** (steps, brains, chat, booked chain, hours — only the entry changes), falling back to the default one-step plan when the project has no sheet flow. The Start block reads *started by hand · N owners a minute*, and the entry drawer explains the tab and holds the pace. `RunProgress::entryLabel` → `Owners`.

**The Owners tab** (`Projects/Show.vue` → `Partials/OwnersTab.vue`, payload `Support\OwnerBoard`, an `Inertia::optional` prop keyed by `?ow=` and `?osearch=`): every owner row with unit, name, phone, floor plan, and — for the chosen owners flow — the run's status pill and position (`4/16`, `Booked ✓`), next fire time and last trail line; counts by bucket; search; tick owners and **Start N selected**, or **Start all N not started** (confirm modal states the pace); per row **Start / Start again**, **Pause**, **Resume**, **History** (the inbox's read-only flow view — `WorkflowTab readonly` + the trail — via `GET leads/{id}/flow?run=&limit=60`; the endpoint gained the capped `?limit=`), and the lead link. Empty states point at the listing page. `POST projects/{id}/owners/start` (`@start`: paces `intdiv(n, pace)` minutes, refuses a paused flow, reports started / already in the flow / no phone), `POST projects/{id}/owners/{row}/pause|resume`.

**Pause is a new run state.** `WorkflowRun::STATUS_PAUSED` (`Paused`, violet) is NOT open, so `WorkflowRunner::advance()`, the heartbeat and every event wake leave a paused run exactly where it is; `Services\RunControl::pause()` stores `{status, waiting_for, resume_at}` in `context.paused_from` and `resume()` puts them back (a run caught mid-step re-enters as due now; a wait that fell due while paused fires on resume; a call that settled while paused is caught by the handler's own reconcile on re-entry). Both write a trail line with who did it. `ChatTakeover::stopSequence()` now stops paused runs too — a stop is final, and a later resume must not revive a killed sequence. Stop stays where it was (the inbox card); the Owners tab offers Pause / Resume only.

**Proven on a real listing (2026-09-13), and two fixes it forced.** Driven through the real routes on a local copy with a real 1,249-contact listing (Vivo Executive Apartment): library ingest → create → question → catalogue link (6 floor plans) → delegated onboarding → rules → From database → owners flow copied from the sheet flow → Start all → board → pause / resume / history, with the flow's first step a 999-day wait so nobody was dialled.
- *The Subsale import could not finish a listing this size.* `ProcessOwnerListingJob` did everything in one 180-second job — one Gemini call for every unmatched unit (timed out at 90s) and a CRM lead resolved per owner — and was killed at 1,002 of 1,236 rows; a timeout kill skips the job's catch, so the import sat at "processing" and the page polled forever. The job now parses and rule-matches only, then runs a `Bus::chain` of `ProcessOwnerListingChunkJob` slices of 200 (`CHUNK_SIZE`) — each AI-classifies its own unmatched units, saves its owners with floor plan and lead, and advances `processed_rows`; the last marks the import ready. Both jobs set `failOnTimeout` and a `failed()` that marks the import failed. Result: 1,236 owners (1,233 with a phone, 1,232 bridged to a CRM lead) in ~10 minutes, 7 slices of 31–105s. Gemini still timed out on 3 of 7 slices, so 651 of 1,236 units got a floor plan; the rest are saved unclassified and the existing Re-match can classify them later.
- *An owner of several units was stranded.* The engine dedupes by phone, so a person owning three units is one lead and one run (correct — nobody gets three calls) — but the board keyed runs by owner row only, so their other units read "not started" forever and Pause / Resume could not find the run (13 units on this listing). `OwnerBoard` now reaches a lead through the row OR the row's phone, prints "same owner as A-08-01" on the extra units, and `OwnerBoard::leadFor()` is what the Pause / Resume endpoints resolve through.
- Start all 1,233 owners: 32s, one run per person, paced to finish ~10 hours out at 2 a minute; the Owners tab served all 1,236 owners in ~700ms (~780 KB).

**QA round (2026-09-15) — three fixes from the first real calls.**
- *Friday 12pm was saved as Saturday 3am.* Retell's post-call analysis returned `appointment_time` as `2026-09-18T12:00:00-07:00` — the right day and clock time, stamped with the model's own Pacific offset — and `parseWhen()` honoured the offset. Now an ISO stamp is read as the CLOCK TIME the customer agreed, in `app.user_timezone`, whatever offset it carries (`AppointmentObjective::parseWhen`; `foreignOffset()` puts a line in the run trail when the stamp was foreign, so the booked hour and the raw value can be reconciled). Upstream, every booking-time extraction field now names the market's timezone and offset (`AppointmentObjective::isoClause()`, used by the goal's own field and by `RetellAgentSync`'s payload guidance) — the payload wording reaches an agent the next time its brain is saved, the parser covers agents synced before.
- *The first flow sat on "Loading the flow…" until a refresh.* Creating a flow from the empty Workflow tab redirects back with `workflows` filled and `planDetail` still null — null before, null after — so the `planDetail` watcher never fired and nothing asked for the plan. `WorkflowTab` now also loads when the workflow list first appears. Reproduced and re-verified in a headless Chrome against the local site.
- *No WhatsApp confirmation after the booking.* The flow's confirmation slot held `ai_caller_thanks_v2` — a 2-blank Chinese "thanks" template — while the booked chain fills 5 blanks, so Meta refused both sends ("#132000 Number of parameters does not match"), hours after the trail had said "Booked and confirmed"; the refusal sat only on the message row. Three layers now: the publish gate (`Workflow::bookedProblems`) validates the confirmation and the reminder template against the step's blanks with the same `TemplateStepValidator` the conversation-flow opener uses; `WhatsappSender::send` runs that validator before queueing, so a mismatch ends the step as "Booked; confirmation not sent: …" with the reason; and `SendWhatsAppMessage::recordWorkflowOutcome` writes any later provider refusal onto the run trail (`whatsapp` event). The booked drawer keeps the blank list sized to the confirmation template and warns when the reminder's blank count differs. On WK the fix is the flow's own: pick `appt_confirm` (5 blanks) as the confirmation.
- *The catalogue match is optional, and says why it exists.* The question page explains that floor plan types (layout, size, bedrooms) come from the catalogue entry, marks the match Optional, and offers "Continue without a catalogue match" beside the primary; `OwnersController::enable` accepts a null id. An unmatched project's listing page (`catalogueLinked` false from the Subsale controller) turns step 1 into "Floor plan types (needs a catalogue match)" whose button goes to the match page (`module.catalogue_url` → the question page with `?link=1`, which reads as "Match … to its catalogue entry" while the listing is already on), keeps step 2 (rules) off, and lets step 3 import without rules — owners land with unit, name and phone only. Nothing here lets a project DEFINE floor plans outside the catalogue: those rows are master-owned, so "define it themselves" means matching an entry and editing it on the catalogue admin domain.
- *The four writes take the same scope rule as the page (2026-09-18).* `OwnersController::enable` / `start` / `pause` / `resume` gated on the strict `AeScope::allows()` while `onboarding()` (the GET) uses `allowsShared()`, so on a SHARED project (`group_id` NULL) any viewer with a chosen agency saw the question page and then a 403 on "Continue without a catalogue match" (reproduced on Rica Residence). All four now use `allowsShared()`; the rule is: a write on a project must pass the scope check the page that offered it passed.
- *Sales & Bookings is a READ, so it takes the shared scope (2026-09-18).* `ProjectsController::sales()` looked the deal up through the strict `projects()` (agency rows only) while `show()` uses `sharedProjects()`; an agency-scoped viewer on a shared deal saw the tab and got a 404 behind it. `sales()` now uses `sharedProjects()`. `update` / `destroy` / `updateSheet` stay strict on purpose — reads are shared downward, writes are not.
- *A booking belongs to the deal whose flow made it (2026-09-18).* Both booking writers (`AiCall::bookIfPromised`, `RecordWhatsappBooking`) stamped `appointments.project_id` from the LEAD, and a lead is deduped by phone and never moved between projects — so a person first enrolled by project A's flow and later called by project B's flow booked under A, and B's Appointments tab stayed empty while the customer held a confirmation. The stamp is now the RUN's workflow project, the lead's own only as the fallback. (Found on Rica Residence, whose test lead had been born under a deal the prod→dev database refresh had since removed — `projects` ids 54–68 are gone while `ae_workflows` 22/27/49/54/57/58/59/60 and ten `ae_leads` still point at them; that orphan set is a data decision, not code.)



**Unchanged on purpose:** the caller brain and its objective live on the AI-call step (`profile_id` → `AiCallProfile::objective`) exactly as before; the rail's AI caller / WhatsApp template steps, the chat brain and the booked chain apply to an owners flow like any other. The publish gate is the same too — an owners flow goes live only once its caller has a brain and the booked chain has its templates.

---

### 2026-09-09 — the database tidy: the engine's duplicate tables merge into the master ones

The owner's instruction was one sentence: *tidy up the database for the appointment engine — merge the duplicates back into our master ones.* An audit of every `ae_*` table against the platform's own found three kinds of table, and each got a different answer. Four migrations, dated 2026-09-09, in order:

**Dropped outright — seven tables with no live writer** (`2026_09_09_100000_drop_appointment_engine_dead_tables`). `ae_appointments` (retired by the 2026-09-06 merge into `appointments`, never read since), `ae_visits` (designed, never implemented), `ae_threads` + `ae_messages` (every send rode the host WhatsApp tables since the engine's own WhatsApp pages went; the two Dashboard counts that still read them now read `whatsapp_conversations` with the host inbox's own "unreplied" rule), `ae_flow_nodes` (the legacy per-agency canvas the plan editor replaced — `FlowNode`, `CallProfileFlow`, the dead half of `ContentController` and `Automation/Index.vue` went with it), `ae_routing_rules` (no page ever wrote a rule; `CloserRotation` had only ever fallen through to round-robin — `RoutingRule`, `RoutingEngine` and `STRATEGY_RULES` are gone), `ae_settings` (three per-agency numbers with no settings page: the commission tile they fed had never rendered, so it left the Dashboard; the daily call budget already had `appointment_engine.voice.daily_budget_usd`, which is now the one that is read).

**`ae_projects` → `projects`** (`2026_09_09_110000_merge_ae_projects_into_projects`). Six columns, every one with a home on the CRM project the row was already bridged to; no engine-only column existed — the per-deal state was always in child tables. The migration bridges every unlinked engine deal (name match within the group, else a minted custom sales project, exactly `CrmProject::ensureLinked()`'s rule), renames `ae_project_id` → `project_id` on `ae_leads`, `ae_workflows`, `ae_content_items`, `ae_documents` and `ae_project_closers`, archives the CRM project of an archived engine deal, and drops the shadow. `Src\AppointmentEngine\Project`, `CrmProject` and `ae:link-projects` are deleted; `Src\Property\Project` grew `aeWorkflows()`, `aeLeads()`, `aeContentItems()` and `aeClosers()`; the engine's `Lead::crmProject()` is the relation (named so because `project` is that table's legacy display-name column). The AE Projects page now lists the agency's CRM projects; create / rename / archive go through the sales side's own repositories (`ProjectRepository` gained `activate()` as `archive()`'s reverse); delete refuses a project that has leads, workflows OR sales engagements.

**`ae_blocked_numbers` → `ai_call_blocked_numbers`** (`2026_09_09_120000_merge_ae_blocked_numbers_into_ai_call_blocked_numbers`). The two do-not-call lists carried the same three columns and the same "safety feature, never automatic" rationale, and each caller checked only its own — so a number blocked from a CRM call was still dialled by a workflow. The master list gained a nullable `group_id` (NULL = the platform's list, binding every caller; an agency's row binds that agency's calls), the unique key became `(group_id, phone_e164)`, and `AiCallBlockedNumber::covers($e164, $groupId)` / the repository's `block()` / `unblock()` take the list.

**`ae_calls` + `ae_call_turns` → `ai_voice_calls` + `ai_voice_call_turns`** (`2026_09_09_130000_merge_ae_calls_into_ai_voice_calls`). The engine's ledger was a deliberate copy of the CRM's — same provider, same payload, same cents-to-dollars rule — plus nine columns that were genuinely its own, which came across: `group_id`, `ae_lead_id`, `channel` (ai | human), `made_by`, `notes`, `outcome`, `refusal_reason`, `telephony_cost`, `lead_spoke`. The engine's status 0 "Not called" became the CRM's own convention (FAILED + `refusal_reason`); the CRM's existing refusals were back-filled into the new column and `lead_spoke` from their turns, so one predicate finds every refusal on either side. `appointments.ae_call_id` was renamed `ai_voice_call_id` and re-pointed at the copied rows' new ids. With one ledger the engine's **second voice stack went too**: `VoiceCaller`, its `RetellCallMapper`, `RetellWebhookBridge`, the `POST /webhooks/ae/retell` door and `AppointmentEngineRetellController`, `AiCallBook` and `AiCallerRow`. `Handlers\AiCall` now places through the platform's `PlaceAiVoiceCall` (which gained an `$attributes` argument for the caller's own columns and writes `refusal_reason` for its own refusals), keeps the engine's per-node window, per-agency fuse and dial pacing in front of it, and reconciles through the shared `ConversationalCaller::getCall()` + mapper + repository; the host webhook settles the row and `AiCallAftermath` hands any row with an `ae_lead_id` to the new `Services\EngineCallHooks`, which wakes the parked run instead of sending the CRM caller's WhatsApp follow-ups. The host mapper now derives `lead_spoke` from the turns and `outcome` from the analysis (the appointment goal's verdict first) for every call. The AI Calls page reads the one table (`refused=1` is the "phone never rang" filter the AE Dashboard links to); the CRM lead page's AI Caller tab no longer concats a second book.

**`ae_connections` — dropped with the calls.** The per-agency credential table had fourteen read sites and **no write path at all** — no route, no page, no controller ever called `putCredentials()` — so every reader had only ever seen `null`: the engine's own caller always refused with `not_connected`, and the suite's AI-profile pages blanked the platform's Retell config on every request. The engine now dials on the platform's Retell credential (Delivery APIs) like the CRM's own agent; the Google Sheets service account moved to `appointment_engine.sheets.service_account_json` (`AE_GOOGLE_SERVICE_ACCOUNT_JSON`) and `ServiceAccountSheetReader::configured()` is the private-sheet mode's one switch; the Meta lead-form trigger's "needs" is the host Facebook module's connection. Per-agency Retell billing was never a working feature and is not one now — that is stated in `AiProfilesController`.

**Deliberately NOT merged — `ae_leads`.** The audit's finding, kept as the boundary: `leads` is one row per human platform-wide (`leads.user_id`, `user_profiles.phone` and `users.email` are all unique) while the engine dedupes per agency by `(group_id, phone)`; `leads` hard-deletes with a cascade over engagements and bookings while `LeadEraser` hard-deletes engine rows precisely so a person can re-enter a flow; and the `CrmIdentity` / `LeadLinker` trust ladder exists so a sheet-sourced lead with an unverified phone stays *unlinked* rather than force-merged onto someone's account. Eleven engine columns (`source`, `intent`, `budget`, `timeline`, `journey_stage`, …) have no home on `leads`, and `source` is the key into that ladder. The bridge (`ae_leads.lead_id`) stays; every reader that needs the person reads through it. `ae_content_items`, `ae_documents`, `ae_project_closers`, `ae_closer_handoffs`, `ae_sheet_cursors` and the `ae_workflow*` tables are engine-own state with no platform equivalent and stay as they are.

> The handbook's [data-model.md](/docs/modules_handbook/manage/appointment-engine/data-model.md), [bridges.md](/docs/modules_handbook/manage/appointment-engine/bridges.md) and [services.md](/docs/modules_handbook/manage/appointment-engine/services.md) each open with a note pointing here; their section-by-section prose on the merged tables predates this change and reads as history.

---

### 2026-08-29 — day one: a suite, a revert, and ten screens built to admit what they cannot do

**The shell, removed, then restored.** `b8336efc` created the suite: a Hub row, a sidebar of ten `soon` entries, and a console page that "prints the plan and NOTHING else — no counts, no sample rows", gated on **one feature flag AND one permission** read by both nav and router (the first draft nested the routes inside `features.projects_enabled` while the Inertia share exposed only `appointment_engine`, so the entry rendered and every link 404'd). `VIEW` was granted by a **migration as well as the seeder**, "because deploys run `migrate` and never `db:seed`" — `database/migrations/2026_08_29_100001_grant_appointment_engine_permissions.php:9`. `MANAGE` stayed with Super Admin because it "edits what the AI says on a live call".

Hours later `bab82291` **reverted the whole thing** — "the console is a separate product, not a PETA suite". The build spec read as a new surface *on* this codebase; it turned out to be a standalone product that merely *references* how PETA does things. The database was reverted by hand (six role grants, two permission rows, the migration record) because `migrate:rollback` is prohibited in this environment. Then `d2c8c9bf` reverted the revert, and the suite has lived here ever since.

**Ten screens, each shipped with its own honesty rule.** The doctrine that runs through the whole module was set here: *a screen may not imply a capability it does not have.*

| Screen | Commit | The rule it was built around |
|---|---|---|
| Leads | `e3464669` | Reads `ae_leads`, **never** the CRM's `leads` — "the two are sold separately, so a person who exists in both is two rows and they are allowed to disagree". Pipeline chips aggregate over the whole book so counts don't shrink when filtered; chips ordered by funnel, never by count. |
| AI calling | `36a761ce` | Connect rate **excludes refused calls** from the denominator (quiet hours, budget fuse, DNC are *our* decisions); with nothing dialled the rate is `null`, not 0%. Cost is shown as **two providers**, named, because the agent and the minutes are billed separately and the carrier rounds up. Analysis is withheld from the payload entirely unless the customer actually spoke. |
| WhatsApp review | `a97b7d60` | Supervision, not an inbox. `author` has **four** states — the fourth, UNKNOWN, exists because "some outbound rows genuinely cannot carry a sender, and filing those as `human` is how thousands of automated sends come to look like a person typed them". No composer at all (absent, not disabled) because outbound wasn't connected yet. |
| Campaigns | `9188d0ca` | Pause ships **visibly disabled with its reason**; every zero-denominator rate renders an em dash, not "RM0.00 per lead"; cost per lead computed from distinct aggregates because a naive roll-up had already doubled lead counts on a live page once; Meta's campaign id stays a **string** (PHP coerces numeric-string keys back to int). |
| Coverage | `717bf235` | The no-leakage proof. No baseline anywhere ("we saved you forty leads" needs a counterfactual nobody has). Median printed beside mean. Three metrics the spec asked for were **named as not shipped, with reasons**, "because a proof screen that silently omits what it cannot support looks identical to one that never had it". |
| Agent allocation + `ae_appointments` | `6b4eac37` | Manual assignment only; the rules editor was refused because the columns it would filter on were never populated. `ae_appointments` carries `source` and `ae_call_id` — without them "the AI replaced the appointment setter" is a correlation. `outcome` NULL means *not recorded*, never a no-show. |
| Automation canvas + Content | `b5932cb4` | The canvas ships **without** the node editor: "a drawer that saved into nothing is the exact failure the spec names". The diagram is labelled INTENDED in a banner *above* it, with `isRunning` a real prop so the label comes off by flipping one value. |
| Upload & integrate | `ad830a9e` | "storage works, reading does not" — a file lands UPLOADED and stays there; `detected_type` is null until something classifies it, and renders "not read yet". A failed store is recorded FAILED, not skipped. The upload loop stays **outside** a transaction on purpose (object storage is a network call). |
| Dashboard + Settings | `0541fbdc` | The commission hero was deliberately **not built** — "a headline nobody can trace makes every other number on the page harder to believe". Stage counts derived from what happened, not a status column. Attention rows with count zero are dropped. |
| Lead detail | `21fd1be6` | Fixed a page `LeadsController::show()` was already rendering that did not exist — "caught by diffing every `Inertia::render` target against the files on disk rather than by anyone clicking it". |

**Closing the spec gaps** (`fb2ce878`) added per-**agency** encrypted connections ("a shared key means one customer's calls billed to another's account"), the commission projection built only from a figure the agency types, first-match-wins routing rules ("scoring would route better on paper and be impossible to explain to the agent whose leads stopped arriving"), and queued document classification with an overridable type proposal.

**Then the four commits that made it real.**

- `7ca15cff` — **connections are verified by using them.** Saving runs a real read-only provider call before anything says Connected. Four states: not connected / incomplete (naming the missing fields) / not working (with the provider's own reason) / connected. `verified_at` is only ever set by a check that **passed**, so it answers "when did we last prove this works", never "when did we last try".
- `b00946f3` — **WhatsApp sends and receives.** Meta signs with the *app* secret (ours) while the receiving number is the customer's, so `phone_number_id` inside the payload decides whose lead it is; a payload naming an unknown number is dropped, never guessed at. Idempotent by construction on the provider's own ids; receipts only move forward (Meta's aren't ordered).
- `1b2b7625` — **a real workflow builder**: many workflows per agency, fifteen node types in **one registry** read by the palette, settings panel, validation, canvas styling and the runner. A half-built graph is the normal state — the gate is on **publish**, via `Workflow::problems()`. Four graph rules enforced, including refusing an edge that closes a cycle. Agent allocation stopped being a page and became a *node*.
- `52efd03e` — **typed step settings.** "Duplicate handling: banana" had saved as happily as "30 days". Settings became a typed schema per step (`select` / `number` with bounds and unit / `time` / `toggle` / `content` reference / `locked`), with validation built **from** the schema. Two things stay LOCKED with the reason on screen: the AI discloses itself (provider ToS + consumer law), and it hangs up on voicemail.

`1a5faae7` made the canvas editable and n8n-shaped.

---

### 2026-08-30 — from a drawing to a machine

**`41ae236c` — the runner. "THE PIECE EVERYTHING ELSE WAS WAITING ON."** A published workflow now walks a lead through it:

- `StepResult` — a step says exactly one of: next (which branch), wait until, park on a person, end, fail.
- `WorkflowRunner` — moves the run, logs every transition, enforces a step budget per run *and* per job, hands a still-moving run to a fresh job rather than holding a worker.
- One handler per node type; the registry has a test that fails if catalogue and registry disagree.
- `AdvanceWorkflowRun` is **overlap-locked per run** — "two workers advancing the same lead at once would execute a step twice, and a step can be a phone call".
- `ae:run-workflows` every minute (`app/Console/Kernel.php:209`). Timers are **also** delayed jobs, 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".
- Triggers are a **sweep** over rows the host pipelines write, from a cursor starting at "now" — never a hook into the host's 2,400-line inbound job, "which this product must not have to change".
- The AI call is real, over Retell on the agency's own credentials, with every **refusal** written as a row so "the AI never called me" can always be answered.

**WhatsApp and Zoom become the host modules, not copies.** `bc7e216b` took "mimic what we have, don't introduce something new" literally: WhatsApp → `/manage/messages?suite=appointment`, Zoom → `/manage/zoom/recordings?suite=appointment`. A duplicate "would be a fork that drifts from the day it is made". The AE-side Zoom controller and its per-agency Zoom connection were retired. `113de965` then rebuilt every WhatsApp-sending step the way the host sends — pick the number, then an **approved template** with `{{1}}…{{n}}` filled from the lead, or typed text with the 24-hour window stated on it — and retired the AE-side WhatsApp provider, webhook and thread pages outright. Completeness became semantic rather than a flat required flag.

**Nav and structure.** `fc461f81` rewrote the sidebar as the sales journey (1 · Lead generation, 2 · Appointment, 3 · Closing), made the palette stage-then-kind, made palette dragging actually work, and shipped three starter templates — the 23-step *Full journey*, *Meta lead form → AI call*, *Closing — after the viewing*. Template config **merges over** catalogue defaults so a field added later reaches templates built before it existed. `e0393d5c` folded Connections / Team / Billing into one pinned **Setting** entry (CLAUDE.md §15) and added the Showroom section — "the only place that measures whether any of it was worth anything".

**Ways into the book.** `1f3f25de` added manual add and CSV/Excel import, both ending on "which live workflow takes it, or none" — asked every time, "because 'none' is a real answer". A number already in the book is the same person; imports never start someone down the same path twice.

**Knowledge.** `c68d44a9` made items live/offline/deletable (the call step reads only ACTIVE items and nothing could make one active). `81f8da29` filed knowledge by **project and category** — "one bucket for everything means the AI answers an Armani Hallson question with another project's price list" — and, running the upload end to end, found that `ClassifyDocument` and `ProcessShowroomVisit` each declared a private `fail()` colliding with the public `fail()` on their `AiJob` parent, a fatal on class load. Robustness followed: `d94849b1` (a failed store is a failed upload, not a document with path `0`), `7e4add3e` (a stray non-UTF-8 byte no longer empties a whole document), `83496885`, `8ecc1fe6` (100 MB).

**AI Profiles, ported not copied.** `bec58963` first linked the host page under the suite token; `4e880253` reversed that — "the rule for WhatsApp and Zoom was applied to something that is TENANT DATA. An agency's prompt, voice and knowledge base are theirs." The CRM's 1,247-line controller gained three seams (`profiles()`, `page()`, `newProfileAttributes()`) whose defaults leave its behaviour untouched, and the suite's controller is a subclass overriding exactly those three. A middleware swaps `config('services.retell.*')` for the agency's own verified credentials, so a sync provisions on **their** account. `72e576cc` put the entry back as a button on the AI calls page.

**`5df337ce` ran the whole 23-step journey through the real controller and the real runner** and found three defects: the publish check demanded a WhatsApp template on "Assign an agent" (keyed on a `mode` field that step also has — the same defect recurred at save time and was fixed in `25366356`); "Appointment set" was an END node so the run never reached the closing stage; and a typed pre-call message to a manually added lead correctly failed for want of a 24-hour window.

**`e6e3f6cf` — the CTWA decision flow.** Five call outcomes (`booked` · `objection` · `no answer` · `gave up` · `invalid number`), because "routing an invalid number and a hot lead into the same WhatsApp fallback wastes the sequence". Loop A is a **real, bounded loop**: the cycle rule admits exactly one shape — an edge back into an AI-call step whose attempts cap ends it. The trial found run context being merged with PHP's `array +`, which keeps the *existing* key: the attempt counter froze at its first value, the cap never engaged (four calls against a cap of three) and "gave up" was unreachable.

`d9a293a8` let Connections **adopt** credentials the account already holds (the CRM's Retell keys, the connected Meta ad accounts) rather than retyping them; tokens never reach the page. Canvas UX ran through `2c551e00`, `78ce79ca`, `d1147576`, `1d1ee697`, `77399f81`, `90655a79`, `474874ad`, plus `288bb815` — the builder page was importing ~1,500 lucide icons, 793 KB for one page.

---

### 2026-09-02 → 09-03 — a third trigger, and the hub inverts

`9185df4a` added the **Google Sheet trigger** — "the odd one out: nothing tells us a row was added", so each trigger node is polled on its own `poll_minutes` clock against a cursor row (`ae_sheet_cursors`). Two readers behind one `SheetReader` interface (shared-link and service-account), and read errors written to the cursor **in the words of the person who pasted the URL**, because "a sheet that silently stops importing must not look identical to a sheet with no new rows". `4f74dc7e` fixed `ProcessShowroomVisit` fataling on load — `AiJob` declares `public $timeout = 300` untyped, so a subclass cannot re-declare it `public int`.

`7d176fff` — **"an agency holds this as 'my projects', so Projects is the hub."** The console had been laid out as the *machine* sees the work. Five entries now: Dashboard · Projects · Leads · Conversations · Appointments. AI Agent left the sidebar entirely (its paths light Projects). Showroom became **Appointments**, "since bookings can be zoom or showroom now, the room is a badge on the row, not the section's name". Every moved GET kept a redirect; **POST/XHR endpoints did not move**, because the Vue partials post by literal URL and a POST cannot be redirected losslessly. Behind it, the WhatsApp AI brain reached the same funnel as the caller: `[[BOOKED: YYYY-MM-DD HH:MM | zoom]]` → `BookingTokenParser` → an appointment. `ProjectFunnel` became one implementation of leads / spoke / booked / attended read by both hub and dashboard, "so the two screens cannot disagree".

---

### 2026-09-06 — one person, one book

`a00eac6e` stopped the engine keeping parallel truths. Leads bridge to the CRM person (`ae_leads.lead_id` via `LeadLinker`, with a per-source phone-trust ladder; an answered call upgrades trust, pinned to the dialled number). Bookings write **the** `appointments` table — eight nullable provenance columns, channel/outcome dialect mapped once in `Support\Booking` — and `src/AppointmentEngine/Appointment.php` was deleted. The AE lead page began rendering the CRM's own Show component at the AE URL. Also: the Retell webhook bridge settles `ae_calls` the moment the event lands (the 15-minute reconcile stays as the net), plus timezone and signed-diff duration bugs in `RetellCallMapper`.

---

### 2026-09-07 — the reform (44 commits in one day)

**`613c4099` — the drag canvas is gone.** `ae_workflows.plan` (json) became the editable truth and `Services\PlanCompiler` rebuilds the runner graph from it, with **fixed** branching rules. Old Builder URLs 302 to the project tab. The same commit landed the **engagement bridge** (bridged AE leads auto-open a CRM engagement; the AE agent and the closer role sync both ways; status advances monotonically), the first **chat brain**, and the shared `Src\Common\Webhooks` forwarding service.

**Chat takeover became real flow runs.** `2dde8fb7` replaced the brain's stateless resolve hook with the host's own flow machinery: every chat-enabled workflow owns a hidden **shadow flow**, and the first WhatsApp send opens a real `WhatsappFlowRun` per conversation straight in AI phase. A captured booking ends the takeover `ENDED_OBJECTIVE` — "no more selling after booked". `ChatBrain` was deleted. `5282cb81` then bounded the takeover to **the workflow's own channels**, because the guard had required only that the lead hold a run, so the same lead writing to the support line would have been taken over; "a plan with no channel picked yet restricts to nothing rather than everything". `0b371ade` gave the inbox three stop buttons: Stop follow-ups (messages *and* calls — they are one sequence), Stop AI replies (which **stays** ended so later sequence sends don't quietly re-arm it), Stop everything.

**The flow editor, rebuilt eleven times in one day.** `21bfc651` (two columns: the flow in time on the left, always-on rules pinned right; the typed-vs-template choice removed because "the mode is the channel's rule") → `9c5d7fb8` (one colour per block kind; waits moved onto the *thread between* steps) → `cffaf6f2` (retries read from 0; real header-image previews from our own media store, "Meta's CDN handles expire") → `9b74c75f` → `95b6a28a` → `9bf280e3` → `c3928d83`. Then the flowchart arrived: `09f22d32` shipped it strictly read-only, "the boss's picture" — "the diamonds are the compiler's fixed branching drawn honestly, not choices". `5ac4421a` deleted the step-card list and made **the flowchart the editor**. `23710fc0` made it a Canva-style canvas (drag to pan, ctrl+scroll to zoom); `009ceafa` made every group free-form draggable with orthogonal connectors following live — "owner's pick: re-ARRANGE, never re-WIRE" — positions persisted in the plan as presentation only, whitelisted in the normalizer, **never read by the compiler**; "Execution order stays the numbered step order whatever the layout — the arrows always tell the truth." `dbb6e141` added auto-tidy on reorder and the chat brain's two dashed two-way ties (to Start, and to the Booked chain) with an AI-replies switch on the line; `719434bd` moved the always-on pair beside Start; `cbc109c8` previews the real template bubble on hover; `99d65603` extracted `BookedChainCard.vue` so the Yes-branch twin and the standalone block "can never drift", and added drag-to-insert; `b0b3f8b5` corrected a misread rule — it is **never two AI callers in a row**, not one per flow — and added a landing-spot preview.

**The booked chain.** `32bfe066` added `{date}` / `{time}` / `{venue}` / `{venue_link}` tokens and Add-appointment on the AE calendar day drawer (which had mounted the shared `DayDrawer` with `can-create` hardwired false). `7dbec60b` put the two approved UTILITY templates live and extracted Zoom meeting creation out of `AiCall` into `Services\ZoomMeetings::ensure()` — a chat-captured Zoom booking had been link-less, so the confirmation said "Join link to follow". It also pinned `context.appointment_id` so "a reminder sent hours later must describe the booking it was queued for, not whichever appointment the lead has newest". `1204b20f` split the drawer into ① Confirmation / ② Reminder with a timeline strip. `0246bc36` painted the Once-booked card emerald to match; `456c4a1f` reversed that the same day — "Emerald was already the WhatsApp-template colour, so painting the booked chain emerald made two different things look alike" — settling on **amber** for Once booked, rose for "not set yet", indigo for the reminder.

**The brain, editable where it is used.** `a275c715` (create a brain + upload knowledge in the chat drawer, reusing `AiProfilesController::storeProfile`/`storeDocument` with `wantsJson()` branches so one backend serves both surfaces) → `d996097c` (the goal became a **picked option** — one for now, *Schedule an appointment*, "the only objective tested end to end" — with a dimmed "More goals — Soon" row) → `665765c8` (in-drawer editing plus a per-brain **confidence gate**: the model ends each reply with `[[CONFIDENCE: NN]]`, stripped unconditionally; a below-bar auto reply is HELD as a composer draft, the conversation stamped `ai_handoff_at`, AI-phase runs ended `ENDED_HANDOFF`, and the new `ae.ai_unsure` event alerts admins; **a missing token fails open**) → `4a35625e` (the editor now opens the moment a brain is picked).

**`2a10242e` — closer rotation.** `Services\CloserRotation` became the one place the engine picks a person: **pool** (everyone assignable in the lead's agency / a Team / named people) → **filters** (a Zoom booking only to a closer Zoom knows; nobody with another appointment within an hour; nobody who already passed) → **strategy** (round-robin = whoever was offered least recently, read from the new `ae_closer_handoffs` ledger, so it self-heals as people come and go) → **handshake** (Telegram `ae.lead_assigned` with Accept / Pass links, GET "because tapped from a phone", and N minutes to answer; `ExpireCloserHandoff` passes a silent offer on; a dry pool alerts the team). No Telegram means **assigned directly — never a clock nobody can hear.** `offer()` writes the assignment everywhere it lives at once (`ae_leads`, `appointments`, the engagement's closer role, CRM distribution history), fail-soft. Found on the way: the three `ae.*` notify events had never been backfilled, so every pre-existing Telegram chat was unticked for all of them.

**Sheets and flow plumbing.** `674a1e0c` moved Connect Google Sheet onto the workflow's own sheet entry card ("the sheet is the flow's door, so it is configured where the flow is designed"); `5832d020` fixed the old canvas-era `updateSheet`, which picked "the primary workflow" and hand-built a trigger node into it — wiring the sheet into the **keyword** flow and bypassing the plan, so the next save would silently erase the URL; `c58cc484` dropped the poll floor from 5 minutes to 1 across normalizer, sweep clock, node schema and UI, "the runner ticks every minute, so 1 is the true floor"; `bf4f142e` gave retry gaps a unit (a rung is `{value, unit}`; a bare integer — every plan saved before — still means hours, at the normalizer **and** in the `AiCall` handler).

**`ae4f5cb7` — hidden-number leads.** A WhatsApp contact whose number is hidden behind a username used to be skipped at the trigger gate. `ae_leads.phone` became nullable and `wa_contact_id` the identity anchor; `WhatsappSender` replies into the conversation they started ("its channel wins — a hidden number is reachable nowhere else"); the AI-call step skips itself with a ledgered `no_phone` refusal. Phone-matched surfaces went dual-track.

`645e2cf8` gave both lead tables a **Workflow** column ("Keyword 4/16", "Booked ✓"), moving the run→step mapping into the shared `Support\RunProgress` and paying two batched queries per page, never per row. `ac9c0b47` removed every hardcoded suite token the resolver cannot override.

#### Deliberately removed on 2026-09-07

| Removed | Commit | Why (from the commit body) |
|---|---|---|
| Project page's **Brain**, **WhatsApp Automation** and **Chats** tabs (786 lines) | `741712f6` | "The flow editor made all three redundant: call steps pick their brain per step, the chat section owns the WhatsApp AI, and the Leads tab is the person-level view." Replaced by a per-row WhatsApp button → `GET leads/{id}/chat`. |
| The suite's **Conversations** pages — `/calls`, `/calls/human`, `/calls/settings`, the `/whatsapp` and `/zoom` aliases, the sidebar entry and the calls strip | `0a46bc38` | "The host channel layers entered with `?suite=appointment` are the conversation surfaces now." The daily call budget and blocked-number list keep working from stored data — `AiCall` reads both on every dial — "they just have no admin UI for now". |
| **Coverage** — controller, page, both routes, and the `dashboard` SuiteTabs section | `58226438` | "Ask the analyst what got dropped instead." The section died with it: "a strip with a single tab has nothing to switch between." |
| **Allocation** — `AllocationController` and its page | `1232a13d` | Retired into `?team=incomplete` (`routes/web.php:2090`), backed by `CrmPipeline::scopeIncompleteTeam`. "Staffing happens where it always did — the Team cell's Assign modal." The `RoutingRule` engine the `assign_agent` step reads is untouched, "no editor UI for now". |
| The suite's **Settings trio** — Connections / Team / Billing | `e0efcc2e` | Setting became **seven host pages wearing one strip** (`AeSettingTabs`), entered with the suite token: "ONE page and ONE backend serve both suites; an edit made anywhere is the same edit everywhere." Old links 302 (`routes/web.php:2129`). The Google service-account connection and stored billing fields keep working from their data, with no UI. |
| The dashboard's KPI cards, week-compare, Needs-you, project rows and today list | `625a8172` | Reduced to a single band summed by `Support\FunnelSummary` over every project, with a range picker narrowing by **event** time. "The removed KPI cards … live on inside the chat snapshot." |

The dashboard itself was rebuilt three times that day: `58226438` made it the leader's seat with a grounded **performance analyst** (`POST dashboard/chat`, `routes/web.php:2076`, rebuilding the page's own snapshot per question under `AiRequest::PROMPT_AE_DASHBOARD_CHAT` — "Nothing spends on page load"); `625a8172` cut it to one band; `033e3955` refilled it with the five things a sales leader actually checks; `1232a13d` added the dark hero board with a five-step setup ring and removed the "Want the money figure?" nag row.

---

### 2026-09-08 — the agency becomes the unit of the product

**`b7ba83a5` — group-based.** Projects are shared **downward**: a platform project (`group_id` NULL) is readable by every agency (`sharedProjects` / `applyShared`), while writes keep the strict scope. Each agency brings its own leads, appointments, runs — and its own closers, via the new `ae_project_closers` / `ProjectCloser` board, which is also the rotation's default `project` pool (the lead's agency's list, else the platform's).

**`e08751e7` — agency-first.** "A super admin entering the AI Appointment System chooses the agency first; from then on the whole suite is that agency's, exactly as its own leaders see it." `Support\AeScope` is `GroupScope` plus the session's chosen agency (null = all agencies), and **53 call sites across the eight AE controllers**, `FunnelSummary` and `ProjectFunnel` now go through it — including the `group_id` stamped on what a platform user creates while acting. Middleware `ae.agency` sends platform staff with no choice yet to the chooser. Teams inside the agency arrived with `ae_leads.team_id` and a `?team=` switcher. `9087617a` and `bd22dd71` then fixed its counters twice — the pill was counting *heads*, not this project's leads; and "All teams" was reading `leads.total`, the paginator's total, which `?team=` had already narrowed.

**`5745bc17` — the appointment book grows the lead book's furniture** (§14): search, state, channel, `booked_by`, project, agent, and a date range on `scheduled_at`. Two decisions worth keeping: the **state** dimension is not just the outcome column — "needs-marking-off and upcoming are the same un-recorded outcome told apart by the clock, and cancelled is a STATUS" — and outcome states carry **named** tokens (`state[]=closed`, not `state[]=4`) because "a JavaScript object hoists integer-like keys to the front of its own iteration order". `booked_by=human` means *not the AI*, null source included. One filtered scope feeds table, calendar **and** counters. This commit also repaired a real hazard: `AppointmentQueryRequest` had been type-hinted by `b7ba83a5` but never committed, so "a fresh checkout or the deploy script's `reset --hard` would have fataled this page".

**`58f5a706` — the inbox drawer shows where a lead is in its flow.** `GET leads/{id}/flow` (`routes/web.php:1998`) serves the same flowchart the Workflow tab draws, mounted read-only, with the lead's position worn on the blocks (Done ✓ / "You are here · fires Tue 9 Sep, 3:00 pm" / Upcoming), reusing `planProps` + `RunProgress`. A finished sequence still lists its newest run so a booked lead's flow stays reachable, flagged `is_open false`, with the stop buttons reading a separate `open_runs` count.

**The AI call's give-up path became visible.** `f6816671` drew the "still no answer" loop-back as a dashed rose connector instead of a footnote; `df9735cf` fixed it starting half a note too low (the sub-part was measured from screen rects, so the wrapper's `-translate-y-1/2` wasn't reflected — it now measures from layout offsets, zoom-free by construction); `3ef8f7f7` corrected the label to name **both** outcomes it carries: `no_answer`, `gave_up` **and** `objection` all leave `condition.call_outcome` for the next step; only `booked` and `invalid` do not.

**`1e3d1335` — "Check now"** on the sheet drawer: `POST ai-agent/workflows/{id}/sheet-check` (`routes/web.php:2110`) runs `Triggers::syncNode` with force — "the minute sweep's own unit of work, so a manual check can never behave differently" — on the **saved** link, and answers in words (rows on the sheet, rows looked at, leads enrolled, or the read error). `2d65a60a` reverted a peer's committed `profiles()` change that commit had accidentally swept in.

> **Updated 2026-09-09:** everything described here as in flight has since landed — the Campaigns retirement and the nav/roles follow-ups are commit `141628ae`, and the tree is clean apart from the deliberately untracked `docs/temp/*.pdf` and `public/ae-system-map.html`.

**`39cf549d` — deleting a lead erases everything.** The owner: "when I delete the lead all records go, so when the lead is in the spreadsheet again the flow triggers again." The AE delete had been a soft delete leaving runs, calls, hand-offs, appointments and the chat takeover behind — and **three of those keys kept the person out for good** (the sheet matches by phone, `enrolOnce` keys on lead+workflow, the takeover's stay-stopped guard keys on ended runs). `Services\LeadEraser` now hard-erases all of it (appointments soft-deleted, "the CRM book keeps its audit"; takeover runs ended idle the host's way, then removed), then `forceDelete`s the lead — the CRM person stays. The sheet reader gained `returningRows`: rows *before* the cursor whose phone has no live lead in the agency are imported again and enrolled regardless of the connect-time baseline, "because deleting is the explicit 'start them over'".

---

### State today (2026-09-08)

The suite is **agency-first and project-shaped**. A platform admin picks an agency on entry (`ae.agency` middleware → `AgencyController`); everything after that reads and writes through `Support\AeScope`. The sidebar is four entries — Dashboard · Projects · Leads · Appointments — plus a **Channel** section pointing at the host Messages / Zoom / Phone Call / Showroom F2F layers, a **Team** section pointing at the host Admins page, and a pinned **Setting** entry that is seven host pages wearing `AeSettingTabs`; the suite owns no settings pages of its own (`resources/js/Layouts/ManageLayout.vue:207-249`). `SuiteTabs` is down to a single section, `appointments`, with four dead sections (`dashboard`, `leads`, `settings` and the Conversations strip) documented in its own comments as having died rather than been forgotten (`resources/js/Pages/Manage/AppointmentEngine/Partials/SuiteTabs.vue:16-35`). A project's Show page is three tabs — Leads, Appointments, Workflow (`Projects/Show.vue:71-74`). The automation is one plan document per workflow, edited on a free-form canvas and compiled to the runner graph by `PlanCompiler`; the runner ticks every minute; leads, calls, appointments and engagements are bridged to the CRM's own tables rather than duplicated. Eleven controllers, ~85 backend classes, 34 `ae_*`-related migrations.

What is **in flight and uncommitted** on `dev-wk`: the Marketing/Campaigns removal — `CampaignsController.php`, `Campaigns/Index.vue`, `src/AppointmentEngine/Campaign.php` and `CampaignDay.php` are deleted in the working tree, and `database/migrations/2026_09_07_190000_drop_ae_campaigns_tables.php` is untracked. Its docblock states the reason plainly: the two tables "never had a WRITER at all — no sync job, no webhook, no import ever populated them, so both were empty on every install and the page could only ever draw a blank report." Ad performance belongs to the Operations suite's Traffics page. Alongside it sit uncommitted edits to the AI Profiles pages, `AiCall`, `RetellCallMapper`, `Workflow`, and two untracked support classes (`AiCallBook`, `LeadEngagement`).
