# NGAIGE Channel API v1 — human guide

## In plain English

This is the guide to the product's official front door — the one published
set of instructions by which any outside software, including the operator's
own app, may drive the whole journey a shop owner would otherwise take in
chat: join, bring a permitted list, draft a campaign, get the human's
recorded go-ahead, send, and read back delivery and cost. The crucial
promise is that the door itself does the policing: whoever knocks, whatever
they claim, consent, the STOP rule and the human approval gate are enforced
inside, and every refusal has a fixed polite shape. Every example in this
guide was truly typed in and answered; nothing is made up for the page.
**The one takeaway:** there is exactly one door for everyone, the rules
live inside it, and this guide proves both with real exchanges. *(≈135
words)*

## Why it matters

- **One door, every surface.** Chat, portal, back office and third-party
  software all use this same contract — there is no private door where the
  rules are looser.
- **Refusals are part of the contract.** Consent-less, STOP-suppressed and
  unapproved calls are answered by pinned, polite refusals — behaviour a
  judge can re-run, not a policy paragraph.
- **Executed, not narrated.** Every request and response below ran against a
  real boot on the recorded date; the appendix carries the log.

*(The full human guide — every route, refusal shape and executed exchange —
follows unchanged.)*

---

The Channel API is the versioned front door for external integrators: over
plain HTTPS + JSON it exposes the whole NGAIGE Phase One pipeline a tenant
would otherwise drive from the chat surfaces — onboarding an SME client with
its consented recipient list, drafting and planning a campaign, the recorded
human approval, the gated dispatch, delivery/cost reporting, the masked STOP
ledger, and one synchronous conversation turn. Every compliance law
(recipient consent, the STOP keyword, the human approval gate) is enforced
server-side inside this contract, no matter who calls or what the request
claims: the refusal shapes below are pinned behaviour, not documentation
wishes.

Machine-readable contract: [`openapi.v1.json`](./openapi.v1.json) (OpenAPI
3.0.3) — this guide is its human half. Every curl below was executed for
real against a local boot on 2026-08-26; the outputs pasted are the true
response bytes (trimmed where marked, key secrets redacted). Appendix A has
the full execution log with exit codes.

## The shape of it — one door, channel-blind cores

Every surface — the chat channels (Telegram live today; SMS, RCS and
WhatsApp ride the same adapter seam when a deployment enables them), the
operator's own app, the small-business portal and the operator back office
— talks to the same Channel API v1. Below the door the cores know nothing
about channels: onboarding, consent evidence, campaigns, dispatch,
reporting, the STOP ledger and identity are one set of machines behind one
contract, and compliance is enforced *inside* that contract. The two-realm
law is drawn in: nothing personal crosses — the AI lanes see only the
merchant's own brief words. Green marks the compliance path throughout this
guide's diagrams.

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"15px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","clusterBkg":"#f6f2fb","clusterBorder":"#440088","titleColor":"#1b1230","edgeLabelBackground":"#ffffff"}}}%%
%% truth: docs/architecture.md · internal/channelapi/channelapi.go (Deps + Routes) · internal/events/events.go · docs/decisions/0009-two-realm-mno-collaboration.md
flowchart TB
    subgraph SURFACES["People's surfaces — every channel talks the same contract"]
        TG["Small business chat — Telegram (live today)"]
        FUT["More chat channels — SMS · RCS · WhatsApp<br/>(adapter slots, enabled per deployment)"]
        MNOAPP["Operator's own app<br/>(embeds the same door)"]
        PORTAL["Small business portal — web pages"]
        BACKOFFICE["Operator back office — staff console"]
    end

    subgraph DOOR["Channel API v1 — the one front door"]
        API["Versioned HTTPS + JSON contract<br/>per-tenant keys · pinned refusal shapes"]
        CMPNOTE["<b>Compliance lives INSIDE the contract</b><br/>consent proof on every import · STOP honoured at send<br/>a named human approves before anything goes out"]
    end

    subgraph CORES["Channel-blind cores — no channel knowledge below the door"]
        direction LR
        ONB["Onboarding"]
        CON["Consent evidence"]
        CAMP["Campaigns"]
        DISP["Dispatch machine"]
        REP["Reporting fold"]
        SUPP["STOP ledger"]
        IDEN["Identity + keys"]
    end

    subgraph DATA["Operator realm — data and audit"]
        PG[("System of record<br/>PostgreSQL — events first")]
        AUDIT[["Hash-chained audit log<br/>every state change, tamper-evident"]]
    end

    subgraph AIREALM["Sovereign AI realm — the model router picks the best available lane"]
        ROUTER["Estate model router<br/>(silent failover · hierarchy is config, not architecture)"]
        L1["k3"]
        L2["glm5.2"]
        L3["glm5.3-flash"]
        L4["qwen — local lane, last resort"]
        ROUTER --> L1 --> L2 --> L3 --> L4
        CFG["lanes named = TODAY's order (2026-08-27 config) —<br/>the router re-picks as the hierarchy improves"]
    end

    REALMNOTE["<b>Data never leaves either realm</b><br/>phone numbers, consent proofs and receipts stay operator-side —<br/>only the campaign's own brief words cross to the AI lanes<br/>tenant data and operator data: separated by schema, service and keys"]

    TG --> API
    FUT -.-> API
    MNOAPP --> API
    PORTAL --> API
    BACKOFFICE --> API
    API --- CMPNOTE
    API --> CORES
    CORES --> PG
    CORES --> AUDIT
    CAMP -. "brief words only — never a phone number" .-> ROUTER
    DATA --- REALMNOTE

    classDef accentBlue fill:#eaf3fc,stroke:#2a78d6,color:#1b1230
    classDef accentOrange fill:#fdf0e9,stroke:#eb6834,color:#1b1230
    classDef accentTeal fill:#e9f8f3,stroke:#1baf7a,color:#1b1230
    classDef accentAmber fill:#fdf6e3,stroke:#eda100,color:#1b1230
    classDef greencall fill:#eafff4,stroke:#0FFC5A,stroke-width:2.5px,color:#0b5c2e
    class TG,MNOAPP accentBlue
    class FUT accentOrange
    class PORTAL accentTeal
    class BACKOFFICE accentAmber
    class CMPNOTE,REALMNOTE greencall
    linkStyle 5 stroke:#0FFC5A,stroke-width:3px
    style AIREALM fill:#fdfcff,stroke:#440088,stroke-width:2.5px,stroke-dasharray:8 5
    style DATA fill:#f6f2fb,stroke:#440088,stroke-width:2.5px
```

![The surfaces, the one door, the channel-blind cores and the two realms](diagrams/context-architecture.svg)

*Truth source: `docs/architecture.md`, `internal/channelapi/channelapi.go`
(the Deps/Routes tables), `internal/events/events.go`, ADR-0009
(`docs/decisions/0009-two-realm-mno-collaboration.md`). The intelligence is a
**sovereign model router**: it selects the best available lane per call from a
hierarchy that changes over time — model-agnostic by construction, so the
estate adopts whatever is best without this API changing. Today's order
(2026-08-27, code defaults in `internal/agent/llm/config.go`): k3 → glm5.2 →
glm5.3-flash → qwen3-coder-next, silent failover — a deployment's
`NGAIGE_LLM_FALLBACKS` pin can trim the chain (staging runs
`glm5,qwen3-coder-next` while the glm5.3-flash lane proves out), and the
router skips any offline lane without the caller ever hearing it.*

## Status: where the API runs

| Surface | State |
| --- | --- |
| Local boot (this guide) | Verified end-to-end 2026-08-26 — every example below |
| `https://product-staging.staging.con5ult.com` | **Enabled** — ask #8 landed. Probed 2026-08-27: `/api/v1/suppression` → **401** (the no-oracle refusal below — boundary mounted, no key honoured yet; exactly the expected closed state). Keyed flows run once a tenant key is minted. |

