# HeliumRises Connect Agent Guide

HeliumRises Connect is the governed, multi-tenant newsletter and email-automation
service for the GFA ecosystem.

- Canonical origin: `https://connect.heliumrises.com`
- API base: `https://connect.heliumrises.com/api/v1`
- OpenAPI 3.1: `https://connect.heliumrises.com/openapi.json`
- Readable API reference: `https://connect.heliumrises.com/docs`
- Authentication authority: GFAVIP Wallet
- Human sign-in: GFAVIP Wallet Secure Code Exchange
- Headless agent sign-in: PowerLobster Identity → GFAVIP Wallet

## Available now versus planned

Available now:

- Wallet-authenticated human and PowerLobster agent access;
- explicit workspace memberships and role/capability authorization;
- contacts, typed fields, provenance, audiences, consent, and suppressions;
- centralized send-eligibility decisions and preference links;
- sender domains, Mailgun secret references, DNS/provider readiness, and sender identities;
- versioned templates, sanitized email content, preview, and campaign drafts;
- gated allowlisted test sends and controlled provider-event rehearsals;
- immutable campaign scheduling behind closed production gates;
- Mailgun event processing, reporting, operational controls, launch preflight, and
  isolated synthetic capacity rehearsals;
- protected generic CSV uploads, reusable mappings, aggregate-only dry runs,
  checksum-bound owner/admin approval, suppression-first apply, reconciliation,
  and automatic payload cleanup;
- workspace-isolated automation drafts, immutable graph versions, typed triggers,
  nodes and edges, dependency validation, previews, and review-ready state.
- checksum-verified Phase 5C legacy reconstruction into disposable review
  drafts, plus an owner-approved, GFA-only production draft loader; recovered
  content remains outside Git.
- Durable automation enrollments, scheduled database waits, attempts, events,
  eligibility decisions, pause/cancel/retry/replay, and operator observability.
  An automation runs once it is published; `audience_subscribed`, `tag_added`
  and `tag_removed` enrol contacts, and a `send_email` step records an
  automation send that the delivery worker then hands to the provider under the
  same gates a campaign passes.
- Phase 5E deterministic local-only automation-runtime rehearsal using a
  disposable database, simulated clock, restart recovery, redacted evidence,
  and an explicit zero-external-call reconciliation.
- Operator-changeable platform gates. Rate limiting, automations, production
  sending, test sends and synthetic rehearsals are database rows resolved as
  `environment ceiling AND stored row`, changed from the console without a
  redeploy. They can flip while your run is in progress -- see "Platform gates
  move underneath you".
- Campaign targeting across several audiences at once, narrowed by tag with
  `any`/`all` matching, frozen into the scheduled snapshot. **Console only for
  writes**; the API creates single-audience drafts and reads any shape back.
- A workspace tag catalogue: registration, bulk apply and remove across
  resolved audiences, and rename/merge. **Console only.** Capped at 50 tags per
  contact.
- Console administration of operators, organizations and workspaces, including
  role changes and a workspace sending off switch.
- Editing and deleting Mailgun connections, sending domains and sender
  identities, and deleting audiences and unsent campaigns. **Console only.**
  Every delete is refused while anything still references the record.
- External subscription-source ingestion, authenticated by a per-source token
  instead of an operator bearer token. Creating a source and issuing,
  rotating or revoking its credential is **console only**.

Planned, not currently available:

- AI-content-generation endpoints;
- application credentials independent of GFAVIP user/agent identity;
- dynamic segments, privacy export/deletion, agent-card discovery.

Automation execution is **no longer planned; it is live.** A published
automation enrols contacts and its `send_email` steps hand real messages to the
delivery worker under the same gates a campaign passes. The `automation_enabled`
platform gate is the kill switch.

`edit_automations` governs authoring. Publishing and pausing are console-only
today -- see "What the API cannot do". `generate_ai_content` remains reserved;
its presence in the capability table does not mean an AI-authoring route exists.

## Actions that can start a real email sequence

Read this before writing anything. Two ordinary-looking write endpoints are
automation triggers, so a call an agent thinks is bookkeeping can put a contact
into a published sequence that sends real email.

| Call | Capability | Trigger it fires |
|---|---|---|
| `POST /audiences/{audience_id}/subscribe` | `upsert_contacts` | `audience_subscribed` |
| Console tag apply / remove | `upsert_contacts` | `tag_added` / `tag_removed` |

The trigger fires only for automations whose current version is `published` and
whose trigger type matches. If the workspace has no published automation, these
calls do exactly what they appear to do.

**Before subscribing contacts in bulk, check what is published:**

