# App shell & Home scope — work-package 1: Home, alerting, and the IA shell

> **Kickoff plan. Written by Fable (the senior seat), 2026-07-16.**
> **Status: awaiting operator approval** (decision items below).
> Sources: target picture §3 (IA) + §4 (Home + alerting) + §9.6-7; scope board.
> Gate judges the package against THIS document. Intake was triaged: empty.

The package's spine: **Home becomes the 90% screen** — "is there an issue?"
readable in 10 seconds — and email becomes the pager for red only. The nav
stops being the WhatsApp-shaped tree §1 forbids. This package consumes the
provenance/failsafe data 1D built (that was the point of building it first).

## Read before touching anything

`CLAUDE.md` · `HANDOFF.md` · `plans/rethink/scope-board.md` ·
`plans/rethink-execution.md` (you are the executor seat) ·
`plans/rethink-target-picture.md` §3+§4 · `design/tokens.md` (law) ·
the harness frontend playbooks (memory: `harness-playbooks`) before UI work.

## Ground truth (verified at kickoff, 2026-07-16 — trust this over older docs)

- **Email sending EXISTS**: `EmailService.send()` via Gmail OAuth2
  (`apps/api/src/email/email.service.ts:23`), reading the `gmail` connection.
  An `alerts` BullMQ queue + `AlertsService.shouldAlert` + worker delivery via
  `POST /internal/alerts/deliver` already run. This package EXTENDS that spine
  (tiering, dedupe/cooldown, digest) — it does not build a mailer.
- **The silence detector is a query, not plumbing**: `webhook_events` has
  `source` (`"meta"` = WhatsApp, `"instagram"`) + `receivedAt`
  (`schema.prisma:429-443`). `MAX(receivedAt) GROUP BY source` answers "age of
  last event per channel". No index on `(source, receivedAt)` — add one
  (additive migration) before querying it per page load.
- Health surfaces: `GET /health` → `{status,db,redis}` (public);
  `GET /api/agent/models/health` → `LlmHealth` incl. `failsafes` 24h count
  (admin). Both feed Home cards.
- **Dashboard.tsx is WhatsApp-only and routed at `/`** (quality rating, tier
  quota, messages today, delivery/read rate, notifications, quick actions).
  Home replaces it at `/`; the WhatsApp quality/tier cards survive as content
  *inside* the WhatsApp channel card or move to Analytics §8.4 later — nothing
  silently dropped.
- Nav today (`AppShell.tsx:30-55`): Overview (Dashboard, Inbox, Contacts,
  AI Agent, Analytics) + "WhatsApp" (Templates, Campaigns, Segments,
  Automation, Reminders) + Settings. The target IA is §3's flat list.
- **No queue-depth endpoint exists** (no `getJobCounts` anywhere) — a Home
  signal wanting BullMQ depth is net-new, small.