**Staging-parity recheck (pending ask #8):** when the platform lands the
staging enable (`NGAIGE_CHANNELAPI_ENABLED=true` +
`NGAIGE_CHANNELAPI_KEY_PEPPER_FILE` on the droplet, app restarted, first key
seeded), re-run the exact commands of this guide with the base URL swapped:
`B=https://product-staging.staging.con5ult.com`. The expected closed-state
probe before key seeding is
`curl -s -o /dev/null -w '%{http_code}' $B/api/v1/suppression` → **401**
(the no-oracle refusal below). Every status code and refusal shape in this
guide is pinned by the executable spec (`internal/channelapi/spec_test.go`),
so staging must answer byte-for-byte what the local boot answered.

## Authentication: per-tenant keys

Every request carries a tenant key as a bearer credential:

```http
Authorization: Bearer nk_<key_id>_<secret>
```

The `key_id` half (8..32 chars of `[a-z0-9]`) indexes the store; the 64-hex
secret half is compared against its stored digest in constant time. Only the
digest (`hex(HMAC-SHA256(pepper, secret))`) ever rests — the plaintext is
revealed exactly once, at mint. The **tenant identity comes from the key
only**: no body field, query parameter or path segment may carry a tenant
id (a smuggled `tenant_id` body field fails JSON parsing as an unknown
field, 400).

**Scopes (closed v1 catalogue):**

| Scope | Unlocks | Routes |
| --- | --- | --- |
| `conversation:write` | one conversation turn | `POST /api/v1/conversations/{id}/messages` |
| `onboarding:write` | create clients | `POST /api/v1/clients` |
| `imports:write` | consent CSV imports | `POST /api/v1/imports` |
| `campaigns:write` | drafts, plans, submits, dispatch requests | `POST /api/v1/campaigns/drafts`, `.../{id}/plan`, `.../{id}/submit`, `POST /api/v1/dispatch-requests` |
| `campaigns:approve` | record the human approval — its own scope on purpose, so automation keys can draft and dispatch without ever holding the human-decision power | `POST /api/v1/campaigns/{id}/approve` |
| `reports:read` | campaign reports | `GET /api/v1/campaigns/{id}/report` |
| `suppression:read` | masked STOP ledger | `GET /api/v1/suppression` |
| `admin:all` | implies every scope above + key minting | `POST /api/v1/keys` (and all of the above) |

A scopeless key is refused at mint (a standing hole). A wrong-scope request
is refused `403 urn:ngaige:problem:v1:scope-required` naming the missing
scope — before any object lookup, so a missing right never oracles an
existing id.

### Minting the first key: `ngaige apikey-seed`

`POST /api/v1/keys` only mints for an already-authenticated `admin:all`
holder, so the **first** key of a tenant is an operator act on the host —
the seed-first-admin bootstrap:

```sh
$ ngaige apikey-seed --tenant b6bbbe81-a72d-4fdb-ae15-3372490f42a7 --scopes admin:all
tenant seeded: b6bbbe81-a72d-4fdb-ae15-3372490f42a7
key: nk_b18ae290_<64-hex secret — redacted here>
store this now — it is never shown again (digest-only at rest)
```

(exit code 0; the pepper comes from `NGAIGE_CHANNELAPI_KEY_PEPPER` or its
`_FILE` twin — both missing is a loud refusal naming both envs.) Every
later key mints through the API itself; the plaintext is revealed once in
the response and nowhere else ever:

```sh
$ curl -X POST $B/api/v1/keys \
    -H "Authorization: Bearer $ADMIN_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-key-001' \
    -d '{"scopes":["campaigns:write","onboarding:write","imports:write","reports:read","suppression:read","conversation:write"]}'
{"key_id":"97160492b7fe7ebc","key":"nk_97160492b7fe7ebc_<secret redacted>","scopes":["campaigns:write","onboarding:write","imports:write","reports:read","suppression:read","conversation:write"],"created_at":"2026-08-26T19:21:52.732388785Z"}
HTTP 201
```

**The no-oracle 401 law:** an absent header, a malformed header, an unknown
key id, a wrong secret and a revoked key all answer the *same bytes* — the
two executions below (no header at all; well-formed key with a wrong
secret) returned byte-identical bodies:

```sh
$ curl $B/api/v1/suppression
{"type":"about:blank","title":"unauthorized","status":401}
HTTP 401            # + header WWW-Authenticate: Bearer
```

Unknown keys never even consume the rate-limit bucket.

### The life of a key

Minting is a live act, always: the first key of a tenant is seeded on the
host, every later key mints through the API for an `admin:all` holder, and
a revocable stamp is never a mint property (the store refuses pre-revoked
records). Rotation always supersedes — the successor is minted first,
callers move across, and only then does the old key die. One honest v1
limit: revoking (and therefore finishing a rotation) is an operator-side
act on the store today — the `revoked_at` column and its 401 law are live,
but there is no public revoke route yet.

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"15px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","stateBkg":"#ffffff","stateBorder":"#440088","stateTextColor":"#1b1230","transitionColor":"#6b5d86","transitionLabelColor":"#1b1230","compositeBackground":"#faf8fd","compositeBorder":"#440088","compositeTitleBackground":"#f0eafa","noteBkgColor":"#eafff4","noteBorderColor":"#0FFC5A","noteTextColor":"#0b5c2e","edgeLabelBackground":"#ffffff","specialStateColor":"#1b1230","innerTitleBackground":"#f0eafa"}}}%%
%% truth: migrations/000013_channelapi.up.sql (api_keys: digest-only, scopes text[], revoked_at NULL=live, anything stamped = the uniform 401) · internal/channelapi/channelapi.go + auth.go (mint one-shot plaintext; no-oracle 401 for every failure class) · internal/channelapi/pgstore/store.go (SaveKey refuses a pre-revoked record) · cmd/ngaige/apikeyseed.go (first key is a staff act on the host)
stateDiagram-v2
    direction LR

    state "One tenant API key" as KEY {
        [*] --> minted : first key — staff seed it on the host<br/>later keys — an admin key mints<br/>them over the API
        minted --> live : the plaintext shows ONCE, then only<br/>its digest rests — scopes come from<br/>the closed catalogue — a scopeless<br/>key is refused at mint
        live --> live : bearer use — scope checked before anything<br/>loads, every wrong-credential class<br/>hears the same 401 (no oracle)
        live --> rotating : rotation — the successor is minted FIRST,<br/>callers move across, only then<br/>does this key die
        live --> dead : revoked — a stamp on the row —<br/>from now on it hears the same 401<br/>as a never-known key
        rotating --> dead : revoked — rotation supersedes:<br/>the old key never comes back
        dead --> archived : the digest row stays as audit history —<br/>never deleted, never usable again
        archived --> [*]
    }
```

![The life of a tenant API key](diagrams/key-lifecycle.svg)

*Truth source: `migrations/000013_channelapi.up.sql` (`api_keys` —
digest-only, `revoked_at` NULL = live), `internal/channelapi/auth.go` +
`channelapi.go` (one-shot plaintext mint; the no-oracle 401),
`internal/channelapi/pgstore/store.go` (pre-revoked mint refused),
`cmd/ngaige/apikeyseed.go` (the seed act). The v1 gap named above (no
public revoke route) is stated, not papered over.*

And a rotation as one exchange — the safe order is the whole story: mint
the successor first, move the callers across while both keys still work,
and only then let the old key die:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: cmd/ngaige/apikeyseed.go (the first key is a staff act on the host) · internal/channelapi/handlers.go (POST /api/v1/keys mints successors for an admin:all holder — plaintext once) · internal/channelapi/pgstore/store.go (revoked_at stamp; SaveKey refuses a pre-revoked mint; digest row retained) · internal/channelapi/auth.go (a revoked key hears the same uniform 401 bytes as a never-known key) · migrations/000013_channelapi.up.sql
%% covers: key-rotation
sequenceDiagram
    autonumber
    participant O as Operator staff<br/>(on the host)
    participant H as Integrator's admin<br/>(admin key)
    participant A as Channel API<br/>front door
    participant K as Key store<br/>(digests only)
    participant C as Callers<br/>(the integrator's apps)

    O->>O: seed the very first key on the host (apikey-seed)
    Note over O,K: one plaintext showing, ever — only the digest rests

    H->>A: mint the SUCCESSOR (scopes from the closed catalogue)
    A->>A: admin scope check, replay check
    A->>K: store the successor — digest only, scopeless refused
    K-->>A: key id
    A-->>H: 201 — the only plaintext showing of the successor
    H->>C: deploy the successor key to every caller

    Note over C,K: both keys work meanwhile — nothing waits on the old key dying

    C->>A: traffic on the successor key
    A-->>C: answers as before

    O->>K: stamp the old key revoked (operator-side act in v1)
    K-->>O: stamped — the digest row stays as audit history, never deleted
    C->>A: a late call on the old key
    A-->>C: 401 — the same bytes as a never-known key (no oracle, no bucket spent)
```

