# CEO Dashboard (Manage)

**Portal:** Manage · **Suite:** `ceo` (its own sidebar) · **Routes:** `manage.ceo.*` · **Nav:** Action Items (tabs: Action Items · WhatsApp Groups · Inbox) · Email · Connection · **Permission:** `view-ceo-dashboard`

> **Reached by URL only — `/manage/ceo`.** There is deliberately no tile on the Hub chooser and no
> entry in the Hub's menu. That page asks "which part of the business are you working on?", and an
> answer that is somebody's own phone does not belong among the suites. Once you are on the path,
> `useSuite`'s `OWNED_PATHS` resolves the `ceo` suite and its own sidebar takes over, so the section
> behaves like any other from that point on. (Same choice as the
> [Operations dashboard](/docs/modules_handbook/manage/operations/readMe.md), for the same reason.)

## What it does
An admin links **their own WhatsApp number** by scanning a QR code, and then reads their chats here — every one-to-one and every group — on a page only they can open. Once a day (and on demand) an AI reads what arrived and produces two things: **what happened**, chat by chat, and **what they now have to do**, as a list they can work.

It exists because the person running the business has the most important conversations on a phone nobody else can see, and no amount of CRM discipline reaches them. This does not try to move those conversations into the CRM. It reads them where they are.

> **This is not the shared inbox, and it is not a second one.** A number linked here is `purpose = PURPOSE_CEO`: invisible to the Messages inbox, to the Channels page, to broadcasts, funnels, payment automations and every report — and **automated by nothing**. The AI reply engine, the flow engine, the STOP/opt-out handler and the lead linker all stand down. Nothing is ever sent from the number unless its owner types it.

> **Unlinking deletes the chats.** `disconnect` hard-deletes the channel and everything captured through it (conversations, messages, attachments, stored media). The dashboard reads somebody's private WhatsApp, so "stop" has to mean the data is gone rather than hidden. The phone itself is untouched.

## How it works

### Email workspace

**Nav:** Email → Inbox · Provider setup. Entry: `/manage/ceo/email?suite=ceo`.

Gmail and Outlook connect through read-only OAuth (state, PKCE, ten-minute,
single-use session). The existing callbacks remain
`/manage/email/connect/{gmail|outlook}/callback`; the initiating session carries
the owner, purpose and suite. The callback rechecks the workspace permission
and returns CEO connections to `manage.ceo.email.index` with `suite=ceo`.
Callback query parameters cannot change mailbox ownership or workspace.

`EmailMailbox.purpose` is `ceo` for these connections and `operations` for all
existing mailboxes. Every inbox, message, refresh and disconnect query scopes
both owner and purpose. Operations campaign mailbox selection excludes CEO
mailboxes, including for the owner and super admins. The same physical account
can be independently authorized in both workspaces without reclassifying history.

The page reuses `Manage/Email/InboxContent.vue` with CEO endpoints. Bodies render
as escaped text. Existing bounded imports read the first 30 days and then use
a five-minute overlap. The scheduler is unchanged; sync jobs check the owner's
current CEO permission, active status and Manage role before contacting providers.
Connect and refresh queue only reads. CEO Email has no send or campaign action.
Disconnect clears tokens and retains already imported messages privately, as
stated in the confirmation.

Provider setup at `/manage/ceo/email/settings` also requires
`manage-email-settings`. It includes Google Cloud/Entra registration steps,
canonical callback URLs and encrypted client credential fields. App credentials
are shared with Operations; mailbox content is separate. Secrets are excluded
from page props, validation flash data and request logs. Blank secret fields
preserve saved values. The user must create the provider registrations and
approve each account; mocked tests do not establish a live mailbox grant.

Related backend: `Manage/Email/EmailController`, `Manage/Ceo/CeoEmailController`,
`CeoEmailSettingsController`, `Email/InboxQueryRequest`, `ProviderSettingsRequest`,
`EmailMailboxRepository`, `EmailProviderSettingsRepository`, `EmailMailbox`,
`SyncMailbox`, `MailboxProvider`, and `routes/{web,email}.php`.
Related frontend: `Manage/Ceo/{Email,EmailSettings,EmailShell}.vue`,
`Manage/Email/{Inbox,InboxContent,EmailPagination}.vue`, `SectionTabs`,
`ManageLayout` and `useSuite`.
Migration: `2026_09_16_190000_add_purpose_to_email_mailboxes.php`.
Tests: `tests/Feature/Email/EmailChannelTest.php`, with isolated SQLite fixtures;
MySQL migration and browser checks are recorded in the deployment report.

### The number
There is no new "assistant" table: a CEO Dashboard number **is** a `whatsapp_channels` row, Bridge-provided like any QR channel, marked `purpose = WhatsappChannel::PURPOSE_CEO` and owned by the admin who scanned it (`user_id`). [`CeoChannelResolver`](/src/Ceo/Services/CeoChannelResolver.php) resolves "my number" from the signed-in user — ownership is part of the lookup, never a check bolted on after it, so no endpoint here takes a channel id at all.

Everything else about it is the ordinary Bridge machinery documented in the [WhatsApp handbook](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) and [wa-bridge/README.md](/wa-bridge/README.md): the same `wa-bridge` Node sidecar, the same QR → `history.set` full backfill on a fresh link, the same minute-by-minute heartbeat (`whatsapp:ping-bridge`), the same webhook ingest. The **Scan-QR modal is literally the same Vue component** as the Channels page's — it grew `qrUrl` / `stateUrl` props so it can be pointed at endpoints that carry no id.

### The isolation — six places, one flag
`purpose` is checked wherever a channel could otherwise leak into shared surface. Each of these was a real hole, not a hypothetical:

