# Leads → READINESS: the verdict layer (2026-09-20)

## What it does

READINESS is **two layers on the same seven areas**, and every screen a person looks at shows the
second:

| Layer | What it is | Where it lives | Who reads it |
|---|---|---|---|
| **Records** | Deterministic rules over what the CRM can show (a form, a booking, minutes watched) — `ReadinessColumns`, unchanged | `lead_readiness.state` / `why`; row key `readiness_records` | the **Status** column (`LeadStages`), the Booking study's weekly snapshot, and the Overall AI reading, which is handed it as `crm_records` |
| **Verdict** | The Overall AI reading's final state + one sentence per area, raised by a record newer than the reading | `lead_readiness.verdict` / `verdict_why` / `verdict_source` / `verdict_at`; row key `readiness` | the list's READINESS band, the tiles on every Insight tab, the `ready` / `readiness_*` filters and sorts, the Status guide's seven dots, and the Pay attention flags `nearly_ready` + `qualified_unworked` |

Founder, 2026-09-20: *"each channel analyze the respective readiness using lead table method, then
overall … use prompt to combine all channels readiness and sum it up and provide final result. then
this result eventually show at lead table. both has to be sync … forget and dun connect anything
from lead table to customer work queue page."* It started as a bug report: a lead read four greens
on the list and "Unknown" on five tiles of its own Overall tab, because the tiles borrowed the
Customer Journey's snapshot (staff-confirmed evidence only). That source is disconnected from every
lead page — see channel-insights → `readiness_states`.

## The three rulings, and where each is enforced

1. **Green needs a record.** `ReadinessVerdict::stamp()` — run on every fresh Overall reading —
   lowers an AI `ready` to `signal` unless the records the AI was SHOWN were `ready` for that area,
   and appends *"said, but no record yet, so not green"*. Enforced in PHP, not trusted to the prompt.
   The AI **may go lower** than a record when a conversation contradicts it; that is kept.
2. **No Overall reading = "Not analysed"** (`ReadinessVerdict::UNREAD`, NULL in the column). Never
   the records' colour. An unread lead matches no readiness filter and sorts last. It draws a DASHED
   tile so it is never mistaken for "read, nothing found" (`none`).
3. **A record newer than the reading raises the verdict at once** (`resolve()`): only a RISE, and
   only against what the reading saw (`records_seen`, stored per area by `stamp()`) — so an AI that
   deliberately went below a green record is not "corrected" back. A raised area reads
   `source: records`; and because `sourceHash()` now includes the records' STATES (never their
   wording — "2d ago" moves daily), the Overall reading shows as **stale** until re-read.

## How it works

- **Prompt** — `resources/prompts/lead_overall_insights.md` → *Decision readiness*. The rules are
  NOT in the prompt: `ReadinessVerdict::promptFacts()` puts `readiness_rules` (the `asks` / `ready`
  / `signal` text of `ReadinessColumns::RULES`, verbatim) and `crm_records` into THREAD FACTS, so
  there is one definition. `MasterInsights::normalize()` carries `state` + `why` across (the shared
  `ChannelInsights::readiness()` drops unknown keys on purpose). `SCHEMA_VERSION` is
  `master-insights-v2`, so readings from before this shipped show as stale and re-run rather than
  being handed back without a state. The ensemble merger is told how to settle two analysts who
  disagree on a state (the rules decide; if both are defensible, the LOWER).
- **Write path** — `ReadinessColumns::persist()` (hourly `leads:refresh-readiness`, and right after
  any reading is saved) computes the records, then `ReadinessVerdict::forPage()`, and writes both
  layers in ONE `LeadReadinessRepository::sync()` upsert. A reading that is deleted writes NULL back.
- **Read path** — `LeadsController@index` sends `readiness` (verdict) and `readiness_records`;
  `ServesChannelInsights::readinessStates()` sends the verdict to all seven Insight controllers;
  `StageGuideController` draws the dots from the verdict while `LeadStages::forRow()` reads
  `readiness_records ?? readiness` — a rung is about contact and learning RECORDS and must not go
  grey with the verdict.
- **Frontend** — `ReadinessCell.vue` (dashed tile, "Not analysed — run Analyse all", and a source
  line: *Overall AI reading · date* or *Raised by a newer record*), `ReadinessGrid.vue`,
  `StageLeads.vue` (a hollow dot). `ReadinessGuide.vue` states the rulings in the reader's words.

## What is not built yet

- **Per-channel states.** Each channel tab's tiles show the FINAL verdict, not that channel's own
  judgement. Giving WhatsApp / Zoom / … their own `state` means eight prompts (WhatsApp's has a DB
  override in `ai_prompts`, so its file default does not apply there).
- **Automatic re-analysis** on new data — designed 2026-09-20 (mark dirty → nightly batch,
  Zoom/phone within the hour, US$5/day · US$120/month cap, ensemble, any lead who writes to us).

## Related files

- [`src/Lead/Support/ReadinessVerdict.php`](/src/Lead/Support/ReadinessVerdict.php) — the rulings
- [`src/Lead/Support/ReadinessColumns.php`](/src/Lead/Support/ReadinessColumns.php) — the records layer + `persist()`
- [`database/migrations/2026_09_20_140000_add_verdict_to_lead_readiness.php`](/database/migrations/2026_09_20_140000_add_verdict_to_lead_readiness.php)
- [`app/Http/Controllers/Manage/Leads/LeadOverallInsightsController.php`](/app/Http/Controllers/Manage/Leads/LeadOverallInsightsController.php) — `records()`, `sourceHash()`, `stamp()`
- Tests: `tests/Unit/Lead/ReadinessVerdictTest.php` (each ruling + its near-miss),
  `tests/Feature/Manage/Leads/LeadReadinessColumnsTest.php`, `ReadinessCell.test.js`
