# 04 · Signal Delivery — Alerts & Interactive Triggers

This layer turns an `Intent` into an **`Alert`** and fans it out across **web,
mobile, email, WhatsApp, and Telegram**, then collects the user's
**approve / reject** and hands an `ApprovedIntent` to the order router (doc 05).

The golden rule: **the same `Alert` object drives every channel.** Channel
modules only *render* and *transport* — they never make trading decisions.

```
Intent ──► render.ts ──► Alert ──► [ web · mobile · email · whatsapp · telegram ]
                                          │ user taps Approve / Reject
                                          ▼
                              webhooks/*.ts  ──►  resolveAlert(alertId, decision)
                                          │ (idempotent, first response wins)
                                          ▼
                                 ApprovedIntent ──► Order Router (doc 05)
```

---

## 1. Alert composition (`alerts/render.ts`)

Build one `Alert` from an `Intent` + its source `Signal`:

- **title**: emoji + pattern + symbol + timeframe — `🟢 Gartley (bullish) · AAPL 1h`
- **body**: `Signal.evidence.note` + geometry (entry / stop / target / R:R) +
  the risk line from `Intent.risk` (`$ at risk`, `% of equity`).
- **chartUrl**: an annotated PNG (pivots/bands/level drawn on candles), rendered
  server-side (lightweight-charts + headless Chromium, or mplfinance). Optional
  but hugely increases trust in the alert.
- **actions**: `Approve`, `Reject`, `Snooze 15m`, `View chart`. In auto-execute
  strategies the actions become `Cancel` / `Reduce` only.

**Canonical message body (channel-agnostic):**

```
🟢 BULLISH · Gartley · AAPL · 1h
Entry  184.20 (limit)
Stop   181.90     Target 188.60 / 191.30
R:R    1.9        Risk $230 (0.9% of equity)
Why    D at 0.786 of XA; RSI reclaimed 30
Mode   PAPER          Expires in 15m
[ ✅ Approve ]  [ ❌ Reject ]  [ 😴 Snooze ]  [ 📈 Chart ]
```

---

## 2. Delivery preferences & routing

Per-user, per-strategy config decides *where* an alert goes and whether it is
**advisory** (notify only), **approval** (wait for a tap), or **auto** (execute
within risk caps, notify after).

```ts
type DeliveryPref = {
  userId: string;
  channels: Channel[];                 // ordered by priority
  mode: "advisory" | "approval" | "auto";
  quietHours?: { fromUTC: string; toUTC: string };
  minStrength?: number;                // suppress weak signals
  perChannelTarget: Partial<Record<Channel, string>>; // token/number/chatId/email
};
```

Routing rules:
- **Approval mode** → send to *all* configured channels; the **first** channel
  to respond wins; the alert is then marked resolved everywhere (edit the
  message to "✅ Approved via Telegram").
- **Auto mode** → execute immediately (subject to risk gate), then send an
  *outcome* notification (filled/rejected) — no buttons.
- Respect `quietHours` and `minStrength`; queue or drop accordingly.

---

## 3. Channel adapters

Each adapter implements one interface so the fan-out loop is uniform:

```ts
interface AlertChannel {
  readonly id: Channel;
  send(alert: Alert, target: string): Promise<{ providerMsgId: string }>;
  /** optional: edit the message in place once resolved */
  update?(providerMsgId: string, alert: Alert): Promise<void>;
}
```

### 3.1 Web (in-app)

- **Transport:** WebSocket / SSE push to the open dashboard + the browser
  **Web Push API** (VAPID) for background notifications.
- **Interaction:** Approve/Reject buttons call `POST /alerts/:id/respond` with
  the signed action token.
- Show a live **alerts inbox** with status (pending/approved/rejected/expired).

### 3.2 Mobile push (FCM / APNs) — ✅ shipped in [`server/push.js`](../server/push.js)

- **Android/iOS:** Firebase Cloud Messaging (or APNs directly). Use a **data +
  notification** payload; on tap, deep-link into the app's alert screen with
  Approve/Reject actions (iOS notification *actions*, Android action buttons).
- Store device tokens per user; prune on `unregistered` errors.

> **Implemented.** `FcmChannel` mints an OAuth2 bearer from a service account
> (JWT assertion signed **RS256** via `node:crypto`, cached until it nears
> expiry) and posts an HTTP v1 `messages:send` with the alert + signed deep
> links in `data`. `ApnsChannel` signs a token-based **ES256** provider JWT
> (`kid`/`iss`, cached ~50 min) and posts to `/3/device/{token}` with the
> `apns-topic`. Both are **send-only**: the approve/reject decision still flows
> through the same signed-token path as the email magic link (§3.3), so a push
> can't place an order on its own. `transport` + clock are injectable, so JWT
> signing and payloads are unit-tested with no network — see
> [`test/push.test.js`](../server/test/push.test.js). Set `MP_PUBLIC_BASE_URL`
> so the deep links are absolute (a phone can open them directly).

### 3.3 Email

- **Transport:** SendGrid / Amazon SES / Postmark (SMTP or API).
- **Interaction:** buttons are **signed magic links**
  (`GET /alerts/:id/respond?d=approve&t=<HMAC>`), because email can't do
  callbacks. Token is single-use, short-TTL, bound to the alert id.
- HTML email with the chart image inline; plain-text fallback.

### 3.4 WhatsApp

Two supported providers — pick one:

**A. Meta WhatsApp Cloud API (official)**
- Send **template messages** with **interactive reply buttons** (Approve /
  Reject / Snooze). Free-form messages are only allowed inside the 24-hour
  customer-service window, so **approval prompts must be pre-approved
  templates** with button components.