```bash
curl -fsS "${WORKSPACE_API}/automations" -H "${AUTH_HEADER}" |
jq '[.automations[] | select(.status == "published") | {id, name, trigger: .trigger_type}]'
```

An empty array means subscribing is inert. A non-empty one means every contact
you subscribe to a matching audience may begin receiving mail within a minute.

One asymmetry worth knowing: `POST /contacts/upsert` accepts a `tags` array and
writes it directly to the contact, which does **not** fire `tag_added`. Only the
console's tag apply/remove path does. Do not rely on this to move contacts
quietly -- it is an inconsistency in the current implementation, not a
guarantee.

## Five-minute headless-agent quick start

Requirements: `curl`, `jq`, a PowerLobster agent API key, and an explicit Connect
workspace membership for that agent's GFAVIP username.

### 1. Authenticate without printing credentials

```bash
read -rsp "PowerLobster API key: " POWERLOBSTER_API_KEY
printf "\n"

POWERLOBSTER_IDENTITY_TOKEN="$(
  curl -fsS -X POST https://powerlobster.com/api/agent/identity-token \
    -H "Authorization: Bearer ${POWERLOBSTER_API_KEY}" |
  jq -er '.identity_token'
)"
unset POWERLOBSTER_API_KEY

GFAVIP_SSO_TOKEN="$(
  curl -fsS -X POST https://wallet.gfavip.com/api/auth/powerlobster \
    -H "Content-Type: application/json" \
    --data "$(jq -cn --arg token "${POWERLOBSTER_IDENTITY_TOKEN}" '{token:$token}')" |
  jq -er '.sso_token'
)"
unset POWERLOBSTER_IDENTITY_TOKEN
```

Do not echo either token, place it in a URL, or commit it. Never send a PowerLobster API key
or identity token to Connect.

### 2. Inspect the live identity response

```http
GET https://connect.heliumrises.com/api/v1/me
Authorization: Bearer <GFAVIP_SSO_TOKEN>
```

```bash
CONNECT_ORIGIN="https://connect.heliumrises.com"
AUTH_HEADER="Authorization: Bearer ${GFAVIP_SSO_TOKEN}"

curl -fsS "${CONNECT_ORIGIN}/api/v1/me" \
  -H "${AUTH_HEADER}" |
jq
```

The actual response shape is:

```json
{
  "user": {
    "gfavip_user_id": "stable-wallet-user-id",
    "username": "pl-agent-name",
    "identity_type": "agent",
    "platform_role": null
  },
  "auth_method": "bearer",
  "csrf_token": null,
  "workspaces": [
    {
      "organization": "gfa-community",
      "workspace": "gfa-community",
      "role": "editor",
      "is_archived": false
    }
  ]
}
```

There is no top-level `memberships` field. The membership array is `workspaces`.

### 3. List assigned workspaces

```bash
curl -fsS "${CONNECT_ORIGIN}/api/v1/workspaces" \
  -H "${AUTH_HEADER}" |
jq
```

Select only an organization/workspace pair returned by this endpoint:

```bash
ORGANIZATION="gfa-community"
WORKSPACE="gfa-community"
WORKSPACE_API="${CONNECT_ORIGIN}/api/v1/organizations/${ORGANIZATION}/workspaces/${WORKSPACE}"
```

### 4. Look up a contact

Requires `view`.

```bash
CONTACT_EMAIL="person@example.com"
curl -fsS --get "${WORKSPACE_API}/contacts/lookup" \
  -H "${AUTH_HEADER}" \
  --data-urlencode "email=${CONTACT_EMAIL}" |
jq
```

Save the returned `contact.id`:

```bash
CONTACT_ID="replace-with-contact-id"
```

### 5. Inspect an audience

Requires `view`.

```bash
AUDIENCE_ID="replace-with-audience-id"
curl -fsS "${WORKSPACE_API}/audiences/${AUDIENCE_ID}" \
  -H "${AUTH_HEADER}" |
jq
```

### 6. Check centralized eligibility

Requires `view`. This is read-only and never contacts Mailgun or a recipient.

```bash
curl -fsS \
  "${WORKSPACE_API}/contacts/${CONTACT_ID}/eligibility/${AUDIENCE_ID}" \
  -H "${AUTH_HEADER}" |
jq
```

Treat `eligible: false` and every `reasons` value as authoritative. Do not recreate
or bypass eligibility logic in an agent.

### 7. Create a template and immutable content version

Requires `edit_content`. These calls are database-only and send nothing.

