---
title: "Communications Administration"
slug: communications-administration
section: Globus Core
status: stable
description: "How Globus staff configure SendGrid trust, govern branded email templates, prove them before approval, and run unattended nurture campaigns — the administration layer that sits in front of the [Notification & Communication Model](/docs/n…"
last-updated: 2026-10-07
---

# Communications Administration

How Globus staff configure SendGrid trust, govern branded email templates, prove them before approval, and run unattended nurture campaigns — the administration layer that sits in front of the [Notification & Communication Model](/docs/notification-model) and the [Host Notifications](/docs/employer-notifications) catalogue.

> **Shipped Sep 14 2026 (Globus 30-day window Aug 17 – Sep 15 2026)**
>
> Everything on this page is grounded in the merged BackOfficePrototype commits `8eebde8cb` (SendGrid settings + template registry screens), `6786a00dd` (campaign management/detail surface), `75759689c` (nurture campaign engine), `7bd1e50a5` (branded preview / test send through the n8n adapter) and `ff11bef00` (atomic evidence stamp + operation announcement), and the staff guides those commits seeded. Later merged work on the same surfaces (through Sep 29 2026) is described in the sections below: in-Globus template authoring and rendered preview, registry-driven SendGrid template creation, brand sender-identity management, reversible suspension, revisions of live mappings, audience as part of a template's identity, the administrator self-approval override, campaign authoring, the applicant-profile Campaigns tab and email timeline, the branded welcome email and the branded invite resend. The HubSpot handoff mapping remains outside this page (proposed, not shipped).

## Overview

Five pieces make up the administration layer:

| Piece | What it owns | Route / surface |
| --- | --- | --- |
| **SendGrid Settings** | The values Globus needs to *trust* SendGrid — today the Event Webhook verification key — plus the per-environment test-send domain allow-list. | `/admin/sendgrid-config` |
| **Brand sender identities** | The verified from-address a brand (or one of its programmes) sends from. Every branded send and every test send needs one. | Brand Settings tab — `/api/brands/:idOrSlug/sender-identities` |
| **Branded Template Registry** | Which approved provider template version serves which brand / programme / audience / event (or campaign step), with a governed lifecycle. Copy is authored and previewed in Globus; the SendGrid template is created and versioned from the registry. | `/admin/email-templates` |
| **Template operations** | Proving a template under review: one rendered preview (rendered in Globus) and one allow-listed test send (performed by the n8n adapter), both recorded as approval evidence. | `/api/comms/templates/:templateId/...` |
| **Nurture campaign engine** | Catalogue-driven, email-only cadences (e.g. the Wild Packs incomplete-profile nurture) with staff authoring, a readiness gate before enabling and a recorded stop reason on every enrolment. | `/api/comms/campaigns` |

The design principle throughout: **Globus is the authority, n8n is the courier.** Globus verifies delivery-event signatures, decides which template version is routable, pins what a test send must prove, and decides who is enrolled in a campaign. n8n performs sends and reports outcomes; it never decides.

## Staff Surfaces & Permissions

| Permission | Grants |
| --- | --- |
| `comms.template.view` | Read the template registry, lifecycle history, the kept preview render and recorded operations / evidence. |
| `comms.template.author` | Register, edit and delete-if-untouched drafts, author content, open a revision of a live mapping, submit for review, render previews and request test sends. |
| `comms.template.approve` | Approve (not your own — see the override below), activate, roll back, suspend, restore and retire template mappings. |
| `comms.campaign.view` | Read the campaign catalogue, cadence, enrolment counts and readiness report. |
| `comms.campaign.manage` | Create and edit campaign definitions, and enable and disable a campaign. |
| `brands.manage` | Register, verify, revoke and deactivate a brand's sender identities (within the caller's brand scope). |
| `applicant.profile.view` | Read the Campaigns tab and the non-campaign email entries on an applicant's Communications card. |

SendGrid Settings is an administrator screen; every write requires a reason and is audited against the acting account. All routes are staff-only — none of these surfaces are exposed to the Applicant, Partner or Host portals.

## SendGrid Settings

Shipped Sep 14 2026. `/admin/sendgrid-config` records the **Event Webhook verification key** — the elliptic-curve public key from SendGrid's Signed Event Webhook settings. Globus, not n8n, is the signature authority for delivery events: every event that arrives is checked against this key and an event that does not verify is refused before anything reads it.

- **Per environment.** Development, QA and production each talk to a different SendGrid configuration, so the key is stored as data in the environment rather than baked into a release. Setting it changes only the current environment.
- **Paste whatever you have.** SendGrid shows the key as bare base64; a PEM block or wrapped copy carries the same bytes and the header, footer and line breaks are stripped. If the value cannot be parsed as an EC public key the save is refused with the reason shown against the field and nothing is written (pasting an *API* key fails for exactly this reason).
- **Reason-audited.** A reason is required before saving; the change is written to the audit log against your account, and the screen shows who last set each value and when — including database writes made outside the screen.
- **"Not set" is silent, not safe.** With no key configured nothing visibly fails; delivery states simply stop moving. Check this screen first when delivery statuses stop updating.
- **Test-send domain allow-list.** The same screen holds the per-environment list of **domains** a template test send may target. With none configured every test send is refused — there is deliberately no built-in default.
- **Server credentials (environment).** Direct transactional email from the Hub (`server/sendgrid.ts`) reads `SENDGRID_API_KEY` and `SENDGRID_FROM_EMAIL`; both must be set for the environment values to be used. If either is missing the server falls back to Replit connector credentials when that runtime is present, and otherwise sending throws `SendGrid not configured`. These are separate from the Event Webhook key above and from the branded path, which sends through the n8n adapter. A send is also suppressed silently (it returns false, not an error) while the `notification_emails_enabled` feature flag is off.
- **API.** `GET /api/admin/sendgrid/config` lists each editable setting with whether it is configured, its value when it is not a secret, and who last set it and when; `PUT /api/admin/sendgrid/config` saves one or more settings. Both need `system_settings.manage`. The PUT body is `reason` (3 to 500 characters) and `configs` (1 to 50 entries of `key` and `value`); every entry is validated before any is written, an empty value clears the setting, and a secret value is never returned or recorded in the audit payload. Failures are `400` with a flat body whose `code` is `VALIDATION_ERROR` for a malformed body, `UNKNOWN_CONFIG_KEY` for a key outside the editable catalogue (`details.editableKeys` lists the valid keys), or `INVALID_CONFIG_VALUE` when a value fails that setting's validation (`details.key` names it).

