# BUILD-PROGRESS.md — AI Appointment Engine

Required by build spec §10.5. One row per slice from §9, with the §10.2
Definition-of-Done checklist. **Kept honest**: a slice is `done` only when all
eight boxes genuinely hold, and §10.5 says so explicitly. Nothing here is marked
done to look tidy.

Last updated: 2026-08-29 · branch `dev-wk`

---

## ⚠️ Read this before reading the table

**No slice can currently reach `done`, and the reason is structural rather than
effort.** DoD box 2 is *"Tenant + role correct. Data is tenant-scoped; role
gating enforced server-side."* Measured against the real codebase:

| Fact | Value |
|---|---|
| PETA's tenancy unit | `Src\People\Group` — its own docblock: *"An agency-level group (whitelabel tenancy unit). A group owns its staff, teams, and — via ingestion stamping — its leads, FLG projects, Meta integrations and WhatsApp channels."* |
| How isolation is enforced | `Src\Auth\Support\GroupScope::apply()` — a **static helper each query opts into**, not an Eloquent global scope |
| Files that call it | **14** |
| Manage controllers | **170** |
| Tenancy package in `composer.json` | none |

So PETA has a tenancy *unit* but not structural tenant *isolation*. Spec §4A
requires "every read and write is filtered by the current tenant, server-side"
and names cross-tenant exposure a **release blocker** (acceptance 1a).

That cannot be satisfied by building this console. It needs a backend decision
first — see **Open decision 1** below. Until then every slice below carries box 2
UNCHECKED, deliberately, even where the slice is otherwise complete.

This is not a reason to stop building: the slices below extend surfaces that
already carry PETA's existing `LeadVisibility` / `GroupScope` level, so they
**inherit** today's isolation and add no new exposure. They just cannot be
called tenant-correct in the sense §4A means.

---

## Definition of Done (§10.2) — the eight boxes

1. **Real data** — reads/writes the live backend; no mock rows. A gap shows a real empty/disabled state and is logged in `DATA-MAPPING.md`.
2. **Tenant + role correct** — tenant-scoped, role gating server-side.
3. **States handled** — loading, empty, error, not-connected.
4. **Responsive** — desktop + mobile, incl. master–detail.
5. **Dark mode** — correct in both themes.
6. **No console errors; no dead buttons** — every control works or is visibly disabled with a reason.
7. **Honesty rules** (§1/§6) — nothing fabricated, no baseline/counterfactual, AI-authored content marked.
8. **Matches its §5 spec.**

---

## Slices

Legend: `✅` holds · `⬜` does not hold yet · `n/a` does not apply to this slice.

### Slice 0 — Suite foundation, nav, permissions
**Status: substantially done (box 2 blocked)**

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| ✅ | ⬜ | ✅ | ✅ | ⬜ | ✅ | ✅ | ✅ |

- Permissions `view-appointment-engine` / `manage-appointment-engine`, granted to
  `sales-leader` + `group-super-admin` by migration **and** seeder (deploys run
  `migrate`, never `db:seed`).
- Route `/manage/appointment-engine`, top-level — **not** nested inside
  `features.projects_enabled`, which was a real bug: nav rendered while every
  link 404'd on any install shipping the `.env.example` default.