```bash
TEMPLATE_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/email-templates" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data '{
      "name": "Agent quick-start template",
      "slug": "agent-quick-start",
      "description": "Draft-only example"
    }' |
  jq -er '.email_template.id'
)"

CONTENT_VERSION_ID="$(
  curl -fsS -X POST \
    "${WORKSPACE_API}/email-templates/${TEMPLATE_ID}/versions" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data '{
      "subject": "Hello {{ first_name }}",
      "preheader": "Draft preview",
      "html": "<p>Hello {{ first_name }}</p><p><a href=\"{{ unsubscribe_url }}\">Unsubscribe</a></p>",
      "plain_text": "Hello {{ first_name }}\n\nUnsubscribe: {{ unsubscribe_url }}",
      "personalization_defaults": {"first_name": "there"}
    }' |
  jq -er '.content_version.id'
)"
```

Slugs are unique per workspace. Change the example slug when repeating it.

### 8. Create a campaign draft

Requires `edit_content`. Draft creation schedules and sends nothing.

```bash
SENDER_ID="replace-with-existing-sender-identity-id"
CAMPAIGN_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/campaign-drafts" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data "$(jq -cn \
      --arg name "Agent quick-start draft" \
      --arg audience_id "${AUDIENCE_ID}" \
      --arg sender_identity_id "${SENDER_ID}" \
      --arg content_version_id "${CONTENT_VERSION_ID}" \
      '{
        name:$name,
        audience_id:$audience_id,
        sender_identity_id:$sender_identity_id,
        content_version_id:$content_version_id,
        notes:"Draft only; do not schedule"
      }')" |
  jq -er '.campaign.id'
)"
```

### 9. Run or inspect launch preflight

Running preflight requires `manage_senders`; `view` can inspect the latest result.
An editor such as the launch Arthur Blaze identity can inspect but cannot run it.
Preflight sends no email and opens no gate.

```bash
# Read latest result (view)
curl -fsS "${WORKSPACE_API}/operations/preflight" \
  -H "${AUTH_HEADER}" |
jq

# Create a new retained report (manage_senders)
curl -fsS -X POST "${WORKSPACE_API}/operations/preflight" \
  -H "${AUTH_HEADER}" \
  -H "Content-Type: application/json" \
  --data '{}' |
jq
```

When finished, remove the Wallet token from the shell:

```bash
unset GFAVIP_SSO_TOKEN AUTH_HEADER
```

### 10. Prepare a protected CSV import

Requires `upsert_contacts`. Uploading and dry-running never contact Mailgun and
never enable sending. Use only an approved local file; do not print its rows.

```bash
CSV_PATH="/absolute/protected/path/contacts.csv"
AUDIENCE_ID="replace-with-audience-id"

IMPORT_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/imports/csv" \
    -H "${AUTH_HEADER}" \
    -F "file=@${CSV_PATH};type=text/csv" \
    -F "audience_id=${AUDIENCE_ID}" \
    -F "source_label=Approved event registration export" |
  jq -er '.import.id'
)"
```

Configure an explicit mapping and queue the zero-write analysis:

```bash
curl -fsS -X PATCH "${WORKSPACE_API}/imports/csv/${IMPORT_ID}/mapping" \
  -H "${AUTH_HEADER}" \
  -H "Content-Type: application/json" \
  --data '{
    "mapping": {
      "email": "Email",
      "first_name": "First Name",
      "status": "Status"
    },
    "consent_policy": "status_column",
    "policy": {
      "consent_source": "documented-event-registration",
      "wording_version": "event-form-v2",
      "evidence_reference": "approved-registration-export",
      "consent_basis": "Documented newsletter opt-in field.",
      "status_values": {
        "subscribed": "subscribed",
        "unsubscribed": "audience_unsubscribe",
        "bounced": "hard_bounce"
      },
      "default_status": "review_required",
      "static_tags": ["event"]
    }
  }' |
jq
```

Poll `GET /imports/csv/{id}` until `ready_for_review`. Reports contain aggregate
counts only. `editor` can prepare the import but cannot approve apply.
`owner` or `brand_admin` must independently submit the exact confirmation
returned by the console/OpenAPI workflow. Approval and apply remain forbidden
while any test-send, sender, workspace, activation, or global production gate is
open.

### 11. Create a non-executable automation draft

Requires `edit_automations`. This writes one immutable workflow version and
makes zero provider or webhook calls. It cannot enroll a contact or execute a
step.