### Event Webhook Destination URL — Where SendGrid Must Point

This screen configures how Globus **verifies** an incoming SendGrid event; it does not configure where SendGrid **sends** that event. The destination URL is a separate setting, made directly in the SendGrid account's own Mail Settings → Event Webhook (or via `PATCH /v3/user/webhooks/event/settings`), and it must point at n8n's raw SendGrid event receiver workflow — never at a Globus URL directly. n8n forwards the raw bytes and signature header untouched so Globus's own ECDSA verification above still applies to the original SendGrid signature.

| Environment | Event Webhook destination URL |
| --- | --- |
| Development | `https://development2.n8n.allianceabroad.com/webhook/globus-sendgrid-events` |
| Sandbox | `https://sandbox.n8n.allianceabroad.com/webhook/globus-sendgrid-events` |
| QA | `https://qa.n8n.allianceabroad.com/webhook/globus-sendgrid-events` (n8n path convention — confirm against the live QA workflow before relying on this) |
| Production | `https://n8n.allianceabroad.com/webhook/globus-sendgrid-events` (n8n path convention — confirm against the live Production workflow before relying on this) |

> **The `globus-` prefix is easy to drop — and dropping it fails silently.** A 2026-09-16 incident found Development's Event Webhook pointed at `.../webhook/sendgrid-events` (missing the `globus-` prefix the live n8n receiver workflow actually registers). SendGrid received a `404` on every delivered/opened/clicked event and gave up — no alert, no visible failure anywhere in Globus, because the events never reached the signature-verification step this screen configures. The email itself still sent and could still be opened and clicked normally; only the engagement callback loop back into Globus was broken, so Globus's delivery/engagement state for those messages never advanced. **If delivery or engagement status looks frozen for a message that otherwise sent fine, check this URL first** — `GET /v3/user/webhooks/event/settings` against the environment's SendGrid account — before assuming an n8n or Globus code bug. Fixed and confirmed live end-to-end (webhook receipt → Globus signature verification → 200 accepted) the same day; see [n8n-workflows PR #475](https://github.com/Alliance-Strategies/n8n-workflows/pull/475).