![Key rotation — successor minted first, callers moved across, then the old key dies](diagrams/seq-key-rotation.svg)

*Truth source: `cmd/ngaige/apikeyseed.go` (the first key is a staff act on
the host), `internal/channelapi/handlers.go` (`POST /api/v1/keys` mints
successors for an `admin:all` holder — one-shot plaintext),
`internal/channelapi/pgstore/store.go` (`revoked_at` stamping; a
pre-revoked mint is refused; the digest row is retained),
`internal/channelapi/auth.go` (a revoked key hears the uniform no-oracle
401 — and consumes no rate budget), `migrations/000013_channelapi.up.sql`
(`api_keys`). The revoke step is operator-side in v1 by design — there is
no public revoke route yet (named above, not papered over).*

- **Idempotency-Key is required on every mutating POST.** Same key + same
  request fingerprint (method+path+body) replays the stored status and body
  byte-identically with `X-Idempotent-Replay: true`; same key + a different
  body is `409 urn:ngaige:problem:v1:idempotency-conflict`; a missing
  header is `400 urn:ngaige:problem:v1:idempotency-key-required`. The
  ledger scope is (tenant, key) and it is durable (migration 000013).
- **RFC7807 everywhere:** every refusal is `application/problem+json` with
  `type/title/status` (+ `detail` when it helps). An unknown `/api/v1/*`
  path and a foreign tenant's object answer the same uniform 404 bytes; a
  registered path with the wrong method answers 405 with an `Allow` header.
- **Rate limit:** per-key token bucket, 10 rps / burst 20; 429 answers
  `urn:ngaige:problem:v1:rate-limited` with whole-second `Retry-After`.
- **Money:** EUR figures are decimal strings with exactly 4 fractional
  digits, computed in code — never narrated by a model.
- **Server ordering:** authn (401) → scope (403) → rate (429) →
  idempotency → validation → handler.

## Endpoint groups

The examples run one continuous story: onboard a café client, import its
consented list, draft→plan→submit→approve a campaign, dispatch it (one
target on the STOP list), read the report, and hold one conversation turn.
`$B` is `http://127.0.0.1:18099` locally; `$ADMIN_KEY` is the seeded
`admin:all` key, `$WRITE_KEY` the minted write key above, `$APPROVE_KEY` a
second minted key holding only `campaigns:approve`.

### Onboarding — create a client

`POST /api/v1/clients` (scope `onboarding:write`). The sender id is the
handset-visible originator, max 11 alphanumeric chars; a duplicate
(tenant, sender_id) is a 409.

```sh
$ curl -X POST $B/api/v1/clients \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-client-001' \
    -d '{"business_name":"Mango Lane Café","sender_id":"MANGOLANE"}'
{"id":"6fd95670-be46-48c7-89b2-8663c3b08c11","tenant_id":"b6bbbe81-a72d-4fdb-ae15-3372490f42a7","business_name":"Mango Lane Café","sender_id":"MANGOLANE","country":"","created_at":"2026-08-26T19:21:42.74217311Z"}
HTTP 201
```

Note the `tenant_id` came from the key, never from the request. Repeating
the *exact same* request (the integrator's retry after a timeout, say)
answers the stored bytes with the replay header — executed response:

```http
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
X-Idempotent-Replay: true
```
(byte-identical body; and the same `Idempotency-Key` with a different body
answered `409 urn:ngaige:problem:v1:idempotency-conflict` — both executed.)

Missing the header entirely:

```sh
$ curl -X POST $B/api/v1/clients -H "Authorization: Bearer $ADMIN_KEY" \
    -H 'Content-Type: application/json' \
    -d '{"business_name":"No Idem Co","sender_id":"NOIDEM"}'
{"type":"urn:ngaige:problem:v1:idempotency-key-required","title":"idempotency key required","status":400,"detail":"mutating requests must carry the Idempotency-Key header"}
HTTP 400
```

**The three ways a business arrives.** This API is the operator door — the
client row it lands is the same row the conversational assistant's doors
land. That flow has three ways to drive it: the owner runs it alone in
their own chat (SELF), a staff console runs it for them (OPERATOR), or the
two link into one shared flow (HYBRID) — the link is a one-shot, 72-hour
hand-off ticket the operator's "hand over" verb mints; the owner's first
open consumes it forever, the audit names the operator who granted it, and
an old or spent link earns only a plain note. Modes are *derived* from the
session link pair — never a stored field that could disagree with reality.

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"15px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","stateBkg":"#ffffff","stateBorder":"#440088","stateTextColor":"#1b1230","transitionColor":"#6b5d86","transitionLabelColor":"#1b1230","compositeBackground":"#faf8fd","compositeBorder":"#440088","compositeTitleBackground":"#f0eafa","noteBkgColor":"#eafff4","noteBorderColor":"#0FFC5A","noteTextColor":"#0b5c2e","edgeLabelBackground":"#ffffff","specialStateColor":"#1b1230","innerTitleBackground":"#f0eafa"}}}%%
%% truth: internal/assistant/mode.go (Mode literals SELF / OPERATOR / HYBRID — derived from the session link pair, never stored; one-shot handoff tickets, 72h, actor = granting operator) · internal/assistant/session.go (flow steps) · internal/events/events.go (assistant.handoff)
stateDiagram-v2
    direction LR

    state "One onboarding flow — three ways to drive it" as MODES {
        SELF : SELF — the owner drives<br/>their own chat alone
        OPERATOR : OPERATOR — a staff chat sets<br/>the business up for them
        HYBRID : HYBRID — linked, both chats<br/>advance the owner's flow

        [*] --> SELF : the owner starts<br/>in their own chat
        [*] --> OPERATOR : staff run "/onboard-for …"<br/>or "set up a customer"
        OPERATOR --> OPERATOR : staff answer the flow's<br/>questions for the business
        OPERATOR --> HYBRID : "hand over" — the owner opens<br/>the one-shot link, the flow rebases<br/>to the owner's chat, audited
        HYBRID --> HYBRID : either side writes — a plain note says<br/>when the other side helped meanwhile
        note right of HYBRID
            <b>the handover is complete</b><br/>one link, one use, operator named in the audit
        end note
    }

    state "The hand-off ticket" as TICKET {
        minted : minted — "/start hand-…"<br/>minted for one named operator chat
        spent : spent — consumed once,<br/>the owner has their seat
        dead : dead — older than 72 hours<br/>or already used: a dud link earns<br/>only a plain note, nothing moves
        [*] --> minted : the "hand over" verb
        minted --> spent : the owner's first open<br/>consumes it — never twice
        minted --> dead : 72 hours pass
    }

    TICKET --> MODES : a spent ticket links the two chats