```bash
AUTOMATION_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/automations" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data '{
      "name": "Agent authoring shell",
      "slug": "agent-authoring-shell",
      "description": "Non-executable Phase 5B example",
      "definition": {
        "trigger": {"type": "manual", "config": {}},
        "start_node": "finish",
        "nodes": [
          {"key": "finish", "type": "end", "config": {}}
        ],
        "edges": []
      },
      "change_notes": "Initial shell"
    }' |
  jq -er '.automation.id'
)"

curl -fsS "${WORKSPACE_API}/automations/${AUTOMATION_ID}" \
  -H "${AUTH_HEADER}" |
jq '.automation | {
  status,
  current_version,
  authoring_only,
  execution_available,
  activation_available,
  selected_version: {
    checksum: .selected_version.definition_checksum,
    validation: .selected_version.validation,
    preview: .selected_version.preview
  }
}'
```

`ready_for_review` is a checksum-bound documentation state. It is not
publication, activation, approval to contact recipients, or permission to
execute.

The runtime exposes governed read APIs for observability, plus synthetic
enrollment and per-enrollment control. An automation must be `published` before
anything enrols into it: `draft`, `ready_for_review`, `paused` and `archived`
all refuse.

**Publishing and pausing are console-only.** There is no
`POST /automations/{id}/publish` or `/pause` on the API. An agent can author a
version, take it to `ready_for_review`, archive it and restore it, but a human
in the console makes it live. Publishing is reachable from `ready_for_review`
and from `paused`; pausing stops new enrollments and leaves anyone mid-sequence
where they are.

Real enrollment is not an API operation either. Contacts enter a published
automation only through its trigger — audience subscription or a console tag
change. The API's `synthetic-enrollments` route is rehearsal infrastructure and
is described below.

Rehearsal enrollment is still its own path and still requires an archived
`synthetic-rehearsal-*` workspace, a `.invalid` contact bound to the same
rehearsal ID, and an `Idempotency-Key`. A `.invalid` contact with no rehearsal
context is refused outright.

The `automation_enabled` platform gate is the kill switch for all of it. It has
no environment ceiling, defaults on, and closing it stops new enrollments and
every step in flight without a redeploy.

The Phase 5E CLI creates its own temporary SQLite database and synthetic
welcome-course structure, then deletes the database after the rehearsal. It is
hard-blocked in production, never loads the recovered GFA course, and emits
only aggregate redacted evidence. It is acceptance infrastructure, not an API
or an activation path.

Protected Phase 5C reconstruction is intentionally not exposed through the
public API. Its synthetic CLI rejects production. The separately approved
production loader is restricted to GFA Community, requires the workspace
owner, exact approval reference and confirmation, accepts only workspace-local
disabled dependencies, and defaults to a savepoint dry run unless `--commit`
is supplied. Neither path creates contacts, enrollments, execution state, or
external calls. Agents must not copy recovered subjects, bodies, URLs, sender
metadata, assets, or the handoff into Git or prompts.

## Authentication and authorization rules

An agent authenticates as its own GFAVIP identity. Its owner or squad context does
not grant permissions in HeliumRises Connect.

1. `POST https://powerlobster.com/api/agent/identity-token` with the agent API key.
2. `POST https://wallet.gfavip.com/api/auth/powerlobster` with the identity token.
3. Use the returned Wallet `sso_token` as a Connect bearer token.
4. Connect validates it server-to-server with Wallet.
5. Connect applies its own explicit workspace membership and role.

All private API requests require:

```http
Authorization: Bearer <GFAVIP_SSO_TOKEN>
Accept: application/json
```

Human browser sessions may also call the API. A mutating request authenticated by
session cookie additionally requires `X-CSRF-Token`. Bearer-authenticated requests
do not use CSRF tokens.

An unassigned workspace is intentionally returned as `404`. Capabilities are
derived from the caller's local role, never GFAVIP tier, human owner, or
PowerLobster squad.

## Roles and capabilities

| Capability | owner | brand_admin | editor | analyst |
|---|:---:|:---:|:---:|:---:|
| `view` | yes | yes | yes | yes |
| `upsert_contacts` | yes | yes | yes | no |
| `approve_imports` | yes | yes | no | no |
| `edit_content` | yes | yes | yes | no |
| `send_test` | yes | yes | yes | no |
| `schedule_campaign` | yes | yes | yes | no |
| `manage_senders` | yes | yes | no | no |
| `pause_delivery` | yes | yes | no | no |
| `resolve_suppressions` | yes | yes | no | no |
| `manage_subscription_sources` | yes | yes | no | no |
| `manage_team_roles` | yes | yes | no | no |
| `manage_audiences` | yes | yes | no | no |
| `edit_automations` | yes | yes | yes | no |
| `generate_ai_content` | planned API | planned API | planned API | no |

Archived workspaces remove `send_test`, `schedule_campaign`, and `pause_delivery`
even when the role normally has them.

`manage_audiences` and `manage_team_roles` are real and enforced, but neither has
an API route today: both gate console operations only. Holding them changes
nothing about what a bearer token can do.