n8n owns the receiver workflow itself (`[Globus] Delivery: SendGrid Event Webhook Receiver`) and the exact registered path; see [Automation (n8n)](/docs/automation#n8n-webhook-config) for how n8n's own webhook routing and instance hostnames are configured.

## Brand Sender Identities

`email_sender_identities` holds the from-address and from-name a branded send uses. It is written from the brand page, where the rest of a brand's configuration already lives: a sending section on the brand's Settings tab shows the brand's from-address and from-name, its verification state, any programme-scoped overrides, and the add / mark-verified / revoke actions, plus a "what each programme will use" table so a reader can see which address a given programme's templates will actually send from. Retired addresses are listed separately rather than disappearing.

| Staff route | Method | Purpose |
| --- | --- | --- |
| `/api/brands/:idOrSlug/sender-identities` | GET | The brand's identities and the per-programme resolution. |
| `/api/brands/:idOrSlug/sender-identities` | POST | Register an address. Always created **unverified**; omit `programId` for the brand-wide address, set it to scope the address to one programme. |
| `/api/brands/:idOrSlug/sender-identities/:identityId/verify` | POST | Record that the address is verified (optionally with the provider sender id). |
| `/api/brands/:idOrSlug/sender-identities/:identityId/revoke` | POST | Mark the provider authentication as no longer trusted. |
| `/api/brands/:idOrSlug/sender-identities/:identityId/deactivate` | POST | Retire the address without saying anything about the provider. |

- **Gated by `brands.manage`, scoped to the caller's brands.** An out-of-scope brand answers `404`, the same as a brand that does not exist, and scope is resolved before any lifecycle check so a foreign id cannot learn a brand's state from the refusal.
- **Retired brands are read-only.** A write to an inactive brand is refused unless a system administrator supplies the same recorded-reason override the rest of the brand page uses (a request-body envelope `override: { confirmedBy, reason }`, both non-empty strings; without it the write is refused with 409 `BRAND_INACTIVE_READ_ONLY`); an overridden write is audited as `…_while_inactive`.
- **Every mutation needs a stated reason** and is audited with the actor and the before/after projection.
- **One active address per scope.** A brand has one active brand-wide address and each programme one active override; the refusals are `BRAND_SENDER_IDENTITY_EXISTS`, `PROGRAMME_SENDER_IDENTITY_EXISTS`, `SENDER_IDENTITY_PROGRAMME_MISMATCH` (programme belongs to another brand), `SENDER_IDENTITY_NOT_FOUND` and `SENDER_IDENTITY_STATE_INVALID`. The programme-over-brand precedence shown on the screen is computed by the same resolver a real send uses.
- **Revoking or deactivating an address breaks what is queued against it.** A test send pins its sender when requested; at dispatch the pinned identity is reloaded and the send is refused (`422 PINNED_SENDER_UNUSABLE`) unless it is still active, verified and unchanged. This is deliberate: moving a send to an address nobody authorised would make the approval evidence describe something that never happened.
- **Globus never calls SendGrid to check a sender.** Verification is an attestation entered and audited here; the n8n adapter holds the credentials. With no verified sender a test send is refused (`SENDER_IDENTITY_UNAVAILABLE`).

## Branded Template Registry

Shipped Sep 14 2026. `/admin/email-templates` is the registry of branded template **mappings**: for a brand, programme, audience, event key (or campaign + step) it records the approved SendGrid template, the approved **provider version**, plus Globus's own content key, version and hash. Filter by brand or by state; the summary line shows the total registered and how many are live.

- **The version is pinned, not "whatever is live in SendGrid".** Because the approved provider version and content hash are recorded, a provider-side edit nobody approved is detectable rather than silently served.
- **Audience is part of the identity.** A mapping is for an **applicant**, **host** or **partner** reader (default: applicant). The same business event can legitimately reach two audiences with different copy and separate approvals, so audience is one of the columns that make a binding unique. Applicant is the only audience with a recipient rule today; a send addressed to a host or partner is refused by name (`AUDIENCE_RULE_NOT_CONFIGURED`) rather than guessing an address, although host and partner copy can still be authored, reviewed and activated. The delivery header records the audience when the record is written, and the person the send resolved to is recorded against it, so "who was contacted" is answerable later.
- **One live mapping per (brand, programme, audience, event, campaign, step).** The database enforces it. Activating a replacement supersedes the previous mapping instead of coexisting with it; activating while another is live returns `ACTIVE_MAPPING_CONFLICT` ("roll that one back first — two cannot be live at once"). A second draft for a binding that already has one waiting is refused (`BINDING_ALREADY_PENDING`).
- **One line per mapping.** An amended mapping is listed as one line: the record in force (or, when nothing is in service, the one that was in force last) with its earlier versions filed underneath behind a chevron ("N earlier versions"). Lineage is read from the predecessor pointer alone; an open revision draft, and a suspended successor, keep their own line because they are work outstanding rather than history. Choosing a state in the filter lists records flat, and the registered total still counts every record.
- **The provider template is optional at registration.** A registration may be created with no provider template id; the SendGrid template is created at the first content publish (see below) and recorded on the row. A mapping with no provider version cannot be previewed, test-sent or activated.
- **Roll back** withdraws the live version and restores its predecessor in its place. The route is `POST /api/comms/templates/:templateId/rollback` (`comms.template.approve`, audited as `comms.template.rolled_back`); it returns the updated registry row and reports refusals through the governance codes below. A mapping with no predecessor cannot be rolled back (`NO_ROLLBACK_TARGET`) — activate a replacement instead. Retiring a live mapping that has a predecessor is refused (`ROLLBACK_AVAILABLE`) unless the caller confirms they mean to leave the event with no branded template.
- **Drafts.** A draft (or a mapping still in review) is edited in place; any edit that changes the rendered artifact or the send's identity clears recorded evidence and returns an in-review mapping to draft. A draft whose ledger holds nothing but its own creation can be deleted outright; anything with history is retired instead (`TEMPLATE_NOT_DELETABLE`).

### Authoring Copy in Globus

Staff write the email's subject, HTML and plain-text body in Globus (`POST /api/comms/templates/:templateId/content`, `comms.template.author`). Globus performs no provider mutation itself: the authored bytes are handed to the n8n adapter, which creates an **inactive** provider version (and, for a registration with no provider template yet, the template itself) and returns its identifiers, which Globus records on the registration.

- **Only while draft or in review**, and against the version the editor read (`TEMPLATE_CHANGED_MEANWHILE` if it moved). A save while in review returns the mapping to draft and clears the recorded preview and test send, because the evidence describes the copy before.
- **Tokens must be declared.** Content referencing a merge token the registration does not declare is refused before the adapter is called (`UNDECLARED_TOKEN_IN_CONTENT`); unparseable content is refused as `TEMPLATE_CONTENT_INVALID`. The authoring catalogue (`GET /api/comms/templates/authoring-catalogue`) serves the tokens Globus can supply to the chosen audience and the event keys a send is actually matched on.
- **Nothing sends yet.** The new version only becomes sendable once the registration is approved and activated; a send pins the version the registration records.
- **Environment-named provider templates.** Every environment authors against the same SendGrid account, so a template is created once as `[DEV] <content key>`, `[QA] …` or `[PROD] …` and each added version carries the Globus content version (`… v<n>`); names are trimmed from the end to SendGrid's 100-character limit.
- **Adapter errors are named.** `PROVIDER_ADAPTER_NOT_CONFIGURED` (no n8n webhook URL in integration settings) and `PROVIDER_ADAPTER_FAILED` (nothing was changed).

### Template Lifecycle — Only Live Can Send

```
draft ──▶ in_review ──▶ approved ──▶ active (Live) ──▶ superseded ──▶ (re-activate) active
  │           │             │              │                │
  │           └─▶ draft     └─▶ draft      ├─▶ rolled_back ──▶ draft
  │                                        ├─▶ suspended ◀──▶ active (pause / restore)
  └──────────────────────────────────────── └─▶ retired  (terminal, from any state)
```

| State | Label | Meaning |
| --- | --- | --- |
| `draft` | Draft | Registered, editable, unroutable. |
| `in_review` | In review | Awaiting preview + test-send evidence and a second person's approval. |
| `approved` | Approved | Signed off. **Still unroutable** — approval is a sign-off, not a switch. |
| `active` | Live | The only state that can send. Exactly one per brand / programme / audience / event / campaign / step. |
| `suspended` | Suspended | Paused. Mail for the event is refused until an approver restores it. Reversible. |
| `superseded` | Superseded | Replaced by a newer live version; kept so a rollback has somewhere to go. Can be re-activated. |
| `rolled_back` | Rolled back | Withdrawn; predecessor restored. Returns to draft for rework. |
| `retired` | Retired | Terminal. Reachable from every non-terminal state. |

Allowed transitions are a single shared map (`TEMPLATE_LIFECYCLE_TRANSITIONS`) imported by both the service and the screen, so the UI never offers a button that would 409 and never calls a template "live" that the service treats as unroutable: `draft → in_review | retired`; `in_review → approved | draft | retired`; `approved → active | draft | retired`; `active → suspended | superseded | rolled_back | retired`; `suspended → active | retired`; `superseded → active | retired`; `rolled_back → draft | retired`.

**Suspend and restore.** Suspension (`POST /api/comms/templates/:templateId/suspend`, reason required) is the move with no price: it keeps the supersession lineage, spends no predecessor and is not permanent, unlike rollback and retirement. It is Globus-side only — SendGrid keeps the template and the version live there; what is paused is whether Globus will instruct a send at all. `POST …/restore` (reason optional) puts a suspended mapping back in force, and is refused if another mapping became live for the binding meanwhile (`ACTIVE_MAPPING_CONFLICT`) or the row carries no provider version. Both are `comms.template.approve`. Flows that look for a mapping (campaign readiness, the welcome email, the invite resend) report a paused mapping as suspended, not as missing.

The registry list `GET /api/comms/templates` (`comms.template.view`) accepts optional `brandId` and `lifecycleState` query filters. The `lifecycleState` enum covers every state except `suspended`, so filtering by `suspended` is rejected with 400.

**Activating and rolling back reach the provider first.** Activation and rollback put a version live at SendGrid through the n8n adapter *before* Globus records the move, so a provider refusal leaves the mapping where it was and is written to the ledger. If the adapter never confirms the outcome, the refusal is `PROVIDER_ACTIVATION_UNCONFIRMED` — the version may or may not be live at SendGrid, the mapping is left unchanged and the history records the uncertainty; check the template in SendGrid before trying again.

### Revising a Live Mapping

A live or suspended mapping is the record of what an approver signed off, so it is never edited in place. `POST /api/comms/templates/:templateId/revise` (`comms.template.author`) opens a **successor draft** from it: same binding (audience included), declared tokens, provider template and current copy carried over, with review evidence deliberately not carried over. The mapping in force keeps sending until the successor is approved and activated, and activation supersedes it. Publishing content from the successor adds a *version* to the same provider template rather than creating a second one. Only live or suspended mappings can be revised (`REVISION_NOT_APPLICABLE` otherwise), and a second open revision for the same binding is refused.

### Governance Refusal Codes

Governance refusals are a closed set with operator-facing copy; the screen renders the copy, never a provider error string. The main codes:

| Code | Meaning |
| --- | --- |
| `ILLEGAL_TRANSITION` | The requested state is not reachable from the current one. |
| `SELF_APPROVAL_FORBIDDEN` | The person who authored a mapping cannot approve it, unless a system administrator overrides the split. |
| `SELF_APPROVAL_OVERRIDE_REASON_REQUIRED` | A system administrator approving their own registration must give a reason (3–1000 characters); it is recorded on the governance ledger. |
| `APPROVAL_EVIDENCE_MISSING` | Approval requires a recorded successful preview **and** a recorded successful test send for this template version. |
| `PREVIEW_EVIDENCE_STALE` / `PREVIEW_EVIDENCE_NOT_RENDERED` | The recorded preview is of a different provider version, or Globus holds no render behind it. |
| `EVIDENCE_INVALID` / `EVIDENCE_CONTAINS_PII` / `TEST_RECIPIENT_NOT_ALLOWED` | Recorded evidence is incomplete or too long, would carry real recipient data, or names something other than an allow-listed domain — refused. |
| `ACTIVE_MAPPING_CONFLICT` | Another mapping is already live for this binding. |
| `BINDING_ALREADY_PENDING` | A draft for this exact binding is already waiting to be activated. |
| `NO_ROLLBACK_TARGET` / `ROLLBACK_AVAILABLE` | No earlier version to restore; or retirement was asked for a live mapping that a rollback could restore. |
| `REVISION_NOT_APPLICABLE` | A revision was asked for a mapping that is not live or suspended. |
| `TEMPLATE_NOT_EDITABLE` / `TEMPLATE_NOT_DELETABLE` / `TEMPLATE_CHANGED_MEANWHILE` | Past the point of editing, reviewed so it can only be retired, or changed by someone else while the screen was open. |
| `UNSUPPORTED_TOKEN_DECLARED` / `INCOMPATIBLE_TOKEN_TYPE` / `UNDECLARED_TOKEN_IN_CONTENT` | A declared token has no approved Globus source, a declared type its source cannot produce, or authored content uses a token the registration does not declare. |
| `PROVIDER_VERSION_UNKNOWN` | No provider version recorded, so there is nothing to render, send or activate. |
| `PROVIDER_ADAPTER_NOT_CONFIGURED` / `PROVIDER_ADAPTER_FAILED` / `PROVIDER_ACTIVATION_UNCONFIRMED` | See "Authoring Copy in Globus" and the lifecycle notes above. |
| `SENDER_IDENTITY_UNAVAILABLE` | No verified sender resolves for the brand or programme. |
| `TEMPLATE_NOT_FOUND` | Retired or removed while the screen was open — refresh the list. |

The campaign-binding codes (`CAMPAIGN_NOT_FOUND`, `CAMPAIGN_OUTSIDE_BRAND`, `CAMPAIGN_STEP_NOT_FOUND`, `PROGRAMME_OUTSIDE_BRAND`, `PROGRAMME_OUTSIDE_CAMPAIGN`) refuse a mapping that names a campaign, step or programme a send could never match.

### Administrator Override of Self-Approval

The author of a registration still cannot approve it: `403 SELF_APPROVAL_FORBIDDEN`. A **system administrator** (`system.admin`) may cross the author/approver split by supplying a reason (`selfApprovalReason`) with the approval; without one the answer is `400 SELF_APPROVAL_OVERRIDE_REASON_REQUIRED`. The reason is written into the governance ledger on the same `approved` entry (as its note plus `evidence.selfApprovalOverride`), so the exception is visible to anyone reading the history. An API key never holds the override however it is scoped, and the override does **not** cover the evidence requirement — missing preview or test-send evidence still refuses approval (`APPROVAL_EVIDENCE_MISSING`).

## Preview & Test Send

Shipped Sep 14 2026; preview reworked afterwards. Before a branded template can be approved it must be proved: rendered once as a **preview**, and sent once to a real inbox as a **test send**. Both are recorded as approval evidence while the template is `in_review`.

| Staff route | Method | Purpose |
| --- | --- | --- |
| `/api/comms/templates/:templateId/preview` | POST | Render the pinned provider version **in Globus** with synthetic token data and keep the result. Synchronous — nothing leaves Globus and no mail is sent. |
| `/api/comms/templates/:templateId/preview` | GET | The kept render for the current provider version (`comms.template.view`). |
| `/api/comms/templates/:templateId/test-send` | POST | Queue one real message to `recipientEmail`, whose **domain** must be on the environment's allow-list. |
| `/api/comms/templates/:templateId/operations` | GET | Recent operations for the template plus the domains currently allow-listed. |

- **Preview is rendered in Globus, not queued.** The adapter reads back the bytes of the pinned provider version; Globus renders them with sample values generated from the declared tokens (no applicant is read) and keeps one render per version, re-rendering replaces it. Preview can be rendered in any state, but it is recorded as approval evidence only while the template is `in_review`. The response reports whether the provider's bytes still match the hash Globus recorded at publish (`contentMatchesRegistry`), whether the provider says that version is the active one, and any tokens the content uses that the registration does not declare.
- **Test send goes through the n8n adapter.** Staff holding `comms.template.author` request it while the template is `in_review`; Globus records the request, pins the instruction, hands it to the adapter once, and records the outcome the adapter reports. That recorded outcome is the evidence.
- **Queued is not done.** Until the adapter picks a test send up the operation reads **Queued** and the panel says so rather than implying mail has gone. Emitted is not delivered.
- **Failures are reasons, never provider text.** `SENDER_IDENTITY_UNAVAILABLE` (no verified sender resolves for the brand or programme — see [Brand Sender Identities](#brand-sender-identities)), `TEST_RECIPIENT_NOT_ALLOWED` (domain not allow-listed; the refusal does not echo the address back), and adapter-reported failures from a closed `reasonCode` set (e.g. `provider_rejected`).
- **Evidence skips a template that left review.** If a template is no longer `in_review` when the result arrives, the evidence is not attached (`template_not_in_review`) — a result cannot retroactively prove a version that has already moved on.

### Adapter Callbacks & Atomic Evidence

The n8n adapter performs three calls, in order, mirroring the delivery pipeline: **claim** the operation, fetch its **instruction**, then post the **result**. Two guarantees were hardened in `ff11bef00`:

- **The evidence stamp is one transaction.** The ledger entry, the registry's evidence timestamps and the operation's own "evidence recorded" marker commit together, with the registry row locked first and the operation row second (the same lock order as the request path). A process that dies mid-way can no longer leave an operation that still claims to owe evidence — which previously made the adapter's retry append the entry a second time. Whoever loses a race between two simultaneous callbacks sees the marker and does nothing.
- **The outbox announcement rides the request's transaction.** The event that tells the adapter an operation exists is enqueued in the same transaction that creates the operation; the immediate push remains a post-commit best effort and the retry worker drains anything the push misses. A queued operation can therefore never be visible on screen while no adapter will ever hear about it.

Callback failure codes: `OPERATION_NOT_CLAIMED` (result posted before claim), `OPERATION_NOT_INSTRUCTABLE` (operation no longer in a state that can be instructed), `UNKNOWN_OPERATION`.

The three machine callbacks are `POST /api/n8n/template-operations/:operationId/claim`, `GET /api/n8n/template-operations/:operationId/instruction` (with `claimRef` as a query parameter) and `POST /api/n8n/template-operations/:operationId/result`, all restricted to an API key with the `integrations.events` scope (there is no staff session branch). The result body carries `claimRef`, `outcome` (`succeeded` or `failed`), an optional `reference` (identifier characters only, at most 200) and a `reasonCode` from the closed failure set, which is required when the outcome is `failed`. The result endpoint responds: 400 for an invalid body, 404 for an unknown operation, 409 with reason `CLAIM_MISMATCH` when another execution holds the claim, and 200 with `recorded`, `state`, `evidenceRecorded` and `evidenceSkipCode` otherwise. `recorded: false` marks a replay of an operation that was already closed, so the caller stops instead of retrying. The instruction endpoint answers 404 for `UNKNOWN_OPERATION` and 422 with a `reasonCode` for other refusals.

### Pinned at Request vs Resolved at Dispatch

An operation fixes **what the evidence is about** when it is requested — provider template, provider version, content key / version / hash, the synthetic token values, and the test-send recipient — because approval reads the evidence back against exactly those coordinates. It resolves **how the message is sent** from live configuration at dispatch — the verified sender, the unsubscribe (ASM) group, the correlation arguments and the environment — because a test send routed through a sender that has since been unverified would prove the wrong thing. Anything that cannot be resolved at dispatch is a refusal; nothing is substituted.

## Nurture Campaign Engine

Shipped Sep 14 2026. A campaign is **catalogue data, not code**: a brand-scoped definition (optionally narrowed to programmes) names a versioned eligibility predicate (`predicateKey` / `predicateVersion`, e.g. `incomplete_portal_profile` v1), a versioned Day 0 anchor (`anchorKey` / `anchorVersion`, e.g. `application_created_at` v1), and an ordered list of steps, each with its own `dueOffsetDays` and template `eventKey` (e.g. `campaign.incomplete_profile.day_3`). Nothing in the engine names a brand, a programme or a campaign; adding one means adding rows. The first shipped campaign is `incomplete_profile_nurture_wild_packs` (Day 3 / 6 / 10 / 16). It is seeded scoped to the Camp Counselor programme (`CAMP_WP`) only. An attached programme is an audience scope, and readiness requires an active template mapping for every enabled step in every attached scope, so a programme added before its mappings are approved does not widen the audience: it fails readiness and skips the step for everyone, including the programme that was ready. Widen the programme list only alongside the approved mappings.

- **Email-only.** A campaign step materialises a notification row for audit and dispatch, marked email-only: it never appears in a portal feed, an unread count or an in-app list.
- **Delivery path.** Each due step emits `external_notification.created`; n8n, subscribed to that event, resolves the step's **live** template mapping, the verified sender and the unsubscribe group and performs the send. Delivery events come back through the signature-verified SendGrid webhook above.
- **Ships disabled.** `enabled` is load-bearing — false means enrol nobody and materialise nothing. Enabling is an audited decision taken per environment. A campaign created by staff (below) is always created disabled.
- **Disable stops, it does not pause.** Disabling stops every active enrolment (reason `campaign_disabled`, with the operator's note as the stop detail). Re-enabling starts fresh enrolments; it never releases a backlog of steps.
- **Kill switches layer.** The delivery-kind switch (`external_email_delivery_enabled:<delivery kind>`, resolved through season → programme → brand → global and failing closed when absent) is the operational stop; the campaign flag is the editorial one.
- **The `portal_url` token signs the recipient in.** A template that declares `portal_url` receives a link to the applicant portal's `/onboarding?token=…` carrying a freshly minted applicant access token scoped to the application the mail is about (30-day life, outliving the day-16 step), instead of the bare portal origin — a campaign chasing an unfinished profile otherwise lands the recipient at a sign-in wall. The credential is minted only when the resolved template declares the token, a delivery with no applicant refuses the send (`MISSING_REQUIRED_TOKEN`), earlier tokens are not revoked by a reminder, and the delivery-instruction endpoint is restricted to the `integrations.events` API-key scope.
- **Provider custom arguments are a closed set with a two-part guard.** Every send carries exactly `glb_key`, `glb_env`, `glb_campaign` and `glb_step`. `assertTransportable` (length limit, no `@` address, safe-character set) is applied to every value including the generated `glb_key`. `assertNoIdentifierTell` (refuses a run of seven or more digits) is applied to the authored labels only. `glb_key` is exempt from the digit-run check because it is a generated opaque token (a UUID for a notification delivery, an `op_`-prefixed random string for a template operation) that carries no applicant data; about one UUID in eight contains seven consecutive digits by chance, and refusing those as `UNSUPPORTED_BINDING` was unrecoverable because the key is fixed for the life of the delivery, so every retry reproduced the refusal and the delivery stayed claimed with nothing sent.
- **`GLOBUS_ENV` and the `glb_env` label.** The `glb_env` argument is resolved from `CONFIG_ENV`, or from `GLOBUS_ENV` when `CONFIG_ENV` is not set: a value containing `prod` gives `production`, `qa` or `stag` gives `qa`, and `dev` or `local` gives `development`. Anything else falls back to `NODE_ENV` (`production` gives `production`, otherwise `development`), so a misconfigured host can never tag its traffic as production.

> **Confirmed live instruction wire shape (2026-09-16 incident).** A real dev enrolment's send failed because the n8n-side adapter had been built against a guessed, flat shape for `GET /api/n8n/deliveries/:deliveryId/instruction` that never matched production. The response is wrapped in the standard `{ success, data }` envelope, and `data` nests the fields the adapter needs: `recipient.email`, `template.providerTemplateId`, `sender.{fromEmail,fromName}`, a pre-built `customArgs` object to pass through unmodified, and `asm.groupId` — sent as a **string** even though SendGrid's Mail Send API requires `asm.group_id` as an **integer**, so callers must coerce it. The n8n side is now fixed and this shape is confirmed live (see [n8n-workflows PR #475](https://github.com/Alliance-Strategies/n8n-workflows/pull/475) and the updated `contracts/globus/v1/schemas/sendgrid-delivery-instruction.schema.json` in that repo, the canonical wire-schema source of truth). A second, independent bug in the same incident — a downstream node reading the wrong upstream context and losing `deliveryId` — meant the failed send was never reported back to Globus, so the delivery sat stuck at `dispatching` until it was manually recovered; the fix also corrects that so future failures resolve to a terminal `dispatch_unresolved` / `dispatch_ambiguous` outcome instead of hanging.

### Readiness Checks & Enabling

Readiness is computed at request time, per environment (ready in QA says nothing about production), and enabling is refused with `409 READINESS_FAILED` when any **blocking** check fails:

| Check | Passes when |
| --- | --- |
| `cadence_defined` | The campaign has at least one enabled step. |
| `predicate_registered` | The named eligibility predicate and version exist in the registry. |
| `anchor_registered` | The named Day 0 anchor and version exist in the registry. |
| `template_mapping` | Every enabled step resolves an **active** template mapping for the audience (an approved-but-not-live mapping fails this check). |
| `provider_version_approved` | Every active mapping carries an approved provider version. |
| `token_schema` | Every active mapping declares the tokens it expects. |
| `unsubscribe_group` | Every active mapping carries an unsubscribe (ASM) group. |
| `sender_identity` | A verified programme or brand sender resolves for every audience scope. |
| `dispatch_route` | An n8n webhook URL is configured for outbound events. |
| `dispatch_subscription` | n8n is subscribed to `external_notification.created`. |
| `provider_event_ingress` | The provider event webhook verification key is configured, so delivery outcomes can be recorded. |
| `external_email_switch` | *(Non-blocking.)* External email is switched on for the campaign's delivery kind at its brand; off means the campaign can be enabled but no step is materialised. |

The readiness report also returns a per-step template-mapping state (`active`, `registered_inactive`, `suspended` or `none`) resolved by the same lookup, so a suspended mapping is named as suspended rather than missing. Each check returns `key`, `label`, `passed`, `detail`, `blocking`; the detail names the exact gap (e.g. "No active mapping for step 1 (campaign.incomplete_profile.day_3)").

### Enrolment Stop Reasons

An enrolment stops for exactly one recorded reason; staff surfaces show the label, the raw reason is stable for reporting.

| Reason | Meaning |
| --- | --- |
| `profile_completed` | The applicant did what the campaign was asking for. |
| `portal_activated` | The applicant reached the portal the campaign exists to get them into — an account exists and they have used it. Distinct from `profile_completed`: the profile may still be unfinished. |
| `no_longer_eligible` | The eligibility predicate no longer holds — programme change, terminal status, withdrawal, left the target population. |
| `manual_stop` | A staff member stopped the enrolment. |
| `suppressed` | The recipient is unsubscribed or suppressed at the provider. |
| `campaign_disabled` | An operator disabled the campaign. |
| `cadence_complete` | Every step has been materialised. |
| `eligibility_unreadable` | Eligibility could not be evaluated, so the campaign stopped rather than guessed. |
| `dispatch_unresolved` | A send could not be resolved to a definite outcome, or dead-lettered. Never shown as sent. |
| `delivery_kind_disabled` | The kind-level kill switch was off when a step came due. |
| `readiness_failed` | Configuration the send depends on stopped resolving. |
| `campaign_expired` | The enrolment passed the campaign's maximum age. |

### Campaign Control API

| Method | Path | Permission | Purpose |
| --- | --- | --- | --- |
| GET | `/api/comms/campaigns` | `comms.campaign.view` | Catalogue with brand / programme scope, enabled flag and enrolment counts. |
| GET | `/api/comms/campaigns/authoring-catalogue` | `comms.campaign.view` | The closed lists an authoring screen picks from: registered predicates, anchors and delivery kinds. |
| GET | `/api/comms/campaigns/:campaignKey` | `comms.campaign.view` | Detail: predicate, anchor, ordered steps, `enrolmentCounts { active, stopped, completed }`, and a readiness report computed at request time. |
| POST | `/api/comms/campaigns` | `comms.campaign.manage` | Create a campaign definition (and optionally its steps). **Always created disabled**, even when complete. |
| PATCH | `/api/comms/campaigns/:campaignKey` | `comms.campaign.manage` | Edit the definition, audience and cadence. Requires `expectedUpdatedAt`. |
| POST | `/api/comms/campaigns/:campaignKey/enable` | `comms.campaign.manage` | Re-evaluates readiness inside the call; `409 READINESS_FAILED` if blocked. No body. |
| POST | `/api/comms/campaigns/:campaignKey/disable` | `comms.campaign.manage` | Stops active enrolments; optional `note` recorded as the stop detail. |

Error codes: `CAMPAIGN_NOT_FOUND`, `READINESS_FAILED`, `ENROLMENT_NOT_FOUND`, `ENROLMENT_NOT_ACTIVE` (already stopped or completed). The campaign detail screen in the Hub renders the same readiness table and stop-reason labels.

**Authoring rules.** The campaign key is 3–120 lower-case letters, digits and underscores starting with a letter. The predicate and anchor must be registered (`PREDICATE_NOT_REGISTERED`, `ANCHOR_NOT_REGISTERED`, `400`) and any narrowing programmes must belong to the campaign's brand (`AUDIENCE_NOT_RESOLVABLE`, `400`). The campaign key, brand and delivery kind cannot be changed after creation (`CAMPAIGN_IDENTITY_IMMUTABLE`) because templates, provider suppression and the kill switch bind to them by value; an existing key is refused (`CAMPAIGN_KEY_TAKEN`); a stale edit is refused (`CAMPAIGN_STALE`); an invalid cadence is `CADENCE_INVALID`. Step numbers are permanent: new steps take the next unused number, a step a template mapping is bound to cannot be removed (`STEP_BOUND_TO_TEMPLATE`), and a step that has already scheduled or sent mail cannot be removed (`STEP_IN_USE`) — disable it instead. Editing a live campaign does not reschedule sends already scheduled; the response reports how many were left unchanged (`scheduledDeliveriesUnchanged`).

### Campaigns on the Applicant Profile

The applicant profile's Communications card has a read-only **Campaigns** tab (`GET /api/applications/:appId/campaign-communications`, `applicant.profile.view`, `404` for an unknown application). It lists the enrolments for the application with their step deliveries and the delivery header's state, newest anchor first. Provider acceptance (the 202) and delivery to the recipient are shown as two separate timestamps and never collapsed into one "sent"; stop reasons use the shared labels; provider error text is never read. It has no send, retry, skip or edit — everything that can cause a send stays in the campaign control plane.

## Transactional Branded Email

The branded path is not only for campaigns. Two transactional emails are queued by Globus itself onto the same registry → n8n → SendGrid delivery rail, and both are **email-only** records (no portal feed entry) gated by the transactional external-email switch.

- **Welcome email — once per applicant.** When an applicant's **first** application is created Globus queues one branded welcome (event key `applicant.created`, which is at once the notification type, the template key and the `event_key` staff register the template under). Nothing is written, and the applicant's single welcome is not consumed, when the send could not resolve: the skip reasons are `already_sent` (a partial unique index arbitrates), `no_mapping`, `mapping_suspended`, `switch_off`, `not_first_application`, `unknown_application` and `no_brand`.
- **Transactional external-email switch.** `external_email_delivery_enabled:transactional` is a seeded application setting (create-only seed plus a guarded migration) that **ships disabled**; arming it is an audited settings change, at global scope or as a per-brand / per-programme override. An absent or unreadable setting means off. The switch gates enqueue only: mail already queued when it is switched off can still be dispatched.
- **Portal invite resend.** A staff **resend invite**, and a **change-email** with resend, now queue a branded email (`applicant.invite_resent`) in the same transaction that replaces the applicant's access token. Each intentional resend creates a new delivery; the welcome's once-only rule does not apply. The earliest application supplies the brand and programme; the approved `applicant.created` mapping is reused, and the send is skipped with a named reason (`no_application`, `no_brand`, `switch_off`, `no_mapping`, `mapping_suspended`, `missing_invite_link`) — the last when the mapping's template does not declare the invite link. The link in the mail is the exact staff-minted redemption URL (stored encrypted in notification metadata, never in audit or outbox data), not a second credential. The API success means the token was reissued, not that the provider accepted mail. The Hub routes are `POST /api/admin/applicants/:applicantId/resend-invite` (`applicant.manage`, or the narrower `invite.resend` support key; 409 `APPLICANT_ALREADY_ACTIVATED` once the applicant has activated the portal) and `POST /api/admin/applicants/:applicantId/change-email` (`applicant.manage`; body `email` and optional `resendInvite`, default false, which is what queues the email). If the enqueue itself fails the transaction rolls back and the request returns 500, so the previous invite is not revoked and no success is reported. On success the audit row (`resend_portal_invite` or `change_applicant_email`) carries the `inviteEmail` outcome: `queued` with the notification, delivery and event ids, or `skipped` with the reason.
- **Non-campaign email on the applicant timeline.** Outbound email that is not a campaign step (the welcome, the invite resend) appears on the applicant profile's Communications timeline from `GET /api/applications/:appId/email-communications` (`applicant.profile.view`, `404` for an unknown application): Globus's record of when it was queued, the delivery kind, the provider's accepted/delivered/terminal timestamps, engagement counts (aggregate only) and the exact approved artifact used, newest first and capped at 200. "Non-campaign" means no campaign step delivery points at the notification, so an email can never fall between this list and the Campaigns tab. Applicant-addressed mail only; read-only.
- **Host organization email audit.** `GET /api/admin/partners/organizations/:orgId/email-audit` (`audit_log.view`; API keys need `partners.read`) lists the notifications recorded for a host organization (recipient type `host`, recipient id the organization id), which are kept in `notifications` and `notification_delivery` rather than `audit_logs`. Each row carries the type, title, message, severity, read state, source event type and the latest delivery's channel, status, provider, sent and delivered times and attachment status. Paged with `page` (default 1) and `limit` (default 50, clamped to 1 to 100); the result includes the `total`.

## Relationship to the Notification Model

- The [Notification & Communication Model](/docs/notification-model) describes *what* is sent to whom and through which channel; this page describes how staff *administer* the SendGrid-backed branded path. Its channel matrix routes applicant email through HubSpot — nurture campaigns are the first applicant-facing branded email delivered on the Globus template registry → n8n → SendGrid path instead, and the matrix carries a note to that effect.
- Campaign step event keys (`campaign.*`) and transactional event keys (`host.*` for hosts — see [Host Notifications](/docs/employer-notifications)) resolve through the same template registry; a step or event with no **live** mapping is unroutable.
- The n8n adapter contract for content publication, template activation, test sends and campaign dispatch is part of the [Automation (n8n)](/docs/automation) boundary: n8n claims work Globus has recorded, executes it, and reports back; it holds no state of its own about templates or campaigns.
- **In-app notification admin routes.** Separate from the branded email path, staff can create notifications directly. `POST /api/admin/notifications/send` (`notifications.send`) needs `applicantId`, `subject` and `body` (`applicationId` and `severity` are optional, severity defaults to `info`); it creates an applicant notification from the `manual_message` template and best-effort emits a `staff.send_message` event. `POST /api/notifications/bulk-send` (`notifications.manage`) takes `applicantIds` (at least one), `templateId` (a template id or key; 404 `NOT_FOUND` when unknown) and optional `variables` that replace `{{key}}` placeholders in the template title and message, creates one notification per applicant and returns `sent`, `failed` and per-applicant `results`. `POST /api/admin/notification-templates` (`notifications.manage`) creates a template (`templateKey` required, `titleTemplate` and `messageTemplate` optional). Scheduled notification jobs can be run on demand with `POST /api/admin/notification-jobs/:jobName/run` or `POST /api/admin/notification-jobs/run` (job name in the body), both `notifications.manage`; the response reports `scannedCount`, `emittedCount`, `skippedDedupe`, `errors` and `durationMs`, and an unknown job name is 400 `INVALID_JOB_NAME` with `details.availableJobs` (the same list is returned by `GET /api/admin/notification-jobs`).