- Review-queue counts, failed sends, failsafe counts: all queryable from
  existing tables (`review_items`, `ig_comments.status`, `agent_turns.
  decision_source` — 1D's work).
- Settings storage is the `Setting` key/value table; alert recipients already
  live at `alerts.recipients`.

## Standing rules

All of `CLAUDE.md` + rethink standing rules. Zero LLM spend in this package.
Tokens are law; every new screen on `design/tokens.*`. The operator flips
nothing here (no gates involved); email sending to real recipients is
outward-facing — **test with the operator's own address only, on their
go-ahead, before the digest/red paths are enabled**.

### Parallel-execution constraints (this package runs beside channels-scope-1)

- **This package OWNS**: `AppShell.tsx`, `App.tsx` (route tree), `Dashboard.tsx`
  → Home, `apps/api/src/alerts/*`, `apps/api/src/email/*`, the new home/overview
  endpoint. The concurrent Channels package must not edit these; if it needs a
  nav/route change it files it in this package's court (scope board note).
- Both packages write additive Prisma migrations: **pull/rebase before creating
  a migration so timestamps order cleanly; deploys to the box are serialized.**
- HANDOFF contention: each package appends under its own scope heading.

---

## Stage 0 — Home data spine (backend, zero UI)

1. **`GET /api/home`** (admin+viewer roles): one aggregation endpoint returning
   the three zones —
   - `channels[]`: per configured channel (whatsapp, instagram) — connection
     configured + last test state; webhook last-event age (from
     `webhook_events` by `source`) + silence flag; agent gate states (comment +
     moderation gates for IG; DM gate when it exists); last inbound/outbound
     timestamps; errors last 24h (webhook `error` rows + failsafe count).
   - `attention[]`: review items waiting (by kind), failed/stuck sends
     (`ig_comments.status="failed"`, stuck campaigns), LLM `lastError` +
     failsafe 24h summary (reuse `TurnTraceAdminService`), token/credential
     expiry warnings where detectable. Each row carries a `href` to its fix
     location. Empty = "all clear".
   - `today`: messages in/out per channel, bot replies, leads captured,
     campaigns in flight, token spend today (sum `agent_turns.costUsd`).
2. **Additive migration**: index `webhook_events(source, receivedAt)`.
3. **Silence detector**: no events for N hours = warning. **Operator-amended
   at the grill (2026-07-16): evaluated 24/7 — alerts and errors do NOT
   respect business hours; and N is CUSTOMIZABLE, exposed in the Settings UI
   (Stage 2 adds the control), default 24h.** Config lives in settings
   (`home.silence`); hardcode nothing. (Stage 0 was built business-hours-aware
   before this amendment — Stage 2 flips the default evaluation to 24/7 and
   keeps the business-hours logic available as a setting option.)

**Verify**: unit tests per zone (TDD); endpoint returns honest nulls where a
signal has no data (never fake zeros); deploy loop + health check.

## Stage 1 — Home UI (replaces Dashboard at `/`)

1. **Mock round first** (`design/tiles/round-6/`, one round, zero spend): the
   three zones + the candidate-signal list from §4 rendered as proposals — the
   operator marks each candidate signal yes/no/later on the mock (median bot
   response time, reply rate, review aging, queue depth, DB/Redis, Meta
   quality, signature failures, automation rate).
2. Build Home per the reacted mock, on tokens. The WhatsApp quality/tier cards
   land inside the WhatsApp channel card (not dropped, not a separate page).
3. `Dashboard.tsx` dies; `/` renders Home. Old notifications list folds into
   the attention feed (it is one today, keep the store).

**Verify**: web tests; Playwright evidence on the published site; "10-second
read" sanity — the operator's reaction is the oracle for zone layout.

## Stage 2 — Email alerting (red tier + digest)

1. **Red tier, immediate**: channel/webhook silent during business hours;
   campaign stuck or failure spike; LLM provider hard-failing (beacon
   `lastError` fresh); health check failing. **Per-issue dedupe + cooldown**
   (one email per issue key per cooldown window, setting-tunable, default 4h)
   — the anti-spam rule is a hard requirement, not polish.
2. **Daily digest** for yellow: review-queue summary + aging, delivery
   failures, failsafe counts, spend. One scheduled job (pattern: the existing
   `reminder-tick` repeatable) at a settings-tunable hour.
3. Recipients: `alerts.recipients` (exists). Every alerted condition is also
   visible on Home — email is the pager, Home is the truth.

**Verify**: TDD the tier rules + dedupe/cooldown; induced red (mock silent
webhook) produces exactly ONE email despite repeated evaluation; digest
renders with real data to the operator's own address on their go-ahead.

## Stage 3 — The IA shell (nav restructure)

1. Nav becomes §3's list: **Home · Inbox · Agent · Campaigns · Contacts ·
   Automation · Analytics · Settings** — flat, no "WhatsApp" group label.
2. This package moves **nav entries and routes only** (redirects for moved
   paths; nothing 404s): Templates under Campaigns (⟨DECIDE⟩ below), Segments
   entry removed from nav (page stays reachable at its route until the
   Channels scope executes the real Contacts fold), Reminders stays a route
   under Automation's entry. **Deep page merges stay in their owning scopes**
   — this stage is the shell, not the organs.
3. Role gating (`canSee`) survives unchanged per entry.

**Verify**: web tests pin every old route resolves (render or redirect);
Playwright of the new nav; no dead entry for any role.

---

## Gate bar

1. Suites green per package; typecheck/lint clean; builds by exit code;
   deploy health-checked; web published.
2. Home answers "is there an issue?" from the three zones with honest empty
   states; every attention row links to its fix location; no fake zeros.
3. Silence detector + red-tier alerting provably fire on induced silence and
   provably DEDUPE (one email per issue per window); digest ships on schedule.
4. `/` is Home; Dashboard is gone; nothing it showed is silently dropped.
5. Nav matches §3; every pre-existing route renders or redirects; no nav
   entry duplicates a page's internal tabs.
6. Zero LLM spend; no gate flipped; committed+pushed; HANDOFF + board current;
   deferrals written into scope backlogs.

## Operator decisions at kickoff (the executor must not decide these)

1. **React to the Home mock** (Stage 1.1) — including yes/no/later on each
   candidate signal from §4. *(Open.)*
2. ~~Red-list reaction~~ **LOCKED 2026-07-16 (grill): the four reds confirmed,
   amended to 24/7 — silence/errors don't respect business hours.**
3. ~~Templates placement~~ **LOCKED 2026-07-16 (grill): under Campaigns.**
4. **Go-ahead for the first real emails** (test to your own address; then
   enable red tier + digest). Outward-facing — held until you say go. *(Open.)*
5. ~~Silence default~~ **LOCKED 2026-07-16 (grill): customizable in the
   Settings UI, default 24h.** Digest hour: still open (rec 08:30), also a
   Settings control.

## Stage log

- **GATE VERDICT: PASS (2026-07-17, Fable) — one trailing observation.**
  Bar-by-bar, all evidence from this date unless noted:
  1. ✅ web 507/507 green (77 files, capped forks), typecheck/lint exit 0
     (7 pre-existing warnings, none in package files), build exit 0; deployed
     2026-07-17 health `{ok,db,redis}`; web published — served bundle
     `index-CunsDKaU.js` verified live = local = VPS (Fable re-checked the
     curl himself, not just the executor's report).
  2. ✅ Home three zones live w/ true attention row — operator-collected
     evidence `docs/evidence/2026-07-17-*.png`.
  3. ✅ red fires + dedupes in production (cycle 1 raised the true
     `channel:silent:whatsapp`, cycle 2 `suppressed=1`); witnessed test email
     received. ⏳ **Trailing:** digest 08:30 send not yet observable — enabled
     Jul 17 00:45 Dubai; zero `sent=true` in the full worker log is CORRECT
     (08:30 Jul 17 hasn't occurred in any candidate scheduler timezone —
     which TZ the `hour:8,minute:30` job uses is unverified). If the next
     08:30 (Dubai, else PDT) passes with `sent=false`, reopen this bar.
  4. ✅ `/` is Home, Dashboard gone (route pins), live evidence.
  5. ✅ flat eight-entry nav, 39 route/redirect pins, live evidence.
  6. ✅ zero LLM spend; no gate flipped; the gate-time ruling (consolidate the
     two `alerts.recipients` surfaces) landed as `819c8fb`, committed+pushed,
     live. Deferrals on the board (responsive/mobile → appshell-scope-2, its
     kickoff exists).
  Open operator decision #1 (Home reaction) converts to live iteration per
  the 2026-07-16 sign-off ("Home = v1 build target, NOT locked").
  **Package closed; scope idles pending the digest observation + scope-2.**

- **Deploy + email half DONE (2026-07-17, orchestrator session):** rethink
  deployed to the VPS (8 additive migrations applied, PM2 reloaded, health
  `{ok,db,redis}`), web published (bundle verified live). Email transport
  **switched OAuth2 → Gmail app-password SMTP** (operator ruling, commit
  `8e42a3a`, TDD; gmail connection was never configured live — root cause of
  the blocked witnessed email). Operator added the gmail connection via
  Settings; witnessed test email **received (operator-confirmed)**. Red tier +
  digest **ENABLED** (`alerts.config`; revert = delete the row). Live proof:
  evaluate cycle 1 raised exactly the true `channel:silent:whatsapp` red
  (number is Meta-disabled, 4d18h silent), cycle 2 `suppressed=1` — the
  one-email-per-issue-per-window rule holds in production. Digest fires 08:30.
  **Remaining for the gate: authenticated live-UI evidence (Home `/`, Alerts
  tab, nav) + operator's Home reaction — deferred; operator was away and UI
  auth cannot be done for him.**

- **Stages 1 + 3 DONE (2026-07-16, executor + Fable-verified, merged):** Home
  v1 at `/` per round-6b (performance zone added to /api/home with TDD;
  Dashboard deleted, notifications folded with mark-read); flat eight-entry
  nav (Templates linked from Campaigns, Reminders from Automation — lightest
  pattern; 39 route/redirect pins); Settings → Alerts tab (writes preserve
  fields the form doesn't own, pinned). Fable rulings on its flags:
  consolidate the TWO `alerts.recipients` edit surfaces (Alerts tab vs
  General's quality card) at gate; "(threshold 24h)" missing on the webhook
  card = one-field /api/home addition, small follow-up; viewers keep the
  `/reminders` route but lose the nav path — inherent in the locked IA,
  operator judges at gate. **Package remaining: operator's witnessed test
  email + deploy/publish + live Playwright evidence → then the gate.**
- **Stage 2 DONE (2026-07-16, executor + Fable-verified, merged; backend
  only):** the four red rules (pure, TDD) + per-issue dedupe/cooldown (induced
  repeated red ⇒ exactly one email, pinned) + daily digest + `alerts-evaluate`
  repeatable (5 min) → internal evaluate endpoint. 24/7 evaluation default
  (`home.silence.mode: "always"`, threshold 24h) with business-hours kept as
  an option. **Red tier AND digest ship DISABLED; pinned: default settings ⇒
  zero sends.** Fable rulings: spike/freshness constants stay constants (60-min
  LLM freshness blessed); digest-on-quiet-days kept (proves the pipeline is
  alive) — operator may ask for silence later; health-red best-effort delivery
  is inherent (recipients in DB, queue in Redis) and honestly recorded.
  Remaining: Stage 1 (Home UI after the round-6 reaction) + Stage 3 (IA shell)
  + Settings UI controls + the operator's witnessed test email.
- **Stage 0 DONE (2026-07-16, executor + Fable-verified, merged to rethink):**
  `GET /api/home` three zones (TDD, +41 api tests), additive
  `webhook_events(source, received_at)` index migration, `home.silence`
  settings read. Fable rulings on the executor's open items: `lastTestAt`-only
  is fine for this package (test OUTCOME persistence = consult if wanted,
  backlog); credential-expiry warnings have no data source today → scope
  backlog, no fake rows; 30-min stuck-campaign constant fine (make it a
  setting only if Stage 2 touches it); viewer-hierarchy roles fine.

## Stop-and-consult list

- A Home signal needs data that doesn't exist and isn't one query away →
  consult before building new collection plumbing.
- Any email beyond the operator-approved test sends → stop.
- The nav restructure surfaces an affordance with no home (like 1D's
  dissolution rule) → consult, never drop silently.
- Anything needing a destructive migration → stop (additive only).