`manage_subscription_sources` is the same shape: it gates console provisioning
of subscription sources (create, issue/rotate/revoke credential) and has no API
route. It does not gate the two subscription-source endpoints in "Subscription
sources" below — those check a source token instead, not this capability or any
operator role.

Some operations need **workspace ownership on top of** a capability, so a
`brand_admin` holding every capability above is still refused: activating a
sender identity, activating workspace sending, and running a synthetic
rehearsal, which also needs platform ownership. Renaming or merging a tag is
gated on workspace ownership alone, with no capability involved — and it is
console-only.

## What the API cannot do

These exist in the product and have **no API route**. An agent should not
attempt them, and should tell its operator that a human in the console is
required rather than retrying or working around them.

| Operation | Where it lives | Why it is not on the API |
|---|---|---|
| Publish or pause an automation | Console | Making a sequence live is a human decision |
| Register, apply, remove, rename or merge a tag | Console | Bulk tagging is an automation trigger; rename is a workspace-owner operation |
| Create a multi-audience or tag-filtered campaign draft | Console | The API writes single-audience drafts only; see below |
| Delete an audience or an unsent campaign | Console | `manage_audiences`, plus reference checks |
| Edit or delete a Mailgun connection, sending domain or sender identity | Console | `manage_senders`, plus reference checks |
| Change a platform gate | Console, platform owner | Kill switches are not automatable |
| Read or acknowledge an operational alert | Console, platform owner | Acknowledgement is a person claiming a problem; an agent cannot make that claim |
| Read the platform audit log | Console, platform owner | It records what agents did, among others |
| Add an operator or change a role | Console | `manage_team_roles` |
| Turn workspace sending off | Console | Deliberately a human action |
| Create a subscription source, or issue, rotate or revoke its credential | Console | `manage_subscription_sources` |

**Campaign draft targeting.** `POST /campaign-drafts` and
`POST /campaign-drafts/{id}/versions` accept a single `audience_id` and cannot
set a tag filter. Both return the full shape:

```json
{"draft": {"audience_id": "aud_1", "audience_ids": ["aud_1"], "...": "..."}}
```

`audience_ids` is always the authoritative list. `audience_id` is a convenience
that is populated only when the draft targets exactly one audience and is
**`null` for a multi-audience draft** — which the console can create and the API
can read. Read `audience_ids`; treat `audience_id` as legacy.

## Subscription sources

A subscription source lets an external application — MemoUpdate forms today — add
contacts to specific audiences in one workspace. Two endpoints serve it.

**These use a different credential from everything else in this guide.** They
authenticate with a source token that looks like `hrs_src_…`, issued per source
in the console. Your operator bearer token does **not** work on them, and a
source token does not work anywhere else. Required capability for both:
source token (not an operator capability) — neither is gated by
`ROLE_CAPABILITIES`, so they do not appear in "Implemented endpoint/capability
matrix" below, which covers Wallet-bearer routes only.

- `POST /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources/<slug>/events`
  — submit one consented contact. The caller asserts consent; Connect records
  the claim and does not re-verify it. Idempotent on the caller's key, so a
  repeat returns the stored receipt rather than subscribing anyone twice.
- `GET /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources/<slug>/audiences`
  — list the audiences this source is allowed to write to. Returns only
  non-archived audiences on the source's allowlist.

An audience outside the source's allowlist is refused even if the caller names
it, and an origin outside the source's allowed origins is refused regardless of
what the calling application stores.

### Provisioning is console-only

Creating a source, issuing or rotating its credential, and revoking it are done
in the console by a workspace `owner` or `brand_admin`, under **Subscription
sources**. There is no API for them and none is planned: the issue flow shows a
secret exactly once, which is not a shape an unattended caller should drive.
Do not look for an undocumented endpoint.

## Implemented endpoint/capability matrix

All routes below are under
`/api/v1/organizations/{organization}/workspaces/{workspace}` unless shown as
absolute.