```

![Onboarding modes and the one-shot hand-off ticket](diagrams/state-onboarding-modes.svg)

*Truth source: `internal/assistant/mode.go` (the SELF / OPERATOR / HYBRID
literals, derived from the link pair; one-shot tickets, 72-hour life, actor
= the granting operator), `internal/assistant/session.go` (the flow steps),
`internal/events/events.go` (`assistant.handoff`).*

### Imports — the consent CSV (consent-in-the-contract)

`POST /api/v1/imports` (scope `imports:write`). The CSV header is exactly
`msisdn,consent,consented_at`. The request must carry **exactly one**
consent proof carrier — this is the consent carrier law and it is in the
wire contract, not in a terms-of-service PDF:

- `consent_affirmation: true` — the importer attests recorded permission
  exists (rows stamp `csv-upload`), or
- `consent_evidence_ref` + `evidence_captured_at` — a previously saved
  consent evidence row id; the sheet's capture instant is the consent stamp.

Neither carrier is a 422 and *nothing is stored*; both carriers is a 422
too (proof carriers are exclusive). Executed refusal:

```sh
$ curl -X POST $B/api/v1/imports \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-import-001' \
    -d '{"client_id":"6fd95670-be46-48c7-89b2-8663c3b08c11","csv":"msisdn,consent,consented_at\n+31612345678,true,2026-08-20T10:15:00Z\n..."}'
{"type":"urn:ngaige:problem:v1:consent-required","title":"consent proof required","status":422,"detail":"provide exactly one consent proof carrier: consent_affirmation=true (the importer attests recorded permission exists) or consent_evidence_ref (a previously saved consent evidence row); nothing is stored without one"}
HTTP 422
```

The affirmed import (5 messy rows on purpose — one `consent:false`, one
unparseable number, one duplicate of the first row):

```sh
$ curl -X POST $B/api/v1/imports \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-import-002' \
    -d '{"client_id":"6fd95670-be46-48c7-89b2-8663c3b08c11","csv":"msisdn,consent,consented_at\n+31612345678,true,2026-08-20T10:15:00Z\n+31687654321,true,2026-08-20T10:16:00Z\n+31655500111,false,2026-08-20T10:17:00Z\nnot-a-number,true,2026-08-20T10:18:00Z\n+31612345678,true,2026-08-21T09:00:00Z","consent_affirmation":true}'
{"import_id":"555371d6-6381-471a-b218-162304ef5557","total":5,"accepted":2,"rejected_consent":1,"rejected_format":1,"duplicates":1}
HTTP 200
```

The report accounts for every row: 5 total = 2 accepted + 1 refused for
missing consent + 1 refused for format + 1 duplicate.

The onboard-and-consented-import exchange, end to end (green marks where
the consent law does its work):

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/channelapi/handlers.go (clients + imports: exactly-one-carrier law) · internal/onboarding/onboarding.go (OnboardClient, ImportRecipients, ImportRecipientsWithEvidence) · internal/events/events.go (client.onboarded, recipients.imported)
sequenceDiagram
    autonumber
    participant I as Small business's system<br/>(write key)
    participant A as Channel API<br/>front door
    participant O as Onboarding core
    participant E as Consent evidence file
    participant L as Hash-chained audit log

    I->>A: create the business (name, sender name)
    A->>A: key check, scope check, replay check
    A->>O: open the client
    O->>L: "client.onboarded"
    O-->>A: client ready
    A-->>I: 201 — business id (tenant comes from the key)

    rect rgba(15,252,90,.16)
        Note over I,A: consent is a wire precondition — exactly one proof rides along
        I->>A: send the contact list + one consent proof
        A->>O: import with proof
        alt "we have their yes on file" (affirmation)
            O->>O: accepted rows stamp "csv-upload"
        else saved consent evidence row
            O->>E: proof row must exist — its capture time stamps every accepted row
        end
        O->>O: answer every row — consented in · refused for consent ·<br/>refused for format · duplicate
        O->>L: "recipients.imported" (the per-row outcome counts)
        O-->>A: the row-by-row account
        A-->>I: 200 — total = accepted + refused-consent + refused-format + duplicates
    end

    I->>A: same list, no consent proof
    A-->>I: 422 consent-required — nothing stored, zero rows written
```

![Onboard a business and import its consented list](diagrams/seq-onboard-import.svg)

*Truth source: `internal/channelapi/handlers.go` (client-create and import;
the exactly-one-carrier law), `internal/onboarding/onboarding.go`
(`OnboardClient`, `ImportRecipients`, `ImportRecipientsWithEvidence`),
`internal/events/events.go` (`client.onboarded`, `recipients.imported`).
Every arrow matches an executed request in this guide.*

### Campaigns — draft, plan, submit, approve

`POST /api/v1/campaigns/drafts` (scope `campaigns:write`). `schedule` must
be strictly in the future. The name is a per-tenant handle: re-posting the
same name re-answers the stored draft with 200, never a second row.

```sh
$ curl -X POST $B/api/v1/campaigns/drafts \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-draft-001' \
    -d '{"name":"quiet-hours-deal","goal":"fill quiet weekday hours","audience":"consented regulars from the August upload","message":"Mango Lane Café: quiet-hour deal this week — coffee + pastry €4. Show this text.","schedule":"2026-08-29T19:21:56Z"}'
{"id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","tenant_id":"b6bbbe81-a72d-4fdb-ae15-3372490f42a7","name":"quiet-hours-deal","goal":"fill quiet weekday hours","audience":"consented regulars from the August upload","message":"Mango Lane Café: quiet-hour deal this week — coffee + pastry €4. Show this text.","schedule":"2026-08-29T19:21:56Z","state":"DRAFT","created_at":"2026-08-26T19:21:56.641857Z","updated_at":"2026-08-26T19:21:56.641857Z"}
HTTP 201
```

(Re-posting the same body under a fresh idempotency key answered `HTTP 200`
with the same stored row — executed.)

`POST /api/v1/campaigns/{id}/plan` asks the sovereign model router for the
`campaign-plan-v1` document; estimates are computed in code off the ledger
rate (`estimated_messages` = Σ segment sizes, `estimated_cost_eur` at 4
fractional digits). Executed 2026-08-26 through the model router — the day's
first-choice lane (k3) answered (trimmed: the plan is shown without the two
free-text review notes, which that lane filled with mundane budget/risk
text):

```sh
$ curl -X POST $B/api/v1/campaigns/3b6e96da-2735-4b0a-b6f7-1c1debe1f74a/plan \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Idempotency-Key: doc-plan-003'
{"campaign_id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","plan":{"schema_version":"campaign-plan-v1","goal":"fill quiet weekday hours","audience":"consented regulars from the August upload","message":"Mango Lane Café: quiet-hour deal this week — coffee + pastry €4. Show this text.","schedule":"2026-08-29T19:21:56Z","segments":[{"name":"Weekday morning regulars","size":120},{"name":"Weekday afternoon regulars","size":85}],"message_variants":["Mango Lane Café: quiet-hour deal this week — coffee + pastry €4. Show this text at the counter.","Slow hours, sweet deal at Mango Lane Café: this week, coffee + pastry for €4. Just show this text when you order."],"estimated_messages":205,"estimated_cost_eur":"6.1500","generated_at":"2026-08-26T19:23:18.187851523Z"}}
HTTP 200
```

The plan *echoes* the draft verbatim (goal/audience/message/schedule) — a
plan that drifts from its draft is refused.

`POST /api/v1/campaigns/{id}/submit` moves DRAFT → PENDING_APPROVAL:

```sh
$ curl -X POST $B/api/v1/campaigns/3b6e96da-2735-4b0a-b6f7-1c1debe1f74a/submit \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Idempotency-Key: doc-submit-001'
{"campaign_id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","state":"PENDING_APPROVAL"}
HTTP 200
```

`POST /api/v1/campaigns/{id}/approve` records the human decision — and here
the approval-gate actor law shows twice, both executed. First, the
write-scoped automation key cannot approve at all (its own scope is a
consciously delegated power):

```sh
$ curl -X POST $B/api/v1/campaigns/3b6e96da-2735-4b0a-b6f7-1c1debe1f74a/approve \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-approve-403' \
    -d '{"actor":"docs-demo"}'
{"type":"urn:ngaige:problem:v1:scope-required","title":"forbidden","status":403,"detail":"this route requires scope campaigns:approve"}
HTTP 403
```

Then the dedicated approve key, naming its human — a blank actor is refused
(`422`, detail: *"actor is required: an approval names its human"*):

```sh
$ curl -X POST $B/api/v1/campaigns/3b6e96da-2735-4b0a-b6f7-1c1debe1f74a/approve \
    -H "Authorization: Bearer $APPROVE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-approve-001' \
    -d '{"actor":"owner:glen"}'
{"campaign_id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","state":"APPROVED","approved_by":"owner:glen","approved_at":"2026-08-26T19:23:21.906320728Z"}
HTTP 200
```