- Full 13-entry sidebar per spec §3, plus Console. Settings group gated
  `superAdmin: true` (a third gate added to `ManageLayout`'s `allowed()`).
- Hub row on `/manage/dashboard`.
- **Box 5 unchecked:** the Manage portal has no dark mode at all. Spec
  acceptance 5 requires it across every screen — that is a portal-wide piece of
  work, not a per-slice one. See **Open decision 3**.

### Slice 1 — Tenancy + auth foundation (§9.2, §4A)
**Status: BLOCKED — needs a human decision, see Open decision 1**

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| ⬜ | ⬜ | ⬜ | n/a | n/a | ⬜ | n/a | ⬜ |

The spec's §9 puts this first and everything else on top of it. It is the one
slice I will not start unattended: choosing between "accept PETA's current
group-level scoping", "scope only this console", and "make PETA structurally
multi-tenant" changes the shape of every other slice, and the third option
touches ~156 controllers of live software.

### Slice 2 — Settings → Connections (§5.12)
**Status: not started — depends on Slice 1**

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ |

A Connections screen claims per-tenant credential separation. PETA stores
WhatsApp channels and Meta accounts with a `group_id`, but nothing structurally
prevents a query from reading another group's. Shipping the screen before the
enforcement exists would be the one thing §10.4 forbids: a UI asserting
something the backend does not do.

### Slice 3 — Dashboard (§5.1)
**Status: not started (planned)**

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ |

Constraint carried in from the mapping: the **commission-saved hero cannot ship
as specified**. The appointment cut is not 20% — it is 15% (Non-Webinar), 10%
(Video Sales) and **zero for a webinar close, which has no appointment role at
all**. And a human still holds that role on 80 live engagement assignments, so
any such figure is forward-looking, not historical.

### Slice 4 — Coverage (§5.2)
**Status: not started (planned)**

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ |

Four of its seven metrics may ship; three may not, and that is a finding, not a
scoping choice — see `DATA-MAPPING.md` §7. "Follow-ups sent on schedule (%)" has
no due-time column anywhere to divide by, and an AI call *deliberately defers*
out of quiet hours, which would score as late. "Retries honoured" has no retry
ladder. "Promised callbacks honoured" has nothing recording a promise.

### Slice 5 — Leads list → Lead detail (§5.3)
**Status: list done (box 2 blocked); detail tab not started**

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| ✅ | ⬜ | ✅ | ✅ | ⬜ | ✅ | ✅ | ◐ |

- Extends the EXISTING leads list rather than adding a second one — the mapping
  ruled a parallel list a fork, and two lists disagree about one lead.
- `ai_call` column: one query per page, real columns only, and `is_refusal`
  rendered as "Not called" so our own refusal never reads as a missed call.
- `properties_max` filter reaches the **8,881** leads who own nothing, which
  `properties_min` structurally could not (0 is its "off" value). NULL stays out:
  2,544 leads are NULL because nobody asked, which is a different fact.
- **Box 8 partial:** the spec's pipeline status bar (acceptance 7a) is not built.
  It needs `Engagement::STATUSES`, and live there are 513 engagements with **0 at
  Appointment Set** — the bar must not read as broken when a stage is honestly
  empty. Lead detail tab also outstanding.

### Slice 6 — AI calling (§5.5) · Slice 7 — WhatsApp review (§5.4) · Slice 8 — Campaigns (§5.6)
**Status: planned, not started**

All three are "buildable now by reuse" per the mapping. Notes carried forward:
AI calling is partly shipped already (decide re-host vs `?suite=` link);
WhatsApp review must extend `MessagePresenter`, which exposes 2 of at least 5
provenance markers so ~3,900 automated sends currently look human — acceptance 7
depends on fixing that; Campaigns renders the pause control **disabled**, because
PETA is read-only against Meta's ad objects.

### Slices 9–14 — Allocation · Automation · Content library · Ingestion
**Status: blocked on named gaps**

| Slice | Blocked by |
|---|---|
| Agent allocation (§5.10) | Gap #1 — nothing writes an appointment from a call. Also 0 users hold `sales-agent` and there are 0 `salesperson_tier_weeks` rows |
| Automation (§5.7) | **PARTLY buildable — corrected 2026-08-29.** A read-only canvas of the WhatsApp flow ships today: `WhatsappFlow` has ordered steps, reply-branching and content-hash versioning, and a renderer already exists (`FlowPresenter::diagram()` at `src/Whatsapp/Support/FlowPresenter.php:229` + `resources/js/Components/Whatsapp/FlowGraph.vue`). So does the node panel's Script tab for those steps, and version history. What is blocked is the spec's **unified** canvas: it must carry a purple "Action — AI call (Retell)" node, but `WhatsappFlowStep::TYPES` is TEXT/MEDIA/TEMPLATE with no call type, while the engine that does place the call (`FunnelAutomationMessage::MEDIUM_AI_CALL`) has **no ordering column and no branching**. The spec's flow spans two engines that do not know about each other; drawing them as one graph is inventing a flow, which §5.7 forbids in as many words |
| Content library (§5.8) | Gap #16 — no shared cross-channel knowledge store, no embeddings |
| Upload & integrate (§5.11) | Partly buildable: classify → extract → improve → **preview** can ship; final activation into the live Retell prompt / WhatsApp automation has no write path, and §5.11 forbids faking it |

---

## Open decisions waiting on a human

1. **Does PETA become genuinely multi-tenant?** Three options with very
   different costs: (a) accept today's group-level scoping and drop acceptance
   1a; (b) scope only this console's queries — resolves nothing, the other 156
   controllers stay open; (c) structural isolation via global scopes plus a
   `group_id` audit per table — a programme of its own, touching live software.
   **Everything in §4A and §5.12 waits on this.**
2. **Is the console's Leads screen the existing list under `?suite=appointment`,
   or its own?** Currently the former, per the mapping. Confirm.
3. **Dark mode (acceptance 5).** The Manage portal has none. Adding it is a
   portal-wide change affecting every existing admin screen, not a console
   feature. Confirm scope before it is attempted.
4. **The 20% commission figure.** The spec's core sales claim uses a number PETA
   contradicts. Someone who knows the business must rule on what the Dashboard
   hero is allowed to say.

---

## Pre-existing test failures (not caused by this work)

Verified by running the same filters against a pristine `git worktree` at the
commit before each change:

| Test | Symptom | Cause |
|---|---|---|
| `IdentitySchemaCoverageTest` | 14 columns missing from `IdentityChildMap` | master merges |
| `FunnelAiCallAutomationTest` (quiet-hours deferral) | expects QUEUED, gets SENT | `.env` has `RETELL_QUIET_HOURS=false`; `phpunit.xml` does not override it |
| `AiLearningAccessTest:327` | expects 403, gets 404 | pre-existing |
| `RecordingsProcessBatchTest:558` | expects 403, gets 302 | pre-existing |
| `FlgCanonicalFactsTest` | sort assertion | pre-existing |
| `AdminLeadFacetTest:177` | phone-merge assertion | pre-existing |
| `PurgeAllLeadsTest:270` | `contact_key_histories` not empty | **order-dependent** — passes in a small filter, fails inside the 777-test `Lead` run, on pristine HEAD too |
| 3 vitest failures (AmenitiesPanel, PropertyMatch admin) | — | other people's commits |