| Method and route | Required capability |
|---|---|
| `GET /api/v1/me` | authenticated |
| `GET /api/v1/workspaces` | authenticated |
| `GET /` | `view` |
| `POST /contacts/upsert` | `upsert_contacts` |
| `POST /contact-field-definitions` | `upsert_contacts` |
| `GET /contacts/lookup` | `view` |
| `POST /audiences` | `upsert_contacts` |
| `GET /audiences/{audience_id}` | `view` |
| `POST /audiences/{audience_id}/archive` | `upsert_contacts` |
| `POST /audiences/{audience_id}/subscribe` | `upsert_contacts` |
| `POST /audiences/{audience_id}/unsubscribe` | `upsert_contacts` |
| `POST /contacts/{contact_id}/suppress` | `resolve_suppressions` |
| `POST /suppressions/{suppression_id}/resolve` | `resolve_suppressions` |
| `GET /contacts/{contact_id}/eligibility/{audience_id}` | `view` |
| `POST /contacts/{contact_id}/preference-token` | `upsert_contacts` |
| `GET /imports/csv` | `view` |
| `POST /imports/csv` | `upsert_contacts` |
| `GET /imports/csv/{job_id}` | `view` |
| `PATCH /imports/csv/{job_id}/mapping` | `upsert_contacts` |
| `POST /imports/csv/{job_id}/approve` | `approve_imports` |
| `POST /imports/csv/{job_id}/retry` | `upsert_contacts` |
| `POST /imports/csv/{job_id}/cancel` | `upsert_contacts` |
| `GET /imports/csv-mapping-profiles` | `view` |
| `GET /automations` | `view` |
| `POST /automations` | `edit_automations` |
| `GET /automations/{automation_id}` | `view` |
| `GET /automations/{automation_id}/versions/{version_id}` | `view` |
| `POST /automations/{automation_id}/versions` | `edit_automations` |
| `POST /automations/{automation_id}/ready-for-review` | `edit_automations` |
| `POST /automations/{automation_id}/archive` | `edit_automations` |
| `POST /automations/{automation_id}/restore` | `edit_automations` |
| `GET /automations/{automation_id}/enrollments` | `view` |
| `GET /automation-enrollments/{enrollment_id}` | `view` |
| `POST /automations/{automation_id}/versions/{version_id}/synthetic-enrollments` | `edit_automations` |
| `POST /automation-enrollments/{enrollment_id}/{pause\|resume\|cancel\|replay}` | `pause_delivery` |
| `POST /sending-domains` | `manage_senders` |
| `POST /sending-domains/{domain_id}/readiness` | `manage_senders` |
| `POST /sending-domains/{domain_id}/provider` | `manage_senders` |
| `POST /sending-domains/{domain_id}/verify` | `manage_senders` |
| `POST /sender-identities` | `manage_senders` |
| `POST /sender-identities/{sender_id}/activate` | `manage_senders` + platform/workspace owner |
| `POST /activate-sending` | `manage_senders` + platform/workspace owner |
| `POST /provider-connections` | `manage_senders` |
| `POST /provider-connections/{connection_id}/check` | `manage_senders` |
| `POST /provider-connections/{connection_id}/activate` | `manage_senders` |
| `POST /email-templates` | `edit_content` |
| `POST /email-templates/{template_id}/versions` | `edit_content` |
| `POST /campaign-drafts` | `edit_content` |
| `POST /campaign-drafts/{campaign_id}/versions` | `edit_content` |
| `POST /test-sends` | `send_test` |
| `POST /rehearsals` | `send_test` |
| `GET /rehearsals/{rehearsal_id}` | `view` |
| `POST /campaign-drafts/{campaign_id}/schedule` | `schedule_campaign` |
| `GET /campaign-runs/{run_id}` | `view` |
| `GET /campaign-runs/{run_id}/report` | `view` |
| `POST /campaign-runs/{run_id}/unschedule` | `schedule_campaign` |
| `POST /campaign-runs/{run_id}/{pause\|resume\|cancel}` | `pause_delivery` |
| `POST /provider-events/{event_id}/replay` | `pause_delivery` |
| `GET /operations/preflight` | `view` |
| `POST /operations/preflight` | `manage_senders` |
| `POST /operations/synthetic-rehearsals` | `manage_senders` + platform/workspace owner |
| `GET /operations/synthetic-rehearsals/{run_id}` | `view` |

`POST /audiences/{audience_id}/subscribe` returns `422` when an unresolved
suppression blocks the contact -- platform-wide, scoped to this workspace, or
scoped to this audience -- and names the blocking scope and reason in the
error. There is no way to override this. Resolve the suppression first via
`POST /suppressions/{suppression_id}/resolve` (`resolve_suppressions`), then
subscribe again.

`GET /campaign-runs/{run_id}/report` returns per-recipient outcome fields alongside the
original raw-event fields. Under normal operation, each accepted recipient falls into
exactly one of `delivered`, `hard_bounced`, `soft_failed_only`, or `no_terminal_event`,
and those four sum to `sent`. A recipient that was delivered and later hard bounced counts only as
`hard_bounced`. `unsubscribes_attributed` is inferred from suppression timing and is
null when the run never started. `delivery_rate` and `bounce_rate` are percentages and
are null when `sent` is zero. The original `events` map remains a raw provider event
count and may exceed `sent`.

