# LLM and transactional events

Pharos keeps product facts deterministic and uses the LLM only for bounded
language generation. Source applications calculate recipients, rankings,
prices, URLs, and other trusted values. Pharos validates those values, asks the
model for copy when enabled, renders trusted templates, and delivers through
SES.

## Project brand brain

Each project can configure:

- LLM enabled/disabled
- provider and model (Gemini is currently supported)
- brand voice
- target audience
- additional vocabulary and safety instructions

All model calls use `lib/ai.ts`. The gateway enforces structured JSON output and
records model, prompt version, latency, status, and a SHA-256 input hash in
`ai_generations`. Raw prompts and recipient PII are not stored in the audit.

## Project API keys

Create a scoped key after the project exists:

```bash
pnpm api-key:create:prod chutometro "Chutometro production" events:write,events:send
```

`events:write` submits and previews events; `events:send` is what actually
delivers. The split mirrors `campaigns:preview` vs `campaigns:send` — agent
keys carry `events:write` only, so an agent can render any transactional email
and cannot deliver one. A server that sends real mail needs both.

The raw key is printed once. Pharos stores only its SHA-256 hash. Put the raw
value in the source application's secret store as `PHAROS_API_KEY`. Add
`--expires-days <n>` for a key that should self-revoke.

## Competition-finished event

`POST /api/v1/events` defaults to preview mode. Set `deliveryMode: "send"` only
after reviewing a preview.

```json
{
  "projectId": "chutometro",
  "eventType": "competition.finished",
  "idempotencyKey": "WC:2026:final-ranking:user-id",
  "notificationCategory": "competition_results",
  "deliveryMode": "preview",
  "recipient": {
    "email": "person@example.com",
    "firstName": "Dan",
    "language": "pt-BR"
  },
  "data": {
    "competition": "Copa do Mundo 2026",
    "season": 2026,
    "pools": [
      {
        "name": "Família",
        "url": "https://chutometro.com.br/b/ABC123?aba=ranking",
        "position": 2,
        "memberCount": 8,
        "points": 24,
        "exactScores": 4,
        "winnerName": "João",
        "winnerPoints": 28
      }
    ]
  }
}
```

Authentication is `Authorization: Bearer phk_...` (a project key), or an
organization key (`pha_...`) whose organization owns the project. Preview
requires `events:write`; `send` mode requires `events:send`, persists the event
under the `(project, idempotencyKey)` unique key and queues delivery through
QStash. Repeating the same request does not send twice.

Participants are attached to the `product-notifications` list on first event.
An existing unsubscribe is honored and never silently reversed.

## Generic events

Any `eventType` other than `competition.finished` goes down the generic path:
the caller supplies finished copy and Pharos owns delivery, idempotency,
suppression, and recording.

One asymmetry, on purpose: on the generic path a **missing** `deliveryMode`
means `send` — generic callers predate the field and their contact-form and
alert mail must keep flowing. An explicit `deliveryMode: "preview"` echoes the
composed email back without persisting or delivering anything. Delivering
requires `events:send` either way; preview needs only `events:write`.

```json
{
  "projectId": "d-fit",
  "eventType": "contact.form_submitted",
  "idempotencyKey": "sha256-of-payload-and-time-bucket",
  "notificationCategory": "operational",
  "recipient": { "email": "contato@dfit.app" },
  "subject": "✔ Contato do D-Fit — Maria",
  "html": "<p>…</p>",
  "text": "…",
  "replyTo": "maria@example.com"
}
```

`notificationCategory` decides both list handling and delivery mode:

- `product` — mail to a user. Subscription status is honored, a
  List-Unsubscribe URL is attached, and delivery is queued through QStash
  (response `status: "queued"`); QStash retries transient failures.
- `operational` — mail to a team inbox, triggered by a human waiting on an
  interactive action. No contact record, no unsubscribe handling, and delivery
  is **synchronous**: a 2xx with `status: "sent"` means SES accepted the
  message, and a 502 with `status: "failed"` means it did not go out — surface
  that to the person instead of acknowledging the send. A stray unsubscribe
  must never be able to mute an internal alert, which is why operational mail
  skips contact handling entirely.

Every delivered event is recorded in `send_events` with
`source: "transactional"` and the event type as its grouping key, so it appears
in the project's Sends view with delivery and open state.