**The lifecycle, in exact states.** The campaign walks three literals —
DRAFT, PENDING_APPROVAL, APPROVED (a rejection is not a state: it folds
back to DRAFT carrying its reason). Each recipient's message walks its own
machine below that: stored queued before any attempt (or terminal failed at
birth for an unusable number), sent when the gateway accepts it, and one
terminal answer — delivered, failed or expired — when the delivery receipt
lands. A stopped recipient never gets a row at all: the skip is audited as
`dispatch.suppressed` instead.

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"15px","primaryColor":"#ffffff","primaryBorderColor":"#440088","primaryTextColor":"#1b1230","lineColor":"#6b5d86","stateBkg":"#ffffff","stateBorder":"#440088","stateTextColor":"#1b1230","transitionColor":"#6b5d86","transitionLabelColor":"#1b1230","compositeBackground":"#faf8fd","compositeBorder":"#440088","compositeTitleBackground":"#f0eafa","noteBkgColor":"#eafff4","noteBorderColor":"#0FFC5A","noteTextColor":"#0b5c2e","edgeLabelBackground":"#ffffff","specialStateColor":"#1b1230","innerTitleBackground":"#f0eafa"}}}%%
%% truth: internal/campaign/service.go (State literals DRAFT / PENDING_APPROVAL / APPROVED; reject folds back to DRAFT; APPROVED terminal) · internal/dispatch/store.go (MessageState literals queued / sent / delivered / failed / expired) · internal/dispatch/service.go (queued→sent→terminal; first terminal answer stands)
stateDiagram-v2
    direction LR

    state "The campaign" as CAMP {
        [*] --> DRAFT : drafted — the name is its handle
        DRAFT --> DRAFT : plan regenerated<br/>(stays a draft)
        DRAFT --> PENDING_APPROVAL : submitted with<br/>a validated plan
        PENDING_APPROVAL --> APPROVED : a named human approves
        PENDING_APPROVAL --> DRAFT : rejected with a reason —<br/>back for rework
        APPROVED --> [*] : terminal — the approved<br/>record is immutable
        note right of APPROVED
            <b>the human gate</b><br/>approval names who and when,<br/>and nothing dispatches before it exists
        end note
    }

    state "One recipient's message" as MSG {
        [*] --> queued : row stored before any send attempt
        [*] --> failed : unusable number — terminal<br/>at birth, no gateway call
        queued --> queued : retryable refusal — attempts counted,<br/>a later run retries
        queued --> sent : gateway accepted,<br/>receipt id stored
        queued --> failed : terminal send refusal
        sent --> delivered : delivery receipt lands
        sent --> failed : failure receipt lands
        sent --> expired : expiry receipt lands
        delivered --> [*]
        failed --> [*]
        expired --> [*]
    }

    CAMP --> MSG : only APPROVED releases messages
```

![Campaign and per-recipient message lifecycles](diagrams/state-campaign.svg)

*Truth source: `internal/campaign/service.go` (the `DRAFT` /
`PENDING_APPROVAL` / `APPROVED` literals — reject folds back to DRAFT,
APPROVED is terminal), `internal/dispatch/store.go` (the `queued` / `sent`
/ `delivered` / `failed` / `expired` literals),
`internal/dispatch/service.go` (row stored before its event; retryable
stays queued; the first terminal answer stands).*

And the same journey as one exchange — draft → plan → **human approval**
(green) → gated dispatch → delivery receipts:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/campaign/service.go (CreateDraft/GeneratePlan/SubmitForApproval/Approve, state literals) · internal/dispatch/service.go (gate first, per-target STOP check, queued→sent→terminal rows, HandleReceipt) · internal/dlr/webhook.go
%% covers: send,dlr-callback
sequenceDiagram
    autonumber
    participant W as Automation<br/>(write key)
    participant H as Human approver<br/>(approve key — separate custody)
    participant A as Channel API<br/>front door
    participant C as Campaign machine
    participant D as Dispatch machine
    participant S as STOP ledger
    participant G as Sandbox SMS gateway
    participant R as Delivery-receipt listener

    W->>A: draft the campaign (name, goal, audience, message, schedule)
    A->>C: store the draft — state DRAFT
    C-->>A: "campaign.created"
    A-->>W: 201 — DRAFT

    W->>A: plan it
    A->>C: the language lanes draft a plan from the brief words only
    C->>C: plan must echo the draft verbatim ·<br/>message count and cost computed in code
    C-->>A: "plan.generated"
    A-->>W: 200 — plan for human eyes

    W->>A: submit
    A->>C: DRAFT → PENDING_APPROVAL
    A-->>W: 200 — PENDING_APPROVAL

    W->>A: try to dispatch now
    A->>D: dispatch request
    D->>C: approval gate — is there a recorded human approval?
    D-->>A: refused — nothing attempted
    A-->>W: 409 not-approved

    rect rgba(15,252,90,.16)
        Note over H,C: the human gate — a machine can prepare everything but this
        H->>A: approve, naming the human ("owner:marta")
        A->>C: scope check: this route needs its own approve scope
        C->>C: record the decision — who and when, immutable
        C-->>A: "plan.approved"
        A-->>H: 200 — APPROVED
    end

    W->>A: dispatch to three numbers (one on the STOP list)
    A->>D: approved — release for send
    D->>S: check EVERY number, inside the send path
    S-->>D: number two is stopped
    D->>D: skip number two — "dispatch.suppressed" audited, never a send
    D->>G: send the other two — each row stored first, then "dispatch.requested"
    G-->>D: accepted, with a receipt id per message — "dispatch.sent", rows sent
    D-->>A: two sent, one suppressed
    A-->>W: 200 — and it names exactly which number was skipped

    G-->>R: delivery receipt per message
    R->>D: verified receipt joins its row by receipt id
    D->>D: row goes delivered · failed · expired (the first terminal answer stands) — "dlr.received"
```

![Draft, plan, human approval, gated dispatch, delivery receipts](diagrams/seq-campaign-dispatch-dlr.svg)

*Truth source: `internal/campaign/service.go` (draft/plan/submit/approve),
`internal/dispatch/service.go` (gate first; the STOP check inside the send
path; rows queued → sent → terminal), `internal/dlr/webhook.go` +
`HandleReceipt` (verified receipts join by receipt id; the first terminal
answer stands). The scenario mirrors this guide's executed matrix
(rows 10–17).*

### Dispatch — the gated send (STOP skip law)