- **Inbound:** Meta posts button taps to your **webhook**
  (`POST /webhooks/whatsapp`); verify `X-Hub-Signature-256` (HMAC-SHA256 with
  the app secret). Payload's button `payload`/`id` carries `alertId + action`.

**B. Twilio WhatsApp**
- `messages.create({ from:'whatsapp:+…', to:'whatsapp:+…', body, persistentAction })`
  or Content templates with quick-reply buttons.
- **Inbound:** Twilio posts to your webhook; validate the `X-Twilio-Signature`.
  Reply keywords (`YES`/`NO`) or button payloads map to the decision.

```
POST /webhooks/whatsapp                 // provider → us
  verify signature (Meta app secret / Twilio auth token)
  parse { from, alertId, action }       // from button payload or reply text
  resolveAlert(alertId, action, via:'whatsapp')   // idempotent
  reply "✅ Approved — routing PAPER order" (edit/confirm)
```

### 3.5 Telegram

- **Send:** Bot API `sendMessage` with an **inline keyboard**:
  `reply_markup.inline_keyboard = [[{text:'✅ Approve', callback_data:'appr:<alertId>'},
  {text:'❌ Reject', callback_data:'rej:<alertId>'}]]`.
- **Inbound:** set a **webhook** (or long-poll `getUpdates`); a button tap sends
  a `callback_query`. Parse `callback_data`, call `resolveAlert`, then
  `answerCallbackQuery` + `editMessageText` to show the result.
- Supports slash commands for control (§5): `/positions`, `/pause`, `/resume`,
  `/kill`, `/mode paper|live`.

```
Update(callback_query):
  [action, alertId] = data.split(':')
  ok = resolveAlert(alertId, action=='appr'?'approve':'reject', via:'telegram')
  answerCallbackQuery(id, ok ? 'Approved' : 'Already resolved')
  editMessageText(chatId, msgId, alert.body + "\n✅ Approved via Telegram")
```

---

## 4. Resolving an alert (the idempotent core)

`resolveAlert` is the single choke point for **every** channel. It must be safe
against duplicate taps across channels.

```
resolveAlert(alertId, decision, via):
  lock(alertId)                                 // redis lock / SELECT … FOR UPDATE
  a = alerts.get(alertId)
  if a.status != 'pending':
     return {alreadyResolved:true, status:a.status}   // first responder won
  if now > a.expiresAt:
     a.status='expired'; return {expired:true}
  a.status   = decision=='approve' ? 'approved' : 'rejected'
  a.respondedVia = via; a.respondedAt = now
  save(a); audit('alert.resolved', a)
  if a.status=='approved':
     enqueue ApprovedIntent(a.intentId)         // → Order Router (doc 05)
  fanoutUpdate(a)                                // edit the message on every channel
  unlock(alertId)
```

- **Security:** every actionable link/button carries an **HMAC token** bound to
  `alertId + action + userId + exp`. Reject unsigned or expired tokens. Never
  trust a channel payload without verifying the provider signature.
- **Expiry:** a scheduler expires unapproved alerts at `Intent.expiresAt` and
  edits messages to "⏰ Expired".

---

## 5. Two-way control commands

Inbound isn't only approvals — let the user *operate* the system from chat:

| Command (Telegram/WhatsApp) | Effect |
|-----------------------------|--------|
| `/positions` | list open positions + live P&L |
| `/orders` | list working orders |
| `/pause` / `/resume` | pause/resume new alerts for a strategy or globally |
| `/mode paper` / `/mode live` | switch execution mode (live requires re-auth, doc 06) |
| `/kill` | **global kill switch** — block all new orders now (doc 06) |
| `/flat SYMBOL` | close a position at market |

All commands run through the same auth (user allow-list + per-command
confirmation for destructive ones) and are **audit-logged**.

---

## 6. Reliability

- **Delivery receipts & retries:** exponential backoff per channel; if a channel
  hard-fails, fall through to the next in priority order and flag it.
- **Rate limits:** respect provider caps (Telegram ~30 msg/s, WhatsApp tiers,
  FCM batching); queue via the event bus.
- **De-dup:** an alert is sent **once per channel**; store `providerMsgId` so
  the resolved-state edit can find it.
- **Observability:** log `sent → delivered → responded` timings per channel;
  alert if approval latency or delivery failure spikes.

---

## 7. "Claude task" prompts

> **Claude task — Alert rendering + fan-out**
> Create `alerts/render.ts` (Intent+Signal → Alert with title/body/chart) and a
> `fanout(alert, pref)` that dispatches to enabled `AlertChannel`s in priority
> order. Implement `resolveAlert` (doc §4) with a Redis lock and HMAC-signed
> action tokens. **Acceptance:** two simultaneous approvals (e.g. Telegram +
> email) result in exactly one `ApprovedIntent`; the loser gets
> `alreadyResolved`; the message is edited to show who approved.

> **Claude task — Telegram + WhatsApp bots**
> Create `alerts/telegram.ts` (inline-keyboard send + webhook `callback_query`
> handler + slash commands) and `alerts/whatsapp.ts` (Cloud API template buttons
> **or** Twilio, + signed webhook). Both call `resolveAlert`. **Acceptance:**
> tapping Approve in Telegram routes a paper order and edits the message;
> unsigned/forged webhook payloads are rejected; `/kill` sets the kill flag.

> **Claude task — Email + Web + Mobile push**
> Create `alerts/email.ts` (signed magic-link buttons), `alerts/web.ts`
> (WebSocket + Web Push, `POST /alerts/:id/respond`), and `alerts/mobile.ts`
> (FCM/APNs with notification actions). **Acceptance:** each channel can approve
> an alert end-to-end; expired tokens/links are refused.

Approved intents now flow to execution:
[`05-order-execution.md`](05-order-execution.md).