See `/openapi.json` for exact request fields, response schemas, status codes,
idempotency rules, and operation-level safety metadata.

## Platform gates move underneath you

Five platform switches used to be environment variables that only changed on a
redeploy. They are now database rows a platform owner flips from the console,
and each resolves as:

```
effective = environment ceiling AND stored row
```

Enabling needs both. Disabling needs either. Turning one **off** always works
and bites within about a minute, because workers refresh once per loop and web
requests cache only for the life of one request.

| Gate | Ceiling variable | What closing it stops |
|---|---|---|
| `rate_limit_enabled` | none | Nothing; it removes the cap on public endpoints |
| `automation_enabled` | none | New enrollments and every step in flight |
| `production_sending_enabled` | `ALLOW_PRODUCTION_SENDING_ACTIVATION` | All real campaign and automation delivery |
| `test_sends_enabled` | `TEST_SENDS_ENABLED` | Allowlisted test sends |
| `synthetic_rehearsals_enabled` | `SYNTHETIC_REHEARSALS_ENABLED` | Synthetic capacity rehearsals |

What this means for an agent:

- **A gate can close between two of your calls.** A `422` on a send-adjacent
  operation may simply mean a human just shut something off. Do not retry in a
  loop; surface the refusal and stop.
- **The reverse is also true.** An operation that failed a moment ago may
  succeed now. Never cache a gate decision across a run, and never infer that a
  gate is open because an earlier call worked.
- **The gates are not readable through the API.** Their state is visible only in
  the console at `/admin/settings`. Treat the refusal message on a failed call
  as the authority.
- **You cannot change one.** There is no API route, by design.

## Safety gates: what can send

| Action | What it does | Mailgun/provider contact | Recipient contact |
|---|---|:---:|:---:|
| Preview | Renders sanitized content in the authenticated console | no | no |
| Eligibility check | Evaluates consent, suppressions, membership, frequency, duplicate, archive, and sending gates | no | no |
| Draft/template authoring | Writes immutable database versions | no | no |
| Provider check | Tests the configured Mailgun credential reference | yes, read/check only | no |
| Domain verification | Reads DNS and Mailgun domain status | yes, read/check only | no |
| Test send | Queues one email to a configured allowlisted address | yes | one allowlisted recipient |
| Controlled rehearsal | Runs one gated test send and reconciles signed provider events | yes | one allowlisted recipient |
| Synthetic rehearsal | Exercises 100–10,000 synthetic recipients in an isolated transaction and rolls operational rows back | no; asserted zero | no |
| CSV upload | Encrypts a protected UTF-8 CSV and stores no row values in responses | no | no |
| CSV dry run | Validates, deduplicates, reports conflicts, and calculates suppression impact with zero contact writes | no | no |
| CSV apply | After independent checksum-bound approval, writes contacts/consent/suppressions with suppressions first | no | no |
| Automation authoring | Validates and stores immutable triggers, nodes, edges, dependencies, and previews | no | no |
| Automation ready for review | Records a checksum-bound authoring milestone only; it is not activation | no | no |
| Automation runtime | Stores durable enrollments, waits, attempts, events, retries and replay for published automations | yes, through the automation send worker | yes |
| Audience subscribe | Records consent and membership — **and fires `audience_subscribed`**, so it can enrol into a published automation | not during the HTTP request | later, if an automation is published |
| Tag apply or remove (console) | Bulk tag change across resolved audiences — **fires `tag_added`/`tag_removed`** | not during the request | later, if an automation is published |
| Contact upsert with `tags` | Writes `tags_json` directly; fires no trigger | no | no |
| Audience, campaign, sender, domain or connection delete (console) | Removes the record after refusing while anything still references it | no | no |
| Platform gate change (console) | Flips one switch platform-wide within about a minute | no | no; but closing one stops delivery |
| Phase 5E automation rehearsal | Uses a disposable local database and simulated clock to verify the synthetic runtime, restart recovery, controls, eligibility, and zero-call safety; hard-blocked in production | no; asserted zero | no |
| Protected reconstruction inspect | Verifies the approved encrypted handoff and emits a redacted aggregate report; non-production CLI only | no | no |
| Protected reconstruction synthetic apply | Creates immutable review drafts in a disposable non-production database; hard-blocked in production | no | no |
| Approved production draft load | After Mike and Manly approval, writes nine immutable review templates and one non-executable automation draft only; GFA owner CLI, checksum and exact confirmation required | no | no |
| Campaign scheduling | Creates an immutable snapshot and durable scheduled run only if all production gates are open | not during the HTTP request | later, only through worker |
| Production sending | Worker prepares centrally eligible recipients and sends idempotently | yes | eligible campaign recipients |