`POST /api/v1/dispatch-requests` (scope `campaigns:write`) runs the gated
dispatch over the sandbox SMS gateway (there is no real SMS in Phase One;
receipts are the seeded sandbox's, signed and verifiable). Two laws are
visible in the executions. First, the approval gate is consulted, never
re-implemented — dispatch before approval (executed against this same
campaign while it was still DRAFT):

```sh
$ curl -X POST $B/api/v1/dispatch-requests \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-disp-001' \
    -d '{"campaign_id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","targets":["+31612345678"]}'
{"type":"urn:ngaige:problem:v1:not-approved","title":"not approved","status":409}
HTTP 409
```

Second, after approval: the second target below is on this tenant's STOP
list (seeded as an inbound STOP the day before). The dispatch machine
checks every target against the system-of-record STOP ledger inside the
send path and skips suppressed numbers; the API's `suppressed_msisdn` list
is a read-only prediction from the same seam, in verbatim submission
order — every target, suppressed or not, is handed to the machine:

```sh
$ curl -X POST $B/api/v1/dispatch-requests \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-disp-002' \
    -d '{"campaign_id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","targets":["+31612345678","+31687654321","+447911123456"]}'
{"campaign_id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","recipients":3,"sent":2,"pending_retry":0,"terminal_failed":0,"suppressed":1,"suppressed_msisdn":["+31687654321"]}
HTTP 200
```

Three recipients, two sent, one suppressed — the STOP target was skipped by
construction, and the report names exactly which.

### Suppression — the masked STOP ledger

`GET /api/v1/suppression` (scope `suppression:read`) lists the tenant's own
STOP rows, sorted `(received_at, msisdn)` ascending. The masking law is in
the wire shape: first 3 + last 3 digits kept, every middle digit replaced
one-for-one — raw numbers never touch this read surface. (`count` always
equals `len(stops)`.) STOP rows are born at the inbound seams (a handset's
STOP text, whatever channel it arrives on) and are permanent: there is no
unsuppress path in Phase One.

```sh
$ curl $B/api/v1/suppression -H "Authorization: Bearer $WRITE_KEY"
{"stops":[{"msisdn_masked":"+316•••••321","source_channel":"sms-sandbox","received_at":"2026-08-25T08:30:00Z"}],"count":1}
HTTP 200
```

The stop path itself, green-lit — from the inbound word landing to every
future send skipping the number:

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/suppression/service.go (keyword match, durable write BEFORE acknowledgement, idempotent replay, tenant-scoped, no unstop) · internal/dispatch/service.go (STOP check first for every target; tombstone scrub) · internal/events/events.go (suppression.stopped, dispatch.suppressed)
%% covers: stop
sequenceDiagram
    autonumber
    participant P as Person's phone<br/>(any channel)
    participant N as Inbound message seam
    participant M as Suppression machine
    participant B as STOP ledger
    participant L as Hash-chained audit log
    participant D as Dispatch machine
    participant W as Message-row store

    rect rgba(15,252,90,.16)
        Note over P,M: the stop path — honoured the moment it lands
        P->>N: texts "stop" (any casing, any spacing)
        N->>M: one normalised stop signal, tenant-scoped
        M->>M: only the word STOP suppresses — "unstop" and lookalikes are no-ops
        M->>B: written durable FIRST — a stop is never silently lost
        M->>L: "suppression.stopped" — permanent, tenant-scoped, no undo path
        M-->>N: only now may the channel acknowledge
        N-->>P: acknowledged
    end

    Note over B: a repeated STOP is an honest no-op — no second row, no second event

    rect rgba(15,252,90,.16)
        Note over D,B: from now on, every future send skips this number
        D->>B: check every dispatch target, inside the send path
        B-->>D: this one is stopped
        D->>L: skip — "dispatch.suppressed" audited, never a send, no message row
        D->>W: a row from before the stop gets its number scrubbed (history stays honest)
    end
```

![Inbound STOP to suppression to every future send skipping the number](diagrams/seq-stop-suppression.svg)

*Truth source: `internal/suppression/service.go` (STOP-only keyword match;
durable write before acknowledgement; idempotent replay; tenant-scoped; no
unstop), `internal/dispatch/service.go` (the per-target check inside the
send path; the identity-scrubbed tombstone for pre-STOP rows),
`internal/events/events.go` (`suppression.stopped`,
`dispatch.suppressed`).*

### Inbound — what a customer's text does

There is no inbound route on this API in v1 — the integrator never
receives a pushed message. Inbound texts from handsets land on the
product's own shared inbox seam, and an integrator sees their *effects*
through the surfaces above: a new STOP row on the masked ledger, a new
consented subscriber in the audience. The seam adjudicates every inbound
text the same way, and the order is the law: **STOP always wins** — the
suppression/help machinery's reserved words (STOP, START, HELP, CANCEL,
END, QUIT, UNSTOP, UNSUBSCRIBE, INFO) are never treated as anyone's
keyword. Only after that does a registered keyword ("text CAKES to …")
act as an opt-in: a keyword must *lead* the message, pass the closed
grammar, and survive the conversation guard ("any CAKES?" is a question,
not a join). A stopped number's keyword is evidence-only — nothing joins,
nothing is texted back. Everything else lands nothing at all: no writes,
no events, no reply.

One inbound text, adjudicated (green marks the two consent-critical
branches — the STOP-first adjudication and the consent receipt with its
welcome):

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/growth/keyword.go (reserved set owned by the suppression/help machinery — STOP is never a keyword; lead-only grammar; the conversation guard) · internal/growth/service.go CaptureInbound (global keyword namespace routes to the owning tenant; STOP-beats-growth: a stopped sender is evidence-only keyword.captured{suppressed}; otherwise consent row stamped keyword-optin, write-through join, growth.join receipt, keyword.captured{joined}, and the warm one-thought welcome the lane sends) · internal/suppression/service.go (the STOP branch itself — see the ledger diagram above) · internal/events/events.go (keyword.captured, growth.join)
%% covers: inbound
sequenceDiagram
    autonumber
    participant P as Person's phone<br/>(any channel)
    participant N as Shared inbox seam<br/>(the product's own line)
    participant G as Keyword adjudicator<br/>(growth engine)
    participant S as STOP ledger
    participant C as Consent + audience core
    participant L as Hash-chained audit log

    P->>N: one inbound text
    N->>G: the normalised text + sender (a bad number is a loud refusal, nothing written)

    alt the word belongs to the STOP/HELP family
        Note over G,S: reserved words are never keywords — this branch never reaches a business
        G->>S: hand to the suppression/help machinery
        S-->>N: (the STOP path of the diagram above — durable first, then acknowledged)
        N-->>P: acknowledged
    else a registered keyword LEADS ("CAKES")
        G->>G: lead-only grammar + the conversation guard hold
        G->>G: the global keyword namespace names the owning business
        G->>S: is this sender stopped? (STOP beats growth)
        alt sender is stopped
            G->>L: "keyword.captured" {suppressed} — evidence only:<br/>no consent row, no join, no welcome
            G-->>N: nothing to send
        else clear to join
            rect rgba(15,252,90,.16)
                G->>C: consent row stamped "keyword-optin" + the subscriber join<br/>(a re-"CAKES" re-answers, never duplicates)
                G->>L: "growth.join" (the consent receipt) · "keyword.captured" {joined}
                G-->>N: the warm one-thought welcome — names the business,<br/>carries "reply STOP any time"
                N-->>P: welcome text
            end
        end
    else anything else (unknown word, mid-sentence mention)
        G-->>N: nothing lands — no writes, no events, no reply
    end
```

![One inbound text adjudicated — STOP first, then registered keywords, everything else no-ops](diagrams/seq-inbound-adjudication.svg)

*Truth source: `internal/growth/keyword.go` (the reserved set the
suppression/help machinery owns; lead-only grammar; the conversation
guard), `internal/growth/service.go` `CaptureInbound` (the global keyword
namespace routes to the owning tenant; STOP-beats-growth — a stopped
sender is evidence-only `keyword.captured {outcome:"suppressed"}`;
otherwise the consent row stamped `keyword-optin`, the idempotent join,
the `growth.join` receipt, `keyword.captured {outcome:"joined"}` and the
welcome line the lane sends), `internal/suppression/service.go` (the STOP
branch itself), `internal/events/events.go` (`keyword.captured`,
`growth.join`). The v1 honesty named above stands: inbound is the
product's own seam, not an integrator callback.*

### Reporting — the campaign fold

`GET /api/v1/campaigns/{id}/report` (scope `reports:read`) is the same fold
every surface serves (chat, portal, compliance centre): sent / delivered /
failed / expired / pending / suppressed plus the cost block — actual vs the
plan's estimate and a verdict. After the dispatch above:

```sh
$ curl $B/api/v1/campaigns/3b6e96da-2735-4b0a-b6f7-1c1debe1f74a/report \
    -H "Authorization: Bearer $WRITE_KEY"
{"campaign_id":"3b6e96da-2735-4b0a-b6f7-1c1debe1f74a","tenant_id":"b6bbbe81-a72d-4fdb-ae15-3372490f42a7","campaign_name":"quiet-hours-deal","sent":2,"delivered":0,"failed":0,"expired":0,"pending":0,"suppressed":1,"cost":{"basis":"sent","messages":2,"rate_eur":"0.0300","actual_cost_eur":"0.0600","estimated_cost_eur":"6.1500","delta_cost_eur":"-6.0900","verdict":"under"}}
HTTP 200
```

A campaign that never sent reports honestly zeroed, with null estimate
fields and `verdict:"no_estimate"` (executed on a second, unsent draft):

```json
{"campaign_id":"4752529e-cc67-48d7-9066-4063d8508242","campaign_name":"empty-report-demo","sent":0,"delivered":0,"failed":0,"expired":0,"pending":0,"suppressed":0,"cost":{"basis":"sent","messages":0,"rate_eur":"0.0300","actual_cost_eur":"0.0000","estimated_cost_eur":null,"delta_cost_eur":null,"verdict":"no_estimate"}}
```

How the fold is built (the integrity all-clear in green — a broken listing
is refused, never folded into a plausible-looking wrong report):

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/reporting/service.go (one pure fold over the scoped event listing; ErrIntegrity — a broken listing is never folded; the six counters; cost computed in code off the ledger rate) · internal/events/events.go (dispatch.*, dlr.received)
sequenceDiagram
    autonumber
    participant R as Reading app<br/>(reports key)
    participant A as Channel API<br/>front door
    participant F as Reporting fold
    participant L as Hash-chained audit log

    R->>A: show me the campaign's report
    A->>A: key check, scope check
    A->>F: fold this campaign's story
    F->>L: the campaign's events, in chain order
    rect rgba(15,252,90,.16)
        Note over F,L: integrity all-clear, or no report at all
        F->>F: verify — a duplicate or broken listing is refused,<br/>never folded into a plausible-looking wrong report
    end
    L-->>F: "sent" / "failed" / "suppressed" / delivery receipts
    F->>F: six counts — sent · delivered · failed · expired · pending · suppressed
    F->>F: cost in code — sent count × the ledger rate, 4 decimal places
    F->>F: actual vs the plan's estimate — difference and verdict<br/>(over · under · no estimate)
    F-->>A: the folded report
    A-->>R: 200 — the same fold every surface serves
```

![The reporting fold over the hash-chained audit log](diagrams/seq-reporting-fold.svg)

*Truth source: `internal/reporting/service.go` (one pure fold over the
scoped event listing; `ErrIntegrity` — a broken listing is never folded;
the six counters; cost computed in code off the ledger rate),
`internal/events/events.go` (`dispatch.*`, `dlr.received`).*

### Conversation — one synchronous turn

`POST /api/v1/conversations/{conversation_id}/messages` (scope
`conversation:write`) is the v1-minimal conversation seam: text in, one
text reply out, over the same sovereign model router the assistant speaks
through. `occurred_at` is optional (defaults to the service clock).
Executed 2026-08-26 (the reply body really came from the router's
first-choice lane of the day, k3 — it answered honestly that it would not
invent capabilities):

```sh
$ curl -X POST $B/api/v1/conversations/conv-demo-001/messages \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-conv-001' \
    -d '{"text":"Hi — I run Mango Lane Café. What can this API do for me in one sentence?"}'
{"conversation_id":"conv-demo-001","replies":[{"type":"text","body":"Honestly, I don't have verified details about this API's specific features, so I can't accurately sum it up in one sentence without guessing — if you tell me what you're hoping to do (e.g., customer messaging, order notifications), I can help you figure out whether it's a fit, or point you toward the official product docs."}],"handled_at":"2026-08-26T19:23:35.154871507Z"}
HTTP 200
```

Media/voice are known-but-unimplemented in v1: carrying any `media` element
is an honest refusal, never a silent text-only fall-through:

```sh
$ curl -X POST $B/api/v1/conversations/conv-demo-001/messages \
    -H "Authorization: Bearer $WRITE_KEY" \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: doc-conv-002' \
    -d '{"text":"listen to this","media":[{"kind":"voice"}]}'
{"type":"urn:ngaige:problem:v1:unsupported-media","title":"unsupported media","status":422,"detail":"media/voice are not served by channel API v1; no silent text-only fall-through exists"}
HTTP 422
```

One turn, pictured — including the router's silent lane failover behind the
answer (lanes named are **today's order, 2026-08-27 code-default config**; the
hierarchy is re-picked as better models land, and callers never learn which
lane spoke):

```mermaid
%%{init: {"theme":"base","themeVariables":{"background":"#ffffff","fontFamily":"-apple-system, Segoe UI, Helvetica, Arial, sans-serif","fontSize":"14px","actorBkg":"#ffffff","actorBorder":"#440088","actorTextColor":"#1b1230","signalColor":"#6b5d86","signalTextColor":"#1b1230","labelTextColor":"#1b1230","noteBkgColor":"#f0eafa","noteBorderColor":"#440088","noteTextColor":"#1b1230","activationBkgColor":"#f0eafa","activationBorderColor":"#440088","sequenceNumberColor":"#ffffff"}}}%%
%% truth: internal/channelapi/handlers.go (conversation route: one text turn; unsupported-media 422) · cmd/ngaige/main.go (channelAPIConversation over the lane router, pinned system prompt) · internal/agent/llm/config.go (DefaultModel k3, DefaultFallbacks glm5.2, glm5.3-flash, qwen3-coder-next)
sequenceDiagram
    autonumber
    participant C as Small business's system<br/>(conversation key)
    participant A as Channel API<br/>front door
    participant T as Answer adapter<br/>(the assistant seam)
    participant K as Lane: k3
    participant G as Lane: glm5.2
    participant F as Lane: glm5.3-flash
    participant Q as Lane: qwen — local, last resort

    C->>A: one message on a conversation
    A->>A: key check, scope check, replay check
    A->>T: the text (tenant from the key, conversation from the path)
    T->>T: pinned system prompt — answer plainly, honestly,<br/>never invent capabilities
    T->>K: try k3 first
    K-->>T: no answer (lane down or silent)
    T->>G: try glm5.2 next
    G-->>T: answer
    Note over T,Q: silent failover — the caller never hears which lane answered —<br/>glm5.3-flash and qwen wait behind glm5.2, and only if<br/>every lane is down does an honest canned line answer
    T-->>A: one text reply
    A-->>C: 200 — one text reply

    C->>A: same route, with a voice clip attached
    A-->>C: 422 unsupported-media — an honest refusal,<br/>never a silent text-only fall-through
```

![One conversation turn over the sovereign model router's lane chain](diagrams/seq-conversation-turn.svg)

*Truth source: `internal/channelapi/handlers.go` (one text turn; the
honest unsupported-media 422), `cmd/ngaige/main.go`
(`channelAPIConversation` — the pinned plain-honest system prompt over the
lane router), `internal/agent/llm/config.go` (`DefaultModel` k3,
`DefaultFallbacks` glm5.2 → glm5.3-flash → qwen3-coder-next — **today's
dated config example**; the router and its hierarchy order are the
architecture, the names are not; silent to the caller, the canned line only
after total exhaustion). The k3-then-glm5.2 failover drawn here is one
illustrative execution — the pinned law is the router's order and the
silence, not which lane happens to answer.*

### Cross-tenant and method refusals (verified for completeness)

A second tenant's well-formed admin key asking for this tenant's campaign
answers the same uniform 404 bytes as a never-existing path (verified
byte-identical across the two executions):

```sh
$ curl -X POST $B/api/v1/campaigns/3b6e96da-2735-4b0a-b6f7-1c1debe1f74a/submit \
    -H "Authorization: Bearer $OTHER_TENANT_ADMIN_KEY" -H 'Idempotency-Key: doc-foreign-001'
{"type":"about:blank","title":"not found","status":404}
HTTP 404

$ curl -X DELETE $B/api/v1/suppression -H "Authorization: Bearer $ADMIN_KEY" -D -
HTTP/1.1 405 Method Not Allowed
Allow: GET
Content-Type: application/problem+json; charset=utf-8
{"type":"about:blank","title":"method not allowed","status":405}
```

## Compliance in the contract

Three laws are enforced **server-side inside this API**, identically for
every caller — a friendly UI client, a curl-wielding integrator and a
hostile script see the same machine:

1. **Consent carrier law.** `POST /api/v1/imports` is unwritable without
   exactly one consent proof carrier (`consent_affirmation` XOR
   `consent_evidence_ref`). No carrier → 422 `consent-required` and *zero*
   import-port calls happen; both → 422. Rows marked `consent:false` are
   refused individually and counted in the report. Consent is a wire-shape
   precondition, not a promise.
2. **STOP skip law.** The dispatch machine checks every target against the
   tenant's durable STOP ledger inside the send path; suppressed numbers
   are skipped by construction and the row is permanent (no unsuppress
   path exists). The API's `suppressed_msisdn` is a *read-only prediction*
   from the same seam — the API never re-implements the skip, it just
   makes the machine's own enforcement legible. The STOP ledger read
   surface is masked; raw numbers never leave it.
3. **Approval-gate actor law.** Dispatch requires an APPROVED campaign
   (409 `not-approved` otherwise — the gate is consulted, never
   re-implemented). Approving requires its own `campaigns:approve` scope
   (403 for a write-only key) and a non-blank `actor` naming the human
   (422 otherwise); the actor is echoed verbatim into the response and the
   audit trail. No path in this API lets a machine mint an approval: the
   scope split exists precisely so automation keys can do everything
   *except* the human-decision step.

Tenant isolation and no-oracle auth back all three: foreign objects are
uniform 404 (existence never oracles), and every auth failure class is the
same 401 bytes.

## Appendix A — execution log (LIVE-means-verified evidence)

Every example above was executed 2026-08-26 ~19:21–19:24 UTC against a
local boot on the workbox, dev PostgreSQL 16 (`/etc/ngaige/dev.env` DSN),
repo @ `642d0fb`, binary built with go1.27.0. Key secrets redacted; the
scratch tenant material was destroyed afterwards (Appendix B). rc = exit
code; all curl outputs pasted above are verbatim response bytes.

```sh
# build + boot
$ go build -o .scratch/ngaige ./cmd/ngaige                          # rc=0
$ ngaige migrate                                                    # rc=0 ("migrate: schema current")
$ env DATABASE_URL=<dev DSN> NGAIGE_HTTP_ADDR=127.0.0.1:18099 \
      NGAIGE_CHANNELAPI_ENABLED=true \
      NGAIGE_CHANNELAPI_KEY_PEPPER=<temp 64-hex> \
      NGAIGE_LLM_BASE_URL=http://127.0.0.1:4000/v1 \
      NGAIGE_LLM_API_KEY=<estate router key> \
      NGAIGE_LLM_TRY_TIMEOUT=180s \
      NGAIGE_SMS_COST_PER_MESSAGE_EUR=0.0300 \
      ngaige api &                                                  # boot banner:
  "api: CHANNEL API v1 mounted at /api/v1/ (per-tenant keys in DB, 0 at rest; ...)"
  (plus the fail-closed WARNs for the unset portal/compliance-centre secrets —
  correct closed state for a channel-API-only boot)
$ curl $B/healthz                                                   # 200

# seed-first-admin
$ ngaige apikey-seed --tenant b6bbbe81-a72d-4fdb-ae15-3372490f42a7 \
      --scopes admin:all                                            # rc=0, plaintext once
```

Executed curl matrix (base `http://127.0.0.1:18099`, in execution order;
the doc sections quote these response bytes):