1. **[ProcessInboundWhatsAppWebhook](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php)** — a CEO channel's inbound message is stored (with media), then `continue`s **before** the `NewWhatsAppMessage` broadcast and before `considerOptOut` / `considerLinkContact` / `considerNotify` / `considerCtaCapture` / `considerFlow` / `considerAiReply`. The broadcast matters as much as the engines: `whatsapp.inbox` is authorised for every inbox admin, so pushing a private message onto it would show a colleague the owner's chats. Group messages take the same branch inside `recordGroupMessage`.
2. **Defence in depth** — `considerAiReply`, `considerLinkContact`, `considerOptOut` and [GenerateAiReply](/app/Jobs/Whatsapp/GenerateAiReply.php) each early-return on a CEO channel independently, the same shape the group capture-only rule uses.
3. **[LeadVisibility::allowsConversation](/src/Auth/Support/LeadVisibility.php)** — returns **false** for a CEO thread for *everyone*, including a full-access admin and including the owner (who reads it on their own dashboard instead). This is the single chokepoint behind every shared-inbox action — open, mark read, reply, react, edit, delete — so a bookmarked or guessed conversation uuid can never reach one.
4. **[AccountVisibility::allowsWhatsappChannel](/src/Auth/Support/AccountVisibility.php)** — returns **false** for a CEO channel, which is the single check behind the shared Channels pages (show / rename / delete / QR / state / history-sync).
5. **Every channel list and picker** excludes `PURPOSE_CEO` explicitly: the Messages inbox (list, deep link, channel prop), the Channels index + its counts, the Messages dashboard, Operations, the Appointment Engine's host options, Funnels ×3, Broadcasts, Facebook projects, the AI Agent page's health counts, the unassigned-AI-profile nag, and `ChaseFollowUps` (whose bare `first()` would otherwise have chased a customer from the owner's personal number).
6. **The lead linkers** — [LinkChannelContacts](/app/Jobs/Whatsapp/LinkChannelContacts.php) still does its history-sync settle bookkeeping for a CEO channel (so the page's "copying your past chats" state stays honest) but links **no** contacts and writes **no** sync report; the hourly `whatsapp:link-contacts --existing-only` sweep skips contacts known only from a CEO number; and [LeadConversationPresenter](/src/Whatsapp/Support/LeadConversationPresenter.php) excludes them so a contact deduped by phone across both numbers cannot hang the owner's private thread off a lead's page.

Locked by [CeoDashboardTest](/tests/Feature/Ceo/CeoDashboardTest.php), which proves the OUTCOME (nothing queued, no lead, no consent row, refused to a super admin, absent from the shared inbox) rather than any one guard.

### Two things that make an inbox silently empty
Both were hit on the first real link, and both look identical from the page: a healthy, connected
number with no chats.

- **The webhook must point at THIS app.** `CeoController::webhookUrl()` registers the instance
  against `APP_URL`, honouring `BRIDGE_WEBHOOK_URL` **only when it names the same host**. That
  override exists to reach the same Laravel at a different address (local dev, where `APP_URL` is an
  https `.test` host Node's fetch rejects); on this box it had been pointed at a different
  production SITE, so the first number linked here delivered its whole history sync there. The
  receiving app looks the channel up by `provider_ref`, finds nothing and drops every message —
  nothing errors, nothing retries, the bridge's DLQ stays empty. A diverging host is now ignored
  with a `warning` log rather than silently obeyed.
- **History only arrives on a FRESH link, so "Re-scan" alone cannot fetch it.** WhatsApp pushes a
  chat's past exactly once, at link time, and a still-valid session resumes instead of re-pairing —
  so showing a QR to a connected number changes nothing. `POST ceo/resync` logs the phone out first
  (`BridgeGateway::logout`, which until then had no caller anywhere in the app) and then hands back
  a QR; the button on the Connection page says **Re-sync my chats** for a connected number and
  **Scan QR** for a disconnected one. Captured messages are kept — ingest de-duplicates on
  `provider_message_id`, so a second backfill fills gaps and repeats nothing. The page also says
  this in words wherever the chat count is zero, so a row of zeros reads as "re-sync" rather than
  "broken".

### The inbox
[CeoInboxController](/app/Http/Controllers/Manage/Ceo/CeoInboxController.php) + [Inbox.vue](/resources/js/Pages/Manage/Ceo/Inbox.vue).

**The thread is built to look like WhatsApp Web; the chat list is built to look like the rest of Manage.** That split is deliberate and was the owner's call: a conversation is a conversation, so the thread follows the app people already know, but the left column is a *navigation list*, and at WhatsApp's 16px/14px scale it shouted next to every other list in the portal. It now uses the shared inbox's own sizing — 44px avatars, 14px names, 12px previews, the same search field, select and chips.

**The thread, on purpose:** The person reading this is reading their OWN chats — they already know where everything is on the real thing, and every difference is something they have to relearn to do what they do all day. So: the two-tone chrome (`#f0f2f5` bars, white panes) flush inside one bordered frame, the 49px avatars with the divider starting after them, the pill filters, the wallpapered thread, green `#d9fdd3` outgoing bubbles against white incoming ones at 7.5px radius, the tail drawn only on the FIRST bubble of a run from one speaker, the time and ticks tucked INSIDE the bubble (with the last line of text reserving room for them), blue `#53bdeb` read ticks, Today/Yesterday date pills, and the green send button.

Two deliberate departures: a **third column** for the AI reading — the reason this exists rather than a browser tab on web.whatsapp.com — and **no CRM furniture**. Below `xl` the panel has nowhere to sit, so the thread header carries a toggle that opens it underneath; below `md` the list and thread take turns, as they do on a phone.

The wallpaper is **our own drawing** ([public/main/images/chat-pattern.svg](/public/main/images/chat-pattern.svg)), not WhatsApp's tile: the look is theirs to inspire, the artwork is not ours to copy.

It is scoped by **channel**, not by lead visibility — every thread belongs to one person, so `channel_id = my channel` is the only scope there is. Groups are listed exactly like people: on a personal phone the groups are usually where the work is.

What is deliberately absent: tags, assignment, AI drafts, flow controls, templates, "convert to lead". Those work a *customer* conversation. Replying is one plain text message at a time through the shared `SendWhatsAppMessage` job (which resolves a group JID for a group thread) — there is no bulk path, because anything that made it easy to send many messages from an unofficial connection would be a ban risk with no upside.

Messages render through the shared [`MessagePresenter::inbox`](/src/Whatsapp/Support/MessagePresenter.php), so bubbles, statuses, attachments, group sender labels and revoked/edited markers behave exactly as they do in the team inbox.

**Group @mentions read as names, not as digits.** A mention inside a WhatsApp group arrives as a raw LID — `@49263392329973` — because WhatsApp's privacy identity no longer puts a phone number in the message. On a personal phone whose most valuable chats are groups, that is most of what the owner reads: 704 of this channel's group messages carry one, naming 22 distinct people. The shared [`MentionResolver`](/src/Whatsapp/Support/MentionResolver.php) decodes them (contacts → group members → the number), the bubble paints the name over the token via `MessageText.vue`, and the chat-list preview is decoded server-side. **The AI reads the decoded text too** — `GenerateCeoDigest`, `GenerateCeoGroupBrief` and `GenerateCeoChatInsight` all build their transcript lines through `MentionResolver::render()`, because a model cannot look a LID up and a brief written over undecoded text raises follow-ups that name nobody. The full contract is in the [WhatsApp module doc](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) — including why `body` itself is never rewritten.

### The WhatsApp Groups tab — is everything read, and what did it raise?
[CeoGroupsController](/app/Http/Controllers/Manage/Ceo/CeoGroupsController.php) + [Groups.vue](/resources/js/Pages/Manage/Ceo/Groups.vue) · `GET ceo/groups` · the **second tab** of the module's one strip (`SectionTabs` section `ceo`: Action Items · WhatsApp Groups · Inbox).

The inbox answers "what is new?". This answers "has everything my groups said been READ, and what did it put on my plate?" One row per group on the owner's phone:

- **Last message** — the newest `whatsapp_messages.created_at` in the group: for a live message, when it arrived here, which answers whether the phone is still feeding the system. Backfilled history keeps its ORIGINAL time, so a history push does not make a quiet group look busy.
- **AI last read** — the group's `ceo_group_cursors.last_analysed_at`, and how many messages are **not read yet**. That count follows the briefing's OWN rule (past the cursor by id, or inside `CeoGroupBrief::FIRST_RUN_HOURS` for a group never read), so "Up to date" can never describe messages the next run will read, and "not read" never describes ones it will skip. Because it follows the id, a history push shows up here: on 2026-09-16 a group read at 02:58 showed 46 unread messages that were all `is_historical` rows from 4–5 Sep, landed after the read — old by the clock, unread by the AI, and read on the next run.
- **New · unassigned**, by priority — open items nobody has been handed. It is the owner's triage pile, and it empties as they assign. **Open** is everything not closed.

**The group drawer carries the room's own conversation.** Above the relevance ruling sits the last `CONVERSATION_MESSAGES` (40) of that group, rendered read-only through the shared [`ConversationThread`](/resources/js/Components/Conversation/ConversationThread.vue) and the same `MessagePresenter` shape the inbox uses — so bubbles, attachments, sender labels and decoded `@mentions` all read identically. It is above the ruling deliberately: deciding whether a room is worth reading is a judgement about its content, and sending the reader to the inbox and back to make it loses the list they were working. Read-only by design — **Open the chat** goes to the real thread to reply. A CATEGORY drawer spans many rooms, so it carries no `messages` key at all.

**By group | By category** is a pivot of the page (a segmented control, not a tab). A group opens a drawer with its items sectioned by category; a category opens one with its items and the group each came from. `?view=`, `?group=`, `?category=` and `?state=open|closed` all live in the URL, so a drawer survives a refresh. Everything on the tab is scoped to GROUP conversations, so the category totals are exactly the sum of the rows — items raised from one-to-one chats live on Action Items.

**Sync & analyze all** is the group briefing on demand (`POST ceo/actions/brief`). There is nothing to pull: messages arrive live over the Bridge while the phone is connected, and the bridge has no fetch-newer call. What the button does is read everything past each cursor and file what it finds.

A room whose new messages carry nothing readable (a revoked message, a sticker) now has its cursor advanced anyway. Before, the briefing skipped the room without moving it, and the tab would have shown those messages "not read" forever.

**Not every group is worth reading — mark the rest irrelevant.** A personal phone is not a work account: beside the rooms where the business happens sit a family chat, a condo group, a school thread. Reading them costs more than tokens, and that is the part worth knowing: the briefing is capped at `MAX_BATCHES` calls and sorts rooms **busiest-first**, so the chattiest irrelevant room does not merely add noise — it **evicts a real one from the run**, and that room's cursor does not advance.

The ruling lives in the **group drawer** rather than on the row, because the drawer is where the owner can see what the room actually holds (and reach the chat itself) before deciding. `PUT ceo/groups/{id}` → `CeoGroupBriefRepository::setGroupIgnored` → one row per owner + conversation in **`ceo_group_settings`**.

- **Ignored means not READ**, not merely "raises no items": `GenerateCeoGroupBrief::collectRooms` checks the list before it reads a single message, so an ignored room produces no briefing line and no action items. Items it raised before it was silenced are kept — they were real when they were made.
- **The default is watched, and that direction is the safe one.** A room nobody has ruled on is covered, so nothing has to be opted in. The opposite default would mean a genuinely important new group raising nothing, with no sign on screen that it had been skipped.
- **An ignored room's cursor still moves**, through `skipCursor` — which deliberately leaves `last_analysed_at` and `runs` alone. Without the move, the room would build an unbounded backlog and the day the owner changed their mind the next capped run would try to swallow all of it (the failure `FIRST_RUN_HOURS` exists to prevent). Without the *omission*, the tab would print "AI last read: just now" about a room the model never saw — a lie on the one page whose whole job is answering that question.
- **An ignored room SINKS to the bottom of the list**, whatever column is being sorted. This is not cosmetic: a room is usually ignored *because* it is the chattiest on the phone, so the default "newest message first" would put the one the owner just switched off straight back at the top. The single exception is sorting by the **AI reads it** column itself — that is the owner asking to review what they have silenced, so it must be able to bring them up (one click; it defaults to descending).
- The tab reads the same list (`CeoGroupSetting::ignoredConversationIds`) the job does, so the page can never promise coverage the run will not deliver. An ignored row reports **0 pending** (nothing is waiting to be read), greys out, and its "AI last read" shows `—`. The headline count stays **all** groups — the label says "on this phone" — with `N ignored` underneath.

### Nav — one entry, one strip (2026-09-16)
The sidebar carries **one** CEO entry, ***WhatsApp*** (renamed from *Action Items* 2026-09-16 — the entry names the MODULE, the strip names the page), landing on `/manage/ceo/actions`; `/manage/ceo` redirects there too. Everything else is the strip on the page: **Action Items · WhatsApp Groups · Inbox** (`SectionTabs` section `ceo`, renamed from `ceo-inbox` when Action Items joined it).

Action Items and Inbox were two sidebar entries until then — one module reached two ways, which §15 exists to prevent, and it put the ANSWER (the to-do list) a click further away than the evidence it came from. The order is the order the work happens: what is on your plate → which room it came from → the raw chats underneath. The entry's `prefixes` must list **all three** paths, or the nav goes dark while the reader stands on two of its own pages.

### The card — what to read first
[ActionItemCard.vue](/resources/js/Pages/Manage/Ceo/Partials/ActionItemCard.vue), shared by Action Items and the Groups drawer. The first version gave the priority chip, the category picker, the title, the quote and the source **the same visual weight**, and the owner's verdict was that they could not tell what to look at. It now answers four questions in the order anyone asks them:

1. **How urgent?** — a colour rail down the left edge. Same place on every card, readable scanning a column, costs no reading. The *word* sits in the controls row, so the card has one loud thing rather than three.
2. **What do I do?** — the title, the largest thing on the card, with the AI's one-line reasoning under it.
3. **Who said so?** — the evidence, as the WhatsApp exchange it came from: the room across the top, the speaker and time above the line, the line in a bubble (green when the owner said it). This was 12px grey italic — the opposite of its importance, since it is the only part a reader can CHECK.
4. **Who owns it, by when?** — the controls, under a divider, so editing never competes with reading.

**The evidence is resolved, not reconstructed.** `ceo_action_items.quote_message_id` points at the actual message, matched at raise time by `CeoActionItem::messageIdForQuote` and backfilled by **`ceo:link-quotes`** (51 of 55 existing items on this install). Matching is on the quote's FIRST SENTENCE, then — only if that fails and the quote carries an `@mention` — against the DECODED bodies of the room's recent messages, because the transcript the model quotes has mentions rendered as names (`@Lee Jie`) while the stored body still holds the raw LID. A quote shorter than `QUOTE_KEY_MIN` (20 chars, e.g. *"Record in system"*) resolves to **null on purpose**: it cannot identify one message, and a wrong sender under a task is worse than no sender. The card then shows the quote with no speaker. The whole message body is deliberately never shipped — it runs past a thousand characters in a busy room and says nothing the quote does not.

### Draft the nudge, then send it — `POST ceo/actions/{id}/draft` + `/nudge`
The dashboard used to stop one step short of useful: it said what was owed and by whom, then left the founder to open WhatsApp, find the person and write the message — which is the part that does not happen at 11pm. Now **Draft a message** on the card writes it ([`NudgesCeoActionItems`](/app/Http/Controllers/Concerns/NudgesCeoActionItems.php) + [`resources/prompts/ceo_item_nudge.md`](/resources/prompts/ceo_item_nudge.md)), the owner edits a word in the composer, and **Send** delivers it from their own number.

- **WHO IT GOES TO IS ITS OWN QUESTION — not the assignee.** The first version conflated the two and got the commonest case exactly backwards: *"Remind Zen on commission collection"* sits in the **Wai Kit Self Reminder** room, so it is the FOUNDER'S item, and the draft dutifully wrote him a note to himself about chasing Zen. The message he wanted was **to Zen**, asking how the collection is going. So the recipient resolves as: an explicit pick in the composer → **the teammate the task NAMES** (`personNamedIn` scans the title and quote, longest name first so "Mei Xin" is not beaten by "Mei", word-bounded so "Zen" never matches inside "Zendesk") → whoever owns it. The composer's **To** is a picker, and changing it rewrites the message, because a nudge to Zen and a nudge to Boon are not the same words. The prompt is told both `to` and `owned_by` for the same reason, with an explicit rule against restating the founder's own shorthand back at the person ("Hi Zen, remind Zen about the commission" is the failure it exists to stop).
- **TWO routes, never one.** Drafting spends money and sending is irreversible; a single button would do both on one press, with a message going out in the founder's name before they had read it.
- **The send takes the text from the REQUEST, not the stored draft** — what the owner reads in the box is what goes out.
- **It goes down the ordinary outbound path** (contact → `conversationFor` → `createOutbound` → `SendWhatsAppMessage`), so the nudge lands in the CEO inbox as a normal thread with delivery ticks, and the reply arrives where the owner is already looking. A side-channel send would be invisible the moment they answered.
- The draft is **stored** (`draft_message` / `draft_for_user_id` / `drafted_at` / `nudged_at`): it cost an AI call, and one bought twice comes back subtly different. `draft_for_user_id` is part of the draft — the same task nudged to Boon and to Kexin is two different messages, and sending a draft to somebody since taken off the item is refused.
- **`max_tokens` is sized for the REASONING, not the message.** The nudge is three lines and 700 looked generous; the first real draft came back cut off mid-sentence at 696 tokens, and a truncated JSON object parses as nothing — an empty draft and a bill for it. It is 2400.
- The button only appears when the item CAN be chased (somebody owns it *and* has a number); otherwise the card says which of the two is missing, rather than offering a button whose only outcome is an error.

### Reading the answer — `ceo:read-replies` (hourly)
Sending the nudge closed half a loop. The other half is the part that actually costs a founder: the answer lands in a room they are not reading, says *"sent it already"* or *"can I do it Friday"*, and the item sits on the list either way — done but still open, or slipping with nobody told. [`ReadCeoNudgeReplies`](/app/Console/Commands/ReadCeoNudgeReplies.php) + [`resources/prompts/ceo_item_reply.md`](/resources/prompts/ceo_item_reply.md) read it and put one line on the card.

- **What counts as the answer: the first INBOUND message in that thread after the one we sent**, found by `nudge_message_id` — which is why the send stores it. Anchoring on the message rather than a clock is what makes it unambiguous: these are people the owner talks to all day, and "the newest message since this morning" would read unrelated chatter as a reply. By **id**, not timestamp, for the same reason the group cursor is: a backfill lands old messages with new ids.
- **`reply_state` is separate from `status`, and that is the point.** It records what somebody SAID — *Says it is done · On it · Gave a date · Blocked — needs you · Replied*. Closing the item stays the owner's tick. Auto-closing because a person typed "done" would hand the list's accuracy to whoever replies fastest, and this module reports rather than decides.
- **A promise is not a completion.** The prompt is explicit that *"will do it tonight"* is `scheduled`, never `done` — a founder who clears an item on a promise finds out a week later that nobody did it. A date THEY give does move `due_on`; nothing else about the item is touched.
- **Chasing again clears the last answer** (`markNudged` nulls the reply columns): what somebody said before being asked again is not a reply to the new message, and leaving it on the card would read as though they had already come back.
- **Hourly, not twice-daily** like the briefing: a reply is only useful while the founder still has the item in mind, and *"Zen said he'd do it at 3pm"* read at midnight is a fact about yesterday. It costs one small call per answer and nothing when nobody has replied. One unreadable reply is logged and retried next run rather than ending the pass.
- **The card shows their ACTUAL WORDS, not only the reading.** The summary says what the answer amounts to; the bubble under it is the message itself — the thing the owner would otherwise open WhatsApp to see, which is the whole point of the card. Sent message green and right, reply white and left, so the panel reads as the exchange it is. Both clamped to four lines with the full text on hover.
- **The composer COLLAPSES once a message has gone out.** An editable box under a delivered message invites the reader to change words nobody will ever see, and buries the two things that now matter — what was sent, and whether they answered. *Reply to them* / *Write another* reopens it.
- Verified against a real exchange: *"Next thursday can come"* → *"我拜四有事，怎麽辦？"* read as **Blocked — needs you**, *"They are busy on Thursday and asked how to proceed."*

### Filtering by whose desk, and sending somebody their whole list
Two rows of chips narrow the same list along different axes — **category** (what part of the business) and **assignee** (whose desk). A founder asks *"what is on Boon?"* as often as *"what is in Finance?"*. `?assignee=<uuid>` for one person, **`?assignee=nobody`** for the pile nobody has picked up — which is the one most often wanted and which a person-only filter cannot express. Counts come through the pivot, so an item on two desks counts once for each; that is the honest answer to "how much is on Boon?".

**Send someone their list** (`POST ceo/actions/summary/draft` → `/send`, [`SummaryModal.vue`](/resources/js/Pages/Manage/Ceo/Partials/SummaryModal.vue)) is the other conversation a founder has. The per-item nudge chases ONE thing; this hands a person everything open on their plate in a single message, so both sides look at the same set instead of trading one-line reminders all week. The prompt is told each item's `last_reply`, so where somebody has already answered the line acknowledges it (*"the Armani payment — you said you'd do it today, still good?"*) rather than re-asking; past eight items it names the urgent ones and counts the rest, because a wall of text on a phone is read as none of it.

- **Draft comes back as JSON, not an Inertia redirect** — it opens in a modal, and a page response would close it under the reader mid-flow.
- **The literal `actions/summary/*` routes are declared BEFORE `actions/{id}/*`.** Without that, `actions/{id}/draft` captures `"summary"` as the uuid and the request 404s on an item that does not exist — the same trap GUIDELINES §14 names for a bare `export` segment. Verified by route matching, because calling the controller directly hides it.
- Two presses, like the nudge, for the same reasons.

### Handing an item to someone — the team
Every item card ([ActionItemCard.vue](/resources/js/Pages/Manage/Ceo/Partials/ActionItemCard.vue), shared by Action Items and the Groups drawer) carries a category picker, an **assignee** and a **due date**, each saved on its own through `PUT ceo/actions/{id}`. Only the field sent is written, so setting a due date can never clear a category; clearing is an explicit null.

**An item can be on SEVERAL people.** `ceo_action_item_assignees` (a pivot) replaced the single `assigned_admin_id` column in 2026-09-16's migration, which copies every existing assignment across and then DROPS the column — keeping both would leave two places answering "whose desk is this on", and GUIDELINES §2 says what happens next. Real work in these rooms lands on two desks at once ("Boon and Shawn, walk me through the Armani workflow"), and one column forced the owner to pick a favourite or leave it unassigned, which reads identically to *nobody has looked at this*. `PUT ceo/actions/{id}` takes **`assignees[]` — the whole list, every time**; an empty array is the real answer *nobody*, and the key being ABSENT is what stops a due-date edit silently clearing the owners. One bad uuid refuses the whole edit rather than being dropped, because a half-applied assignment is worse than a refused one. The picker is [`AssigneePicker.vue`](/resources/js/Pages/Manage/Ceo/Partials/AssigneePicker.vue), a checklist rather than a `<select multiple>` — that control needs ctrl-click to add a second name and gives no hint it holds more than one.

**The owner is always the first option, labelled "Me (name)"** — prepended by `ceoTeamOptions`, never stored as a `ceo_team_members` row. Most of what this dashboard raises is the founder's own next move (the *Wai Kit Self Reminder* room exists for exactly that), and a picker that could only hand work AWAY left those items unassigned — indistinguishable from "nobody has looked at this". Being able to assign yourself is not a membership decision, so it holds on a brand-new dashboard whose team is still empty and survives the owner emptying their team. After that, the picker is the owner's **team** (`ceo_team_members`), not the whole assignable staff pool — eighteen people on this install, test accounts and a shared Support login among them. **Phone numbers are edited here too**, in the same modal and the same save. A missing number is not a fact about a teammate — it is the reason nothing can reach them about an item they own — so fixing it is not a trip to the Admins module. Only the numbers actually CHANGED are sent (`phones` is a partial uuid => number map), because posting the whole map would let a second person's save quietly restore what the first replaced. **The number lives on `ceo_team_members.phone`, NOT on the person's profile — and duplicates are allowed.** It is not an identity claim; it is *where I WhatsApp them*. `user_profiles.phone` cannot hold it for two reasons that only showed up in use: it is UNIQUE, and the same human routinely holds two accounts (Lee Jie is `Leejie`/super-admin with no number **and** `Teh Lee Jie`/non-member holding 60169088504), so the real number is refused on the staff account. Dropping that unique index was the obvious fix and the wrong one — [`LeadRepository`](/src/Lead/Repositories/LeadRepository.php) names it as the reason lead capture is idempotent (*"repeated calls converge on one user + one lead"*, without which one customer splits across accounts), and [`AgentAppLeadSearchQuery`](/src/Lead/Queries/AgentAppLeadSearchQuery.php) does a literal `forceIndex('user_profiles_phone_unique')` that errors the moment it is gone. So the number is recorded per owner, per person, unconstrained: two teammates may share a company line, and neither identity record is touched. `user_profiles.phone` stays the fallback, so nothing is re-entered for staff whose account already carries the right number. **The team is synced BEFORE the phones**, since a number needs its row to exist.

The team is picked from that pool with the tab's **Team** button (`PUT ceo/team`, `CeoTeamController`) and always read back THROUGH the pool (`ResolvesCeoTeam`): somebody who loses their manage role drops out by themselves, and a crafted uuid cannot put work on anyone outside the team. A person with no staff account cannot join until they have one. The chat panel's "Someone else" picker uses the same team.

Assigning still **does not message anyone.**

### The panel beside a chat (Gemini)
[GenerateCeoChatInsight](/app/Jobs/Ai/GenerateCeoChatInsight.php) + [ChatInsightPanel.vue](/resources/js/Pages/Manage/Ceo/Partials/ChatInsightPanel.vue) — a third column that answers the question you actually have when you open a conversation you have not read in a while: what happened, what should I know, what do I owe anyone.

- **It leads with who the ball is with.** `state` is one of `waiting_on_you` / `waiting_on_them` / `active` / `quiet`, and it sits above the summary, because it is the only line that changes what the reader does next. Sentiment would not.
- **Suggestions are not tasks.** The follow-ups live in the insight's json with an **Add** button; nothing reaches `ceo_action_items` until the owner accepts one (`acceptItem`). An AI reading somebody's private WhatsApp must not be able to fill their task list — or their team's — on its own.
- **Every item carries its quote**, the same rule the digest follows and for the same reason: a list you cannot check is a list you stop trusting.
- **It caches with an explicit freshness test.** `covers_message_id` records the newest message the reading saw, so reopening an unchanged chat is instant and free, and a chat that has moved re-reads itself. Without that column the only honest options are to pay on every click or to show a summary that quietly predates the last three messages.
- **Reading is a POST**, never a side effect of opening the thread: a GET that spends money at a provider is a GET you cannot safely reload, prefetch or share. The page fires it on open and on stale, which is what makes it feel automatic.
- **Gemini is named explicitly** in the job rather than left to `config('ai.default_provider')`, so changing the app-wide default cannot silently move this feature. The registry entry carries a `model_note` saying so, because a pin on the AI Prompts page is ignored for a caller that names its own provider.
- **Assigning records, it does not notify.** `ceo_action_items.assigned_admin_id` says who owns a task; the panel says in words that the person is not told yet. Writing down a delegation is useful on its own, and announcing one is a promise the system should only make once it can keep it. The picker is the bounded `assignableAdminOptions` list, and the controller re-checks the chosen uuid against that same list, so a hand-crafted request cannot assign work to somebody outside it.

### Reading and sending media
The thread renders text, photos, video, voice notes, documents, locations and call events through the shared `MessagePresenter`, with the shared `ImageLightbox` for pictures. Sending goes through the team inbox's own `SendMediaRequest`, so the mime whitelist and per-type caps are the intersection both WhatsApp providers accept — a file the wire would refuse is refused here, with a readable reason, instead of failing at the provider minutes later.

**History media is metadata only.** A backfill ingests thousands of photos without their bytes (pulling every file of every chat on a personal phone would be an enormous, mostly-unwanted download), so an old picture shows a **Load image** button that queues `DownloadWhatsappMedia`. WhatsApp expires media on its CDN after roughly two weeks, so an old fetch can legitimately fail; the button simply comes back.

### How much history arrives — and how to get more
WhatsApp decides how deep the link-time sync goes, and it is usually less than people expect. The first real link here produced only **INITIAL_BOOTSTRAP**: 867 chats, 634 messages — the chat list plus roughly the newest message per chat — and no deep `FULL` batch at all, leaving nearly every thread one message deep. The bridge was already asking for everything it can (`syncFullHistory`, `Browsers.ubuntu('Desktop')`, accepting every sync type); WhatsApp simply did not send it.

**Two things changed that, both verified on this install on 2026-09-15.**

- **Reconnecting can trigger another push.** A bridge restart resumed the session and WhatsApp followed with `RECENT` (3,852 messages) and three `FULL` batches (2,035 / 4,217 / 1,048). The store went from 5,195 messages to 14,024. So "history only on a fresh link" is too strong: a reconnect is worth trying, and a re-scan more so.
- **On-demand paging WORKS.** The handbook used to call `fetchMessageHistory` a dead end for linked devices (Baileys [#2452](https://github.com/WhiskeySockets/Baileys/issues/2452)). It is not, at least not here: asking for messages older than a 7-message group's oldest returned **sync type 6 with 45 messages**, and the group became 52. That is now a product feature — see below.

**"Load earlier messages"** at the top of a thread is one control over two sources, in order. While older messages are still in the database it pages through them (60 on open, +100 a press — the deepest chat here holds 9,533, so loading a thread whole to show the last twenty would be a slow page for nobody). Once the database is exhausted the button becomes **Fetch older from WhatsApp**: `POST inbox/{id}/history/older` → `BridgeGateway::fetchOlderHistory` → the bridge's `fetchMessageHistory`.

That second half is **asynchronous and may return nothing** — WhatsApp answers through the ordinary history webhook, or not at all. So the endpoint reports only that it *asked*, the page polls for about eighteen seconds, and it then says either nothing (the messages appeared) or *"WhatsApp had nothing older for this chat"*. Presenting it as a fetch that succeeded would be a lie the reader catches immediately.

Each conversation has its own URL — `?conversation={uuid}`, plus `&thread=N` once the reader has paged back — so a chat is shareable and the back button is honest.

### The twice-daily GROUP briefing
[GenerateCeoGroupBrief](/app/Jobs/Ai/GenerateCeoGroupBrief.php) · `ceo:group-brief` at **00:00 and 12:00** · on demand at `POST ceo/actions/brief`.

**Groups only, and that is the product decision.** A one-to-one chat is a conversation the founder is already in; a group is where the business talks about itself while they are asleep. Decisions get made, work gets promised and problems surface in rooms nobody summarises.

**It reads by CURSOR, not by clock** ([`ceo_group_cursors`](/database/migrations/2026_09_15_100002_create_ceo_group_cursors_table.php)). Each group is read from the last message id already analysed. A clock window breaks twice: a skipped run loses a half-day outright, and a backfill answered by WhatsApp lands weeks-old messages with brand-new ids — old by the clock, unread by us. A cursor advances **only after the model has answered**, so a failed or unparseable run re-reads instead of silently skipping a room.

**The first read of a room is bounded by TIME, not by a count.** That distinction was measured, not assumed: "the last 150 messages of every room" reached back through the whole backfill and produced a **148,000-character** first run across 38 rooms, where the last 24 hours of the same rooms is **204 messages**. One is a briefing; the other is a history essay and five reasoning calls to write it.

**Continuity.** Each room's `carry_forward` line is fed back into the next run as `PREVIOUSLY:`, so the brief reads as a continuing relationship with each room ("Faizal still has not sent it") rather than a cold read every twelve hours.

**On GEMINI FLASH since 2026-09-19** — the founder's call (*"switch the ai model to google gemini latest flash model"*). It ran on OpenAI's `gpt-6-astra` until then, which is where the reasoning-model timings below come from. The PROVIDER is still named explicitly in the job (`aiProvider()` + the call's `provider` option), so a change to the app-wide default cannot move the briefing onto another company's model; the MODEL is **`config('ai.providers.gemini.default_model')`**, not a literal, because "the latest Flash" is a moving target and that key is where this app records which one is current — today `gemini-3.6-flash`. The rest of the CEO module was already on it (`ceo_digest`, `ceo_chat_insight`, `ceo_item_nudge`); the briefing was the last thing on OpenAI. ⚠️ A queue worker holds the old model until **`php artisan horizon:terminate`**.

Two things it forced, both worth keeping:
- **A per-call timeout.** `AbstractTransport::callTimeout()` reads an `ai.timeout` option, capped by `ai.max_request_timeout` (240s). The 60-second default is for a short completion; a reasoning model reading a half-day of a business does not answer inside it — the first two real runs died on a cURL 28 with nothing to show. **Measured: 113 seconds** for 19 rooms / 188 messages / 18,351 characters.
- **A sampling guard.** `OpenAiTransport::rejectsSampling()` drops `temperature` for the `gpt-5.x` / `gpt-6.x` families, which answer anything but the default with a 400 — the same rule `AnthropicTransport` already applies to Opus 4.7+.

**The report** ([resources/prompts/ceo_group_brief.md](/resources/prompts/ceo_group_brief.md)) is ordered the way a chief of staff would brief someone walking between meetings: what needs their decision, what has been promised in their name, what is going wrong that nobody escalated, what money moved, who is carrying or stalling, then room by room, then the follow-ups. **Every finding carries the sentence it came from** — a brief that cannot be checked stops being read — and the prompt refuses to raise anything it cannot quote.

**Downloading it.** `GET ceo/brief/download` (latest) and `GET ceo/brief/{id}/download` stream the briefing as **Markdown** — [`BriefMarkdown`](/src/Ceo/Support/BriefMarkdown.php), a pure function of the stored report, so the file can never drift from the screen. Markdown because a briefing is something the founder takes OUT of the system: pasted into a message, dropped into a doc, kept on a laptop. Streamed rather than written to disk, so a customer's commission is never sitting in a public directory, and scoped to the signed-in owner like everything else here. The page offers it with a plain `<a href>`, never an Inertia `<Link>` (GUIDELINES §13 — a download link is the one exception).

Its follow-ups are filed **directly** on the action list (nobody is awake at midnight to accept them), through the same `syncItemsFor` dedupe as everything else, so the noon run does not re-raise what midnight found.

### The digest
[GenerateCeoDigest](/app/Jobs/Ai/GenerateCeoDigest.php) (`extends AiJob`, so it inherits the dedicated `redis-ai` lane, the per-provider rate limit, the circuit breaker and the retry policy — see the [AI handbook](/docs/modules_handbook/shared/ai/readMe.md)).

- **It batches by chat.** A personal phone's day is past any model's useful context, so chats are grouped into batches under a character budget and each batch is one call. A chat is **never** split across batches: half a conversation summarised on its own produces confident nonsense.
- **It has a ceiling.** Past `MAX_BATCHES` (6) the run stops and records `skipped_chat_count`, which the page shows. "I read 40 of your 58 chats" is cheaper and more honest than an unbounded loop that spends real money on a busy day and can outlive the job timeout.
- **It tells the model what day it is.** The transcript opens with today's date and the window. Without it the model cannot resolve "by Tuesday" or "before Friday" and correctly returns `null`, so **every action item arrived with no deadline** — which is exactly what happened before that line existed.
- **It only ever reads.** Nothing in the class can send a WhatsApp message.
- Prompt key `ceo_digest` ([resources/prompts/ceo_digest.md](/resources/prompts/ceo_digest.md), editable + versioned on the AI Prompts page). It runs on the FLASH tier.

### Action items, and why they do not pile up
Every item carries `quote` — the sentence from the chat it came from, copied verbatim. That is not decoration: it is the only way the owner can tell at a glance whether the AI understood the chat or invented a task, and a list you cannot check is a list you stop trusting. The prompt refuses to raise an item it cannot quote.

Dedupe (`CeoActionItem::fingerprintFor`) keys on the chat plus the **first sentence of the quote**, not the title. The title is written fresh on every run: two reads of the same window produced *"Send signed booking form to Ahmad Faizal"* and *"Sign and send the Setia Alam booking form to Ahmad"* — one task, two titles. Keying a fixed number of characters of the quote does not fix it either, because the variance between runs is how *much* of the message gets copied; when the shorter quote falls under the cutoff and the longer one does not, the keys differ by exactly the extra clause. The first sentence is the same in both, and by the prompt's own instruction it is the sentence that puts the task on them.

What blocks a re-raise, and why each case is what it is:

| Existing item | Next run | Why |
|---|---|---|
| **Open** | skipped | Already on the list; a second copy is noise. |
| **Dismissed** | skipped | The owner said it was never a task. Re-raising it tomorrow is the same failure wearing a hat. |
| **Done** | raised again | A task finished last week and asked for again this week is a NEW task. Silently swallowing it is the worst of the three, because nothing on screen would say so. |

### Categories — whose desk an item sits on
Priority says what to do first; it does not say which part of the business a task belongs to. Once the list carried items from every group, a webinar slide fix, a commission claim and a renovation lead sat in one column, and the owner could not ask the question they work by: *what does marketing owe me?* So every item carries a **category** (`ceo_action_items.category`, `CeoActionItem::CATEGORIES`): **Sales · Marketing · Webinar · Finance · Reno & Rental · Tech & AI · Partners · Team · Other**.

- **The AI files it when it raises the item.** All three prompts that produce items (`ceo_digest`, `ceo_group_brief`, `ceo_chat_insight`) carry the same category guide and return a `category` key. `CeoActionItem::categoryFrom()` maps the word (near-misses like "Tech & AI" included) and degrades an unknown one to Other. The guide says to choose by what the task is FOR, not by which chat it came from: a slide fix for tonight's webinar is Webinar even in the sales group, and a commission claim is Finance even when a developer asked for it.
- **No word is not the same as an unknown word.** A missing category leaves the column **null** — "not sorted yet" — rather than Other. That is what an item raised before categories existed looks like, and what a Horizon worker still on old code produces until `horizon:terminate`. The page lists and counts null under Other, and **`php artisan ceo:categorize-items`** sorts them with the `ceo_item_category` prompt. It touches only null rows unless given `--all` (a re-sort after the category list itself changes — which also overwrites anything the owner moved by hand), because the only other way a row gets a category is the owner's own decision. `--dry-run` shows the filing without writing; a batch the model half-answers leaves the rest null and exits non-zero, so running it again finishes the job.
- **The owner moves it.** On Action Items the category pill on each item is its own picker (`PUT ceo/actions/{id}` → `CeoDigestRepository::update`, the item's one plain-field writer). The chip row under the tabs narrows the list by key (`?category=marketing`) and counts each category within the tab being shown; the two filters travel together.
- **The chat panel labels each suggestion** with the category it will carry, and accepting it keeps that category.
- **The briefing's Markdown** names the category on each follow-up.
- **A test holds the prompts to the list** (`test_every_category_is_offered_by_every_prompt_that_files_items`): a category added to the model but not to the prompts would never be chosen. To change the list: `CATEGORIES` + `CATEGORY_CODES` on the model, the category guide in all four prompt files, then `ceo:categorize-items --all`.

### The owner moved accounts (2026-09-19, this install)
The dashboard was linked under `shawn@propertylab.com.my` while the phone and the mailboxes were Wai Kit's own, so the person the data belonged to could not read it and somebody else could. At the founder's instruction everything owner-keyed moved to `waikit@propertylab.tech` in one transaction each, with the affected row ids written to `storage/app/deploy/backups/ceo-owner-move-*.json` and `ceo-email-move-*.json` first.

| Moved | Rows |
|---|---|
| `whatsapp_channels` (purpose CEO, #27) | 1 |
| `ceo_action_items` · `ceo_chat_insights` · `ceo_digests` · `ceo_group_briefs` | 122 · 15 · 6 · 20 |
| `ceo_team_members.owner_user_id` · `ceo_group_settings.owner_user_id` | 11 · 16 |
| `email_mailboxes` **purpose `ceo` only** (+1,185 `email_messages` by `mailbox_id`) | 2 |
| `ceo_email_chats` · `ceo_email_reviews` | 1 · 1 |

**What deliberately did NOT move:** `ceo_group_cursors` (keyed by `channel_id` — it follows the channel), `ceo_action_item_assignees` (those are the people items are ON, not the owner), and the two **`operations`**-purpose mailboxes (`support@propertylab.com.my`, `support@propertylab.tech`) — a shared support inbox on another surface, and handing it to one person is not what was asked.

⚠️ **Ownership is what every CEO query keys on**, so a move like this is all-or-nothing per surface: leaving `ceo_team_members` behind would have given the new owner a dashboard with nobody to assign to, and leaving `ceo_group_settings` behind would have un-ignored every room the old owner had silenced. Verified after the move: the new owner gets 200 on Action Items (73 open), Groups, Inbox and Email (both mailboxes); the old owner gets 403 on the Inbox and 404 on the pages that need a linked number.

### The other side: a team member's own list (2026-09-19)
Founder: *"i can assign the actual propertylab user id, so that now all member can login to update their status. when they login, they only can see the whatsapp tab"*. Until now an item reached a colleague as a WhatsApp nudge and came back the same way — the owner chased, and the answer lived in a chat.

**`/manage/ceo/my-actions`** ([CeoMyActionsController](/app/Http/Controllers/Manage/Ceo/CeoMyActionsController.php) + [MyActions.vue](/resources/js/Pages/Manage/Ceo/MyActions.vue)), behind its own permission **`view-ceo-assignments`**. Two tabs (On me · Done) and one button per row: **Done**, with Reopen as the undo.

- ⚠️ **It is NOT the owner's page with a filter on it.** The owner's card carries the evidence behind every item — the sentence, the room, the person, the time — because a list you cannot check is a list you stop trusting. That evidence is a line out of somebody's private WhatsApp, and being handed a task is not being handed the conversation it came from. So this is a separate controller and a separate component, and the payload carries **no quote, no chat name, no evidence, no draft**: title, detail, priority, category, due date, overdue, status and who asked. (Verified on the wire — those are the only keys.)
- **Scope is the pivot, never a URL.** An item is theirs if their user id is on `ceo_action_item_assignees`; somebody else's uuid answers **404**, not 403, so the page never confirms an item exists.
- **Dismiss stays with the owner.** A member may close their work or reopen it; "this was never a task" is the one outcome the owner would never see if a member could make it. `done_by` records the member, so a closed item says who closed it.
- **One permission, one page, one sidebar line.** `view-ceo-assignments` opens nothing else — the inbox, the groups, the briefings and the owner's list all stay 403. The suite's nav shows the member a single **WhatsApp** entry pointing here, hidden from anybody who also holds `view-ceo-dashboard` (`unless` in `ManageLayout`'s nav filter) so an owner never sees two lines with the same name.
- **They land on it when they sign in** ([ResolvesPortalHome](/app/Http/Controllers/Concerns/ResolvesPortalHome.php)): the CEO suite is URL-only and absent from the Hub, so a member would otherwise sign in to a chooser with nothing on it. The rule reads the GRANT (`checkPermissionTo`), not the Gate, so a super-admin — who holds every permission — is not swept onto the member page.
- ⚠️ **The permission is withheld from the `admin` ROLE in `RolesSeeder`**, like `view-owner-listing`: it is not a module gate but a named person's place on one owner's team, and holding it changes where they land after signing in. Grant it per person on Manage → People → Roles. On a fresh deploy run `php artisan db:seed --class="\RolesSeeder" --force` first or the permission does not exist yet.
- **Who can actually be restricted.** The narrow sidebar is an RBAC outcome: a member whose role is `marketing` or a custom role sees one entry, but a member who is a **super-admin still sees everything** (Gate::before). On this install 7 of the 11 team members are super-admins — the restriction bites for the rest.

### Who may read the Inbox (2026-09-19)
Founder: *"/manage/ceo/inbox should be only access by waikit@propertylab.tech"*. The rest of the module is what the AI MADE of the chats; the Inbox is the chats — a person's private WhatsApp, one-to-one threads included.

- **It is an IDENTITY check, not a permission, and it had to be.** [AuthServiceProvider](/app/Providers/AuthServiceProvider.php) registers a `Gate::before` that returns true for every super-admin, so `can()`, policies and spatie's `permission:` middleware all hand a super-admin everything — and **nine accounts on this install hold that role**. A permission named `view-ceo-inbox` would have restricted precisely nobody while looking like a lock, which is worse than no lock.
- **One answer, two readers.** [`Src\Ceo\Support\InboxAccess`](/src/Ceo/Support/InboxAccess.php) compares the signed-in e-mail (case-insensitively) against `config('ceo.inbox_emails')` (env `CEO_INBOX_EMAILS`, comma-separated; an empty value falls back to the default rather than to "nobody", so a typo cannot lock the owner out). The route middleware `ceo.inbox` ([EnsureCeoInboxReader](/app/Http/Middleware/EnsureCeoInboxReader.php)) and the shared `auth.user.can_ceo_inbox` prop both read it, so the gate and the menu can never disagree.
- **The whole inbox group is gated, not the page.** `GET inbox` plus read, send, media, fetch-media, history/older, insight and insight/items. A gate on the page alone leaves every write reachable by anybody who knows the path — verified: another super-admin gets **403** on `POST inbox/{id}/insight` and on `POST inbox/{id}/read`.
- **The tab is hidden, not 403'd** — `SectionTabs`' new `authFlag` gate, checked BEFORE the permission ones so no role can defeat it (§15: a tab that 403s is a bug, not a hint).
- **It sits on top of the existing scoping, which is unchanged**: every endpoint still resolves the number through `CeoChannelResolver` by `user_id`, so an allowed reader still only sees the phone linked to their OWN account. This says who may knock; that says what they find.
- ⚠️ **On this box the phone is linked under a different account.** Channel #27 "Wai Kit Business Whatsapp 012-2574886" has `user_id = 1` (shawn@propertylab.com.my), and so do all 122 action items, the digests and the briefs. With the gate in place that account gets 403 and `waikit@propertylab.tech` gets the module's own 404 ("No WhatsApp number is linked to your CEO Dashboard yet") — the inbox is closed to everyone until the number is linked under the reader's own account, or the channel and its data are moved.

### The tabs are the triage (2026-09-19)
The open pile is two piles, and the founder asked for them apart: *"i wanna first see action item which has not been assigned actionee, then another tab is those who assigned & what status"*. So the strip is **To assign · Assigned · Done · Not a task**, where the first two split `status = open` by whether anybody owns it.

- **Why that split is the top-level control, not a chip.** An item with nobody on it is not work in progress — it is a decision nobody has made, and the only person who can make it is the owner. An item with a name on it is a different question ("has it moved?"), answered by a different set of eyes. A chip row cannot say that one comes before the other.
- **`?state=unassigned|assigned|done|dismissed`**, and `open` still resolves (both halves at once) because older links carry it. **With no `?state=` the page lands on To assign — unless that pile is empty, in which case it opens on Assigned**: an owner who has handed everything out should meet their board, not a blank page that reads as a bug.
- **The person chips are gone from To assign** (every item there is on nobody, so every chip would read 0) and the old **"Unassigned" chip is gone entirely** — it is the first tab now, and two controls for one idea is how a page starts lying about which one is in charge.
- **Category counts follow the tab's desk, not just its status** (`categoryCounts($user, $statuses, $state)`). The rule on this page is that a chip never promises rows the list will not show; counting the whole open pile under To assign would offer "Finance 6" and then list two.
- **`counts.unassigned` / `counts.assigned`** come from `openDeskCount()` — `whereHas` / `whereDoesntHave`, never a sum of `assigneeCounts`, whose per-person totals count a two-owner item twice. A tab that promises eleven rows and lists ten is a tab nobody trusts again.
- **"How far has it got" is on the card**, and only once somebody owns it: **Overdue → their reply → "Chased · no answer yet" → "Not chased yet"**, in that order of what decides the owner's next move. It reads states the card already held (`overdue`, `reply`, `awaiting_reply`) — the Assigned tab needed them in one chip rather than scattered down the card.

### Priority is the owner's call (2026-09-19)
The AI sets a priority when it raises an item; until now nothing could change it, and the founder's question was simply *"how to change priority level from medium to high?"*. The **priority chip IS the picker** — same place, same colour, so the row still reads at a glance and the control does not have to be hunted for. `PUT ceo/actions/{id}` with `priority` (the integer constant, validated against `CeoActionItem::PRIORITIES`), through the same one plain-field writer as category, assignees and due date — a new editable field joins `CeoDigestRepository::update()`'s `data_only` list rather than growing a sibling writer (GUIDELINES §2).

### When it runs
`ceo:run-digests` daily at **07:30** ([Kernel](/app/Console/Kernel.php)) for every **connected** CEO number, covering the last 24 hours — plus the page's own **Read the last 24 hours** button. Daily rather than hourly on purpose: this is a briefing you read once with your coffee, and a feed that refreshes all day is a second inbox, which is the thing the dashboard exists to save you from. A run already in flight is never duplicated, so a manual read a minute before the schedule fires does not pay for the same window twice.

## Prerequisites
The **`wa-bridge/`** Node service running, **Horizon** (inbound ingest + the `redis-ai` lane the digest runs on), the **scheduler** (`ceo:run-digests`, `whatsapp:ping-bridge`), an AI provider key, and GCS for media. On an existing install run `php artisan migrate` (four migrations) then **grant `view-ceo-dashboard`** on Manage → Setting → Roles — the migration creates the permission but deliberately grants it to nobody, because who reads a private WhatsApp is a person's decision, not a deploy's.

## Not yet
- **No per-chat mute.** The digest reads every chat with activity in the window. A chat the owner does not want read has no way to be excluded yet.
- **Assigning does not notify.** `assigned_admin_id` records who owns a follow-up; the teammate is not told and it does not appear on a list of theirs.
- **No settings.** The window (24h), the schedule (07:30) and the batch ceiling are constants, not preferences.
- **Media is metadata to the AI.** An image or voice note reaches the digest as `[image]` / `[voice note]` plus its caption; the bytes are not transcribed or described, so a decision taken entirely inside a voice note is invisible to the summary.
- **No realtime.** The inbox does not receive live pushes (the broadcast is deliberately suppressed for privacy — see isolation #1); reopening the page is the refresh.

## Related files

**Backend — Models & services**
- [src/Ceo/CeoDigest.php](/src/Ceo/CeoDigest.php) · [CeoActionItem.php](/src/Ceo/CeoActionItem.php) (`STATUSES` / `PRIORITIES` / `PRIORITY_CODES` / `CATEGORIES` / `CATEGORY_CODES` / `categoryFrom` / `categoryForKey` / `fingerprintFor`) · [Repositories/CeoDigestRepository.php](/src/Ceo/Repositories/CeoDigestRepository.php) (`create` / `claim` / `complete` / `fail` / `syncItems` / `update` / `done` / `reopen` / `dismiss`) · [Services/CeoChannelResolver.php](/src/Ceo/Services/CeoChannelResolver.php).
- Groups tab + team: [CeoGroupsController.php](/app/Http/Controllers/Manage/Ceo/CeoGroupsController.php) · [CeoTeamController.php](/app/Http/Controllers/Manage/Ceo/CeoTeamController.php) · [UpdateTeamRequest.php](/app/Http/Requests/Manage/Ceo/UpdateTeamRequest.php) · [src/Ceo/CeoTeamMember.php](/src/Ceo/CeoTeamMember.php) · [Repositories/CeoTeamRepository.php](/src/Ceo/Repositories/CeoTeamRepository.php) · controller traits [ListsCeoActionItems](/app/Http/Controllers/Concerns/ListsCeoActionItems.php) (the item query + row shape both pages share), [ResolvesCeoTeam](/app/Http/Controllers/Concerns/ResolvesCeoTeam.php), [PresentsCeoBrief](/app/Http/Controllers/Concerns/PresentsCeoBrief.php) · [Groups.vue](/resources/js/Pages/Manage/Ceo/Groups.vue) · [Partials/ActionItemCard.vue](/resources/js/Pages/Manage/Ceo/Partials/ActionItemCard.vue) · [Partials/TeamModal.vue](/resources/js/Pages/Manage/Ceo/Partials/TeamModal.vue) · migration `2026_09_15_100004_create_ceo_team_members_table.php`.
- Which groups the briefing may read: [src/Ceo/CeoGroupSetting.php](/src/Ceo/CeoGroupSetting.php) (`ignoredConversationIds` — the one read both the job and the page use) · [UpdateGroupRequest.php](/app/Http/Requests/Manage/Ceo/UpdateGroupRequest.php) · `CeoGroupBriefRepository::setGroupIgnored` / `skipCursor` · migration `2026_09_16_100001_create_ceo_group_settings_table.php`.
- Categories: [app/Console/Commands/CategorizeCeoActionItems.php](/app/Console/Commands/CategorizeCeoActionItems.php) (`ceo:categorize-items`) · [app/Http/Requests/Manage/Ceo/UpdateItemRequest.php](/app/Http/Requests/Manage/Ceo/UpdateItemRequest.php) · [resources/prompts/ceo_item_category.md](/resources/prompts/ceo_item_category.md) · [resources/js/utils/ceoCategory.js](/resources/js/utils/ceoCategory.js) · migration `2026_09_15_100003_add_category_to_ceo_action_items_table.php`.
- [src/Whatsapp/WhatsappChannel.php](/src/Whatsapp/WhatsappChannel.php) — `PURPOSE_INBOX` / `PURPOSE_CEO`, `isCeo()`, `isSharedInbox()`.

**Backend — Controllers, job, command**
- [app/Http/Controllers/Manage/Ceo/CeoController.php](/app/Http/Controllers/Manage/Ceo/CeoController.php) (connect / qr / state / disconnect) · [CeoInboxController.php](/app/Http/Controllers/Manage/Ceo/CeoInboxController.php) · [CeoActionsController.php](/app/Http/Controllers/Manage/Ceo/CeoActionsController.php).
- [app/Http/Requests/Manage/Ceo/ConnectRequest.php](/app/Http/Requests/Manage/Ceo/ConnectRequest.php) · [SendRequest.php](/app/Http/Requests/Manage/Ceo/SendRequest.php).
- [app/Jobs/Ai/GenerateCeoDigest.php](/app/Jobs/Ai/GenerateCeoDigest.php) · [app/Console/Commands/RunCeoDigests.php](/app/Console/Commands/RunCeoDigests.php).

**Frontend**
- [resources/js/Pages/Manage/Ceo/Connect.vue](/resources/js/Pages/Manage/Ceo/Connect.vue) · [Inbox.vue](/resources/js/Pages/Manage/Ceo/Inbox.vue) · [Actions.vue](/resources/js/Pages/Manage/Ceo/Actions.vue).
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) (`ceoNav` — and the comment in `hubNav` saying why there is no entry there) · [composables/useSuite.js](/resources/js/composables/useSuite.js) (`SUITES` + `OWNED_PATHS`, which is what makes the URL resolve its own sidebar) · [Pages/Manage/Messages/Channels/Partials/QrConnectModal.vue](/resources/js/Pages/Manage/Messages/Channels/Partials/QrConnectModal.vue) (`qrUrl` / `stateUrl`).

**Prompt**
- [resources/prompts/ceo_chat_insight.md](/resources/prompts/ceo_chat_insight.md) · [resources/prompts/ceo_digest.md](/resources/prompts/ceo_digest.md) · [config/ai_prompts.php](/config/ai_prompts.php) · `AiRequest::PROMPT_CEO_DIGEST`.

**Backend — the group briefing**
- Mentions: [src/Whatsapp/Support/MentionResolver.php](/src/Whatsapp/Support/MentionResolver.php) (shared with the team inbox) · [resources/js/Components/Whatsapp/MessageText.vue](/resources/js/Components/Whatsapp/MessageText.vue) · [tests/Feature/Whatsapp/MentionResolverTest.php](/tests/Feature/Whatsapp/MentionResolverTest.php).
- [src/Ceo/CeoGroupBrief.php](/src/Ceo/CeoGroupBrief.php) · [CeoGroupCursor.php](/src/Ceo/CeoGroupCursor.php) · [Repositories/CeoGroupBriefRepository.php](/src/Ceo/Repositories/CeoGroupBriefRepository.php) · [app/Jobs/Ai/GenerateCeoGroupBrief.php](/app/Jobs/Ai/GenerateCeoGroupBrief.php) · [app/Console/Commands/RunCeoGroupBriefs.php](/app/Console/Commands/RunCeoGroupBriefs.php) · [resources/prompts/ceo_group_brief.md](/resources/prompts/ceo_group_brief.md).
- Shared AI plumbing it added: [AbstractTransport::callTimeout()](/src/Ai/Transports/AbstractTransport.php) + `ai.max_request_timeout`, and [OpenAiTransport::rejectsSampling()](/src/Ai/Transports/OpenAiTransport.php).

**Migrations**
- `2026_09_15_100001_create_ceo_group_briefs_table.php` · `2026_09_15_100002_create_ceo_group_cursors_table.php`
- `2026_09_14_100001_add_purpose_to_whatsapp_channels_table.php` · `_100002_grant_ceo_dashboard_permission.php` · `_100003_create_ceo_digests_table.php` · `_100004_create_ceo_action_items_table.php`.

**Routes & permission**
- [routes/web.php](/routes/web.php) — the `manage.ceo.*` group · [src/Auth/Permission.php](/src/Auth/Permission.php) — `VIEW_CEO_DASHBOARD`.

**Tests**
- [tests/Feature/Ceo/CeoDashboardTest.php](/tests/Feature/Ceo/CeoDashboardTest.php) — capture-only, no lead, no opt-out, refused to a super admin, absent from the shared inbox, owner-scoped resolver / inbox / items, the dedupe rules, and the one-run-at-a-time guard.