### Test-send gates

Every test send requires:

- `send_test`;
- `TEST_SENDS_ENABLED=true` as the ceiling, plus Test sends turned on in Platform settings;
- exact recipient membership in `TEST_RECIPIENT_ALLOWLIST`;
- exact confirmation `SEND TEST EMAIL`;
- a 12–200 character `Idempotency-Key`;
- verified current DNS/domain evidence;
- verified and active provider connection;
- configured hourly test limit.

### Controlled-rehearsal gates

A controlled rehearsal has every test-send gate plus:

- exact confirmation `RUN CONTROLLED REHEARSAL`;
- its own `Idempotency-Key`;
- signed/deduplicated Mailgun events for reconciliation.

### Synthetic-rehearsal gates

A synthetic rehearsal requires:

- `manage_senders`;
- both platform owner and workspace owner;
- `SYNTHETIC_REHEARSALS_ENABLED=true` as the ceiling, plus Synthetic rehearsals turned on in
  Platform settings;
- 100–10,000 recipients;
- body `idempotency_key` of 12–200 characters;
- exact confirmation `RUN {recipient_count} SYNTHETIC REHEARSAL`.

It uses `.invalid` addresses, no subscriber data, no Mailgun adapter, zero provider
calls, and rolls back all synthetic operational data while retaining only the
report and audit evidence.

### Campaign scheduling and production gates

Scheduling requires `schedule_campaign`, a timezone-aware future time, an
`Idempotency-Key`, a draft campaign, non-archived audience/content, and a ready
sender. It also fails closed unless:

- application activation gate is open (`ALLOW_PRODUCTION_SENDING_ACTIVATION=true`);
- global production sending is enabled -- its own switch in Platform settings,
  behind the activation gate above as its ceiling;
- workspace production sending is enabled;
- provider is verified and active;
- domain has a current passing verification;
- sender is explicitly enabled.

At delivery time, every recipient still passes centralized eligibility. Current
production marketing sending remains disabled until explicit owner approval.

## Empty-workspace onboarding checklist

Complete in this order:

0. List published automations (`GET /automations`, filter `status == "published"`).
   In an empty workspace this is empty and step 3 is inert. In a workspace that
   already runs sequences, subscribing contacts starts them.
1. Confirm the agent appears in `GET /api/v1/me` with the intended workspace role.
2. Create or import contacts with provenance.
   For CSV, upload → map → dry run → owner/admin approval → reconcile; never
   treat file presence as consent.
3. Create an audience and attach only reviewed consent evidence.
4. Review legacy-consent classifications and suppressions.
5. Create a secret-reference-only Mailgun provider connection.
6. Check the provider; activate it only for approved test activity.
7. Create the brand-specific sending domain and configure provider/DKIM metadata.
8. Run live DNS/domain verification.
9. Create a sender identity; leave production activation closed.
10. Create a template and immutable content version; preview it.
11. Run one allowlisted test send.
12. Run and reconcile one controlled rehearsal.
13. Run the isolated synthetic capacity rehearsal if owner-approved.
14. Run launch preflight and resolve every block.
15. Create a campaign draft. The API writes a single-audience draft; ask a human
    to use the console if the send needs several audiences or a tag filter.
16. Do not schedule or enable production sending without the separate explicit
    owner approval. Workspace activation is console-only and requires the
    workspace owner.

## Error and idempotency handling

- Success bodies are JSON.
- Current API error bodies are Flask `text/html`; branch on HTTP status, not body
  parsing.
- Retain the response `X-Correlation-ID` for support and audit lookup.
- Wallet validation outages fail closed with `503`.
- Invalid/expired Wallet tokens return `401`.
- Missing workspace membership is intentionally hidden as `404`.
- Role/owner failures return `403`.
- Validation and safety-gate failures return `422`.
- Uniqueness/source-identity conflicts may return `409`.
- Use the documented `Idempotency-Key` on test sends, controlled rehearsals, and
  scheduling. Synthetic rehearsal uses `idempotency_key` in its JSON body.
- CSV apply is idempotent through its checksum-bound job and per-row ledger.
  Failed worker claims are recovered; changing a mapping or report invalidates
  approval. Cancellation, expiry, and completion permanently destroy the
  encrypted upload payload.

## Upstream identity contract

The authoritative GFAVIP Headless guide is:
`https://wallet.gfavip.com/skill.md`.

Wallet and PowerLobster remain responsible for identity issuance. This service
accepts only the Wallet-issued bearer and remains responsible for local
authorization, consent, suppression, delivery safety, and audit.