| # | Command (abbrev.) | Status | Note |
| --- | --- | --- | --- |
| 1 | `POST /api/v1/clients` (doc-client-001) | 201 | client `6fd95670…` |
| 2 | identical repeat of #1 | 201 | `X-Idempotent-Replay: true`, byte-identical body |
| 3 | `POST /api/v1/imports` without carrier | 422 | `urn:ngaige:problem:v1:consent-required` |
| 4 | `POST /api/v1/imports` + `consent_affirmation:true` | 200 | total 5 / accepted 2 / rejected_consent 1 / rejected_format 1 / duplicates 1 |
| 5 | `POST /api/v1/keys` (write-scoped set) | 201 | minted write key, plaintext once |
| 6 | `POST /api/v1/keys` (`campaigns:approve` only) | 201 | minted approve key, plaintext once |
| 7 | `POST /api/v1/dispatch-requests` unknown campaign id | 404 | uniform bytes |
| 8 | `POST /api/v1/campaigns/drafts` (quiet-hours-deal) | 201 | campaign `3b6e96da…` |
| 9 | same draft re-posted, fresh idem key | 200 | idempotent-by-name re-answer, same stored row |
| 10 | `POST /api/v1/dispatch-requests` on DRAFT campaign | 409 | `urn:ngaige:problem:v1:not-approved` |
| 11 | `POST …/plan` | 500 → 500 → 200 | first two attempts failed without `NGAIGE_SMS_COST_PER_MESSAGE_EUR` / with the 5s default lane try-timeout (see mismatches below); third succeeded over the real k3 lane (lane log `outcome ok`, 5.8s) |
| 12 | `POST …/submit` | 200 | `PENDING_APPROVAL` |
| 13 | `POST …/approve` with write key | 403 | `scope-required`, detail names `campaigns:approve` |
| 14 | `POST …/approve` with approve key | 200 | `APPROVED`, `approved_by:"owner:glen"` |
| 15 | `GET /api/v1/suppression` | 200 | 1 masked row, `count=1=len(stops)` |
| 16 | `POST /api/v1/dispatch-requests` (3 targets, 1 stopped) | 200 | sent 2, suppressed 1, `suppressed_msisdn` names it |
| 17 | `GET …/report` (dispatched campaign) | 200 | sent 2, suppressed 1, verdict `under` |
| 18 | `POST /api/v1/campaigns/drafts` (empty-report-demo) | 201 | second campaign `4752529e…` |
| 19 | `GET …/report` (never-sent campaign) | 200 | all-zero fold, null estimates, `no_estimate` |
| 20 | `POST /api/v1/conversations/conv-demo-001/messages` | 200 | real k3 reply, one `{type:"text"}` |
| 21 | same route with a `media` element | 422 | `unsupported-media` |
| 22 | `GET /api/v1/suppression` with no Authorization | 401 | pinned uniform bytes |
| 23 | same route, well-formed key, wrong secret | 401 | **byte-identical** to #22 (verified by diff) |
| 24 | `POST /api/v1/clients` without Idempotency-Key | 400 | `idempotency-key-required` |
| 25 | `POST /api/v1/clients`, doc-client-001 + different body | 409 | `idempotency-conflict` |
| 26 | `POST /api/v1/clients` with the approve-only key | 403 | `scope-required`, detail names `onboarding:write` |
| 27 | `POST …/approve` with `{"actor":"  "}` | 422 | "an approval names its human" |
| 28 | `GET /api/v1/nope` | 404 | uniform bytes — **byte-identical** to #7/#29 (verified) |
| 29 | `DELETE /api/v1/suppression` | 405 | `Allow: GET`, problem+json |
| 30 | `POST …/submit` with a second tenant's admin key | 404 | foreign object, uniform bytes |

Plus `ngaige apikey-seed` for the second tenant (rc=0) used for #30, and
one direct `INSERT INTO suppression_stops …` (rc=0) to seed the inbound
STOP row the STOP-skip example shows (stops are born at the inbound seams;
the read and skip laws then observably held).

Total: **30 HTTP executions + 2 CLI acts + 1 SQL seed**, all captured with
rc; every refusal shape in this guide is from this run.

### Doc-vs-impl mismatches found during execution (reported, not patched)

1. **Cost-rate config gaps surface as a bare 500.** With
   `NGAIGE_SMS_COST_PER_MESSAGE_EUR` unset (the app default is empty),
   `POST /api/v1/campaigns/{id}/plan` answers
   `{"type":"about:blank","title":"internal error","status":500}` — the
   cost-rate parse failure escapes the campaign refusal taxonomy, so an
   integrator (or a boot without the env) sees a generic 500 instead of a
   boot-time refusal naming the env or a mapped problem. Owner: campaign
   cost computation / config default.
2. **Default lane try-timeout is shorter than real k3 latency.** With
   `NGAIGE_LLM_TRY_TIMEOUT` at its 5s default, every lane try timed out
   (k3's observed chat latency on this prompt class is 5.5–6s), so `plan`
   and `conversation` answered 500 with all-lanes-down. Setting
   `NGAIGE_LLM_TRY_TIMEOUT=180s` restored both. Staging droplets set this
   in `/etc/product/app.env`; worth pinning a floor above observed p50
   lane latency or documenting the local-boot requirement. Owner: llm
   router defaults.

Everything else matched the OpenAPI contract and the executable spec
exactly — including byte-identical 401/404 uniformity laws.

## Appendix B — scratch hygiene

The verification boot used throwaway material: two scratch tenant UUIDs
(`b6bbbe81-a72d-4fdb-ae15-3372490f42a7` and one more for the cross-tenant
probe), three minted keys, one temporary pepper, one seeded STOP row. After
the run the API process was killed and the dev scratch database was reset
(dropped + recreated), so no key or tenant from this guide resolves
anywhere. Redacted secrets in this document are therefore dead values even
where partially visible.
