broadcast-ruby 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/SDK-COVERAGE.md ADDED
@@ -0,0 +1,469 @@
1
+ # broadcast-ruby — coverage
2
+
3
+ What this gem covers, what it deliberately doesn't, and what the Broadcast
4
+ application can do that no client can reach.
5
+
6
+ > **This file describes the Ruby gem specifically.** Since 2026-07-27 there are
7
+ > three sibling SDKs — `broadcast-node`, `broadcast-python`, `broadcast-php` —
8
+ > all at 104/104 against the same spec. The endpoint map below applies to all
9
+ > of them (the paths are identical); only the method names differ. Per-language
10
+ > status, including the caveat that PHP's suite has never been run, is in
11
+ > [SDK-TODO.md](SDK-TODO.md#status--2026-07-27).
12
+
13
+ **Headline: 104/104 API operations (100%).**
14
+
15
+ That number is narrower than it sounds, and [Limits of the 100%](#limits-of-the-100)
16
+ explains exactly what it does not measure. Read that section before quoting the
17
+ figure.
18
+
19
+ - Gem version: **0.3.0**
20
+ - API version: **2.20.0** (75 paths, 104 operations)
21
+ - Generated by: `bin/rails "openapi:coverage[../broadcast-ruby]"` in the
22
+ `broadcast` repo
23
+ - Verified: self-hosted and SaaS mode (the `/api` surface is identical in both)
24
+
25
+ ---
26
+
27
+ ## Contents
28
+
29
+ 1. [How coverage is measured](#how-coverage-is-measured)
30
+ 2. [Endpoint map](#endpoint-map) — every operation → the method that calls it
31
+ 3. [Transport coverage](#transport-coverage) — the protocol, not the endpoints
32
+ 4. [Webhook coverage](#webhook-coverage) — inbound
33
+ 5. [Not in the API at all](#not-in-the-api-at-all) — app features no client can reach
34
+ 6. [Demo instances answer differently](#demo-instances-answer-differently)
35
+ 7. [Limits of the 100%](#limits-of-the-100)
36
+
37
+ ---
38
+
39
+ ## How coverage is measured
40
+
41
+ The `broadcast` repo generates an OpenAPI 3.1 document from `config/routes.rb`
42
+ plus the `*_PARAM_KEYS` constants each controller declares, then scans this
43
+ gem's source for the paths it calls.
44
+
45
+ ```bash
46
+ # in the broadcast repo
47
+ bin/rails openapi:generate
48
+ bin/rails "openapi:coverage[../broadcast-ruby]"
49
+ ```
50
+
51
+ Two details worth knowing when reading the map below:
52
+
53
+ - **PATCH is canonical.** Rails' `resources` routes `update` to both PATCH and
54
+ PUT. They are one operation; the generator drops the PUT alias. Counting both
55
+ would inflate the denominator by 8 and make this gem look 8 endpoints short.
56
+ - **Metaprogrammed endpoints are declared.** The 18 migration collections are
57
+ defined by `define_method` over `Migration::COLLECTIONS`, so no path literal
58
+ exists to scan. They are listed in `.api-coverage.yml` and marked
59
+ *(generated)* below. That file is produced from the gem's own constants — it
60
+ is not a hand-written promise.
61
+
62
+ ---
63
+
64
+ ## Endpoint map
65
+
66
+ Every operation in the API, and the gem method that issues it. Grouped as the
67
+ spec groups them.
68
+
69
+ ### Discovery — 4/4
70
+
71
+ Introspection endpoints. Useful as a deploy-time smoke check and as the entry
72
+ point for agents and CLIs.
73
+
74
+ | Method | Path | Gem |
75
+ |---|---|---|
76
+ | GET | `/api/v1/whoami` | `Discovery#whoami` — also `client.whoami` |
77
+ | GET | `/api/v1/status` | `Discovery#status` — also `client.status` |
78
+ | GET | `/api/v1/prime` | `Discovery#prime` — also `client.prime` |
79
+ | GET | `/api/v1/skill` | `Discovery#skill` — also `client.skill`; returns a String, not a Hash |
80
+
81
+ ### Subscribers — 11/11
82
+
83
+ | Method | Path | Gem |
84
+ |---|---|---|
85
+ | GET | `/api/v1/subscribers` | `Subscribers#list` |
86
+ | GET | `/api/v1/subscribers/find` | `Subscribers#find` |
87
+ | POST | `/api/v1/subscribers` | `Subscribers#create` |
88
+ | PATCH | `/api/v1/subscribers` | `Subscribers#update` |
89
+ | POST | `/api/v1/subscribers/add_tag` | `Subscribers#add_tags` |
90
+ | DELETE | `/api/v1/subscribers/remove_tag` | `Subscribers#remove_tags` |
91
+ | POST | `/api/v1/subscribers/activate` | `Subscribers#activate` |
92
+ | POST | `/api/v1/subscribers/deactivate` | `Subscribers#deactivate` |
93
+ | POST | `/api/v1/subscribers/unsubscribe` | `Subscribers#unsubscribe` |
94
+ | POST | `/api/v1/subscribers/resubscribe` | `Subscribers#resubscribe` |
95
+ | POST | `/api/v1/subscribers/redact` | `Subscribers#redact` |
96
+
97
+ Subscribers are addressed by **email**, not id — there is no
98
+ `/api/v1/subscribers/{id}`. `list` supports eight filters (`is_active`,
99
+ `source`, `created_after`, `created_before`, `tags`, `email` partial match,
100
+ `confirmation_status`, `custom_data` JSONB containment).
101
+
102
+ There is no destroy endpoint by design; `redact` is the GDPR erasure path and
103
+ preserves aggregate statistics.
104
+
105
+ ### Broadcasts — 8/8, plus statistics 3/3
106
+
107
+ | Method | Path | Gem |
108
+ |---|---|---|
109
+ | GET | `/api/v1/broadcasts` | `Broadcasts#list` |
110
+ | GET | `/api/v1/broadcasts/{id}` | `Broadcasts#get_broadcast` |
111
+ | POST | `/api/v1/broadcasts` | `Broadcasts#create` |
112
+ | PATCH | `/api/v1/broadcasts/{id}` | `Broadcasts#update` |
113
+ | DELETE | `/api/v1/broadcasts/{id}` | `Broadcasts#delete` |
114
+ | POST | `/api/v1/broadcasts/{id}/send_broadcast` | `Broadcasts#send_broadcast` |
115
+ | POST | `/api/v1/broadcasts/{id}/schedule_broadcast` | `Broadcasts#schedule` |
116
+ | POST | `/api/v1/broadcasts/{id}/cancel_schedule` | `Broadcasts#cancel_schedule` |
117
+ | GET | `/api/v1/broadcasts/{id}/statistics` | `Broadcasts#statistics` |
118
+ | GET | `/api/v1/broadcasts/{id}/statistics/timeline` | `Broadcasts#statistics_timeline` |
119
+ | GET | `/api/v1/broadcasts/{id}/statistics/links` | `Broadcasts#statistics_links` |
120
+
121
+ ### Sequences — 8/8, plus steps 6/6
122
+
123
+ | Method | Path | Gem |
124
+ |---|---|---|
125
+ | GET | `/api/v1/sequences` | `Sequences#list` |
126
+ | GET | `/api/v1/sequences/{id}` | `Sequences#get_sequence` |
127
+ | POST | `/api/v1/sequences` | `Sequences#create` |
128
+ | PATCH | `/api/v1/sequences/{id}` | `Sequences#update` |
129
+ | DELETE | `/api/v1/sequences/{id}` | `Sequences#delete` |
130
+ | POST | `/api/v1/sequences/{id}/add_subscriber` | `Sequences#add_subscriber` |
131
+ | DELETE | `/api/v1/sequences/{id}/remove_subscriber` | `Sequences#remove_subscriber` |
132
+ | GET | `/api/v1/sequences/{id}/list_subscribers` | `Sequences#list_subscribers` |
133
+ | GET | `/api/v1/sequences/{id}/steps` | `Sequences#list_steps` |
134
+ | GET | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#get_step` |
135
+ | POST | `/api/v1/sequences/{id}/steps` | `Sequences#create_step` |
136
+ | PATCH | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#update_step` |
137
+ | DELETE | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#delete_step` |
138
+ | POST | `/api/v1/sequences/{id}/steps/{step_id}/move` | `Sequences#move_step` |
139
+
140
+ Steps form a tree linked by `parent_id`, not a flat list — `move_step` reparents
141
+ rather than reorders.
142
+
143
+ ### Transactionals — 2/2
144
+
145
+ | Method | Path | Gem |
146
+ |---|---|---|
147
+ | POST | `/api/v1/transactionals` | `Transactionals#create` — also `client.send_email` |
148
+ | GET | `/api/v1/transactionals/{id}` | `Transactionals#get_transactional` — also `client.get_email` |
149
+
150
+ The only endpoint accepting `Idempotency-Key`. Also reachable through
151
+ ActionMailer via the `:broadcast` delivery method.
152
+
153
+ ### Templates — 5/5
154
+
155
+ | Method | Path | Gem |
156
+ |---|---|---|
157
+ | GET | `/api/v1/templates` | `Templates#list` |
158
+ | GET | `/api/v1/templates/{id}` | `Templates#get_template` |
159
+ | POST | `/api/v1/templates` | `Templates#create` |
160
+ | PATCH | `/api/v1/templates/{id}` | `Templates#update` |
161
+ | DELETE | `/api/v1/templates/{id}` | `Templates#delete` |
162
+
163
+ ### Segments — 5/5
164
+
165
+ | Method | Path | Gem |
166
+ |---|---|---|
167
+ | GET | `/api/v1/segments` | `Segments#list` |
168
+ | GET | `/api/v1/segments/{id}` | `Segments#get_segment` |
169
+ | POST | `/api/v1/segments` | `Segments#create` |
170
+ | PATCH | `/api/v1/segments/{id}` | `Segments#update` |
171
+ | DELETE | `/api/v1/segments/{id}` | `Segments#delete` |
172
+
173
+ `get_segment` returns the segment's *matching subscribers*, paginated, and
174
+ recounts on read — it is a query, not a cheap fetch.
175
+
176
+ ### Opt-in forms — 8/8
177
+
178
+ | Method | Path | Gem |
179
+ |---|---|---|
180
+ | GET | `/api/v1/opt_in_forms` | `OptInForms#list` |
181
+ | GET | `/api/v1/opt_in_forms/{id}` | `OptInForms#get_opt_in_form` |
182
+ | POST | `/api/v1/opt_in_forms` | `OptInForms#create` |
183
+ | PATCH | `/api/v1/opt_in_forms/{id}` | `OptInForms#update` |
184
+ | DELETE | `/api/v1/opt_in_forms/{id}` | `OptInForms#delete` |
185
+ | GET | `/api/v1/opt_in_forms/{id}/analytics` | `OptInForms#analytics` |
186
+ | POST | `/api/v1/opt_in_forms/{id}/variants` | `OptInForms#create_variant` |
187
+ | POST | `/api/v1/opt_in_forms/{id}/duplicate` | `OptInForms#duplicate` |
188
+
189
+ `list` returns main forms only — A/B variants are excluded.
190
+
191
+ ### Email servers — 7/7
192
+
193
+ | Method | Path | Gem |
194
+ |---|---|---|
195
+ | GET | `/api/v1/email_servers` | `EmailServers#list` |
196
+ | GET | `/api/v1/email_servers/{id}` | `EmailServers#get_email_server` |
197
+ | POST | `/api/v1/email_servers` | `EmailServers#create` |
198
+ | PATCH | `/api/v1/email_servers/{id}` | `EmailServers#update` |
199
+ | DELETE | `/api/v1/email_servers/{id}` | `EmailServers#delete` |
200
+ | POST | `/api/v1/email_servers/{id}/test_connection` | `EmailServers#test_connection` |
201
+ | POST | `/api/v1/email_servers/{id}/copy_to_channel` | `EmailServers#copy_to_channel` — admin token |
202
+
203
+ `update` carries a **credential-redaction guard**: the API returns credential
204
+ fields bullet-masked, and the gem strips values matching that shape out of the
205
+ payload so a naive fetch-modify-save cannot overwrite a real SMTP password with
206
+ `••••••••`. This is behaviour the API does not provide — it is the gem
207
+ protecting you from a data-loss bug.
208
+
209
+ ### Webhook endpoints — 7/7
210
+
211
+ | Method | Path | Gem |
212
+ |---|---|---|
213
+ | GET | `/api/v1/webhook_endpoints` | `WebhookEndpoints#list` |
214
+ | GET | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#get_endpoint` |
215
+ | POST | `/api/v1/webhook_endpoints` | `WebhookEndpoints#create` |
216
+ | PATCH | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#update` |
217
+ | DELETE | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#delete` |
218
+ | POST | `/api/v1/webhook_endpoints/{id}/test` | `WebhookEndpoints#test` |
219
+ | GET | `/api/v1/webhook_endpoints/{id}/deliveries` | `WebhookEndpoints#deliveries` |
220
+
221
+ The `secret` is returned once, on create, and never again.
222
+
223
+ ### Autopilot — 10/10
224
+
225
+ AI-generated newsletters. Requires the `autopilot_read` / `autopilot_write`
226
+ token permissions.
227
+
228
+ | Method | Path | Gem |
229
+ |---|---|---|
230
+ | GET | `/api/v1/autopilots` | `Autopilots#list` |
231
+ | GET | `/api/v1/autopilots/{id}` | `Autopilots#get_autopilot` |
232
+ | POST | `/api/v1/autopilots` | `Autopilots#create` |
233
+ | PATCH | `/api/v1/autopilots/{id}` | `Autopilots#update` |
234
+ | DELETE | `/api/v1/autopilots/{id}` | `Autopilots#delete` |
235
+ | POST | `/api/v1/autopilots/{id}/activate` | `Autopilots#activate` |
236
+ | POST | `/api/v1/autopilots/{id}/pause` | `Autopilots#pause` |
237
+ | POST | `/api/v1/autopilots/{id}/deactivate` | `Autopilots#deactivate` |
238
+ | POST | `/api/v1/autopilots/{id}/trigger_run` | `Autopilots#trigger_run` |
239
+ | GET | `/api/v1/autopilots/{id}/runs` | `Autopilots#runs` |
240
+
241
+ `openrouter_api_key` is never returned — it is encrypted at rest, and `show`
242
+ reports only `api_key_configured`. `update` carries the same **credential
243
+ redaction guard** as email servers: a bullet-masked key is stripped from the
244
+ payload so a fetch-modify-save cannot destroy the stored credential.
245
+
246
+ `activate` requires an active source, an API key, and a model; it returns 422
247
+ naming the missing prerequisites. `trigger_run` returns 202 — generation is
248
+ asynchronous, so poll `runs`.
249
+
250
+ ### Migration / export — 20/20
251
+
252
+ Admin tokens only. `broadcast_channel_id` is required on every call; set it once
253
+ on the client and the gem attaches it.
254
+
255
+ | Method | Path | Gem |
256
+ |---|---|---|
257
+ | GET | `/api/migration/v1/manifest` | `Migration#manifest` |
258
+ | GET | `/api/migration/v1/file_assets/{id}/download` | `Migration#download_file_asset` — raw bytes |
259
+ | GET | `/api/migration/v1/channels` | *(generated)* `Migration#channels` |
260
+ | GET | `/api/migration/v1/subscribers` | *(generated)* `Migration#subscribers` |
261
+ | GET | `/api/migration/v1/templates` | *(generated)* `Migration#templates` |
262
+ | GET | `/api/migration/v1/segments` | *(generated)* `Migration#segments` |
263
+ | GET | `/api/migration/v1/sequences` | *(generated)* `Migration#sequences` |
264
+ | GET | `/api/migration/v1/email_servers` | *(generated)* `Migration#email_servers` |
265
+ | GET | `/api/migration/v1/opt_in_forms` | *(generated)* `Migration#opt_in_forms` |
266
+ | GET | `/api/migration/v1/broadcasts` | *(generated)* `Migration#broadcasts` |
267
+ | GET | `/api/migration/v1/outbound_receipts` | *(generated)* `Migration#outbound_receipts` |
268
+ | GET | `/api/migration/v1/webhook_endpoints` | *(generated)* `Migration#webhook_endpoints` |
269
+ | GET | `/api/migration/v1/tokens` | *(generated)* `Migration#tokens` |
270
+ | GET | `/api/migration/v1/suppressions` | *(generated)* `Migration#suppressions` |
271
+ | GET | `/api/migration/v1/tags` | *(generated)* `Migration#tags` |
272
+ | GET | `/api/migration/v1/users` | *(generated)* `Migration#users` |
273
+ | GET | `/api/migration/v1/link_redirects` | *(generated)* `Migration#link_redirects` |
274
+ | GET | `/api/migration/v1/link_clicks` | *(generated)* `Migration#link_clicks` |
275
+ | GET | `/api/migration/v1/subscriber_histories` | *(generated)* `Migration#subscriber_histories` |
276
+ | GET | `/api/migration/v1/file_assets` | *(generated)* `Migration#file_assets` |
277
+
278
+ `Migration#each_record(:collection)` pages any of them automatically, advancing
279
+ by the page size the **server granted** rather than the one requested (the
280
+ server clamps `limit` to 250).
281
+
282
+ ---
283
+
284
+ ## Transport coverage
285
+
286
+ Endpoint coverage is the easy half. These are the protocol behaviours the API
287
+ expects a well-behaved client to implement — and where a client can look
288
+ complete while quietly losing information.
289
+
290
+ | Capability | Status | Gem surface |
291
+ |---|---|---|
292
+ | Bearer auth | ✅ | `api_token:` / `BROADCAST_API_TOKEN` |
293
+ | Explicit host, no default | ✅ | `host:` / `BROADCAST_HOST`, required |
294
+ | `User-Agent` attribution | ✅ | `broadcast-ruby/<version>` |
295
+ | **`warnings` array** | ✅ | `result.warnings`, `warnings_mode:` `:log`/`:raise`/`:ignore` |
296
+ | **`Idempotency-Key`** | ✅ | `transactionals.create(idempotency_key:)` |
297
+ | **`Idempotency-Replayed`** | ✅ | `result.idempotent_replay?` |
298
+ | **Rate-limit headers** | ✅ | `result.rate_limit` — limit, remaining, reset |
299
+ | **429 + `Retry-After`** | ✅ | Retried, bounded by `max_retry_delay` |
300
+ | Retry on 5xx and timeout | ✅ | `retry_attempts`, `retry_delay` |
301
+ | Typed errors | ✅ | 401/403/404/409/422/429/5xx each distinct |
302
+ | Redirect handling | ✅ | Same-host GET follows; writes and cross-host refuse |
303
+ | Raw/text responses | ✅ | `client.skill`, `download_file_asset` |
304
+ | Channel scoping | ✅ | `broadcast_channel_id:`, `with_channel(id) { }` |
305
+ | Debug logging | ✅ | Credentials redacted |
306
+ | Pagination helper | ⚠️ Migration only | `each_record`; v1 endpoints page manually |
307
+ | ActionMailer integration | ✅ | `:broadcast` delivery method + Railtie |
308
+
309
+ Error mapping in full:
310
+
311
+ | HTTP | Exception |
312
+ |---|---|
313
+ | 401 | `Broadcast::AuthenticationError` |
314
+ | 403 | `Broadcast::AuthorizationError` |
315
+ | 404 | `Broadcast::NotFoundError` |
316
+ | 409 | `Broadcast::ConflictError` — idempotency replay in flight |
317
+ | 422 | `Broadcast::ValidationError` |
318
+ | 429 | `Broadcast::RateLimitError` — carries `#retry_after` |
319
+ | 5xx | `Broadcast::APIError` |
320
+ | — | `Broadcast::TimeoutError`, `ConfigurationError`, `WarningError`, `DeliveryError` |
321
+
322
+ ---
323
+
324
+ ## Webhook coverage
325
+
326
+ Inbound webhooks — the app calling you.
327
+
328
+ | Capability | Status |
329
+ |---|---|
330
+ | HMAC-SHA256 verification (`v1,<base64>`) | ✅ `Webhook.verify` |
331
+ | 5-minute timestamp tolerance | ✅ Replay protection |
332
+ | Constant-time comparison | ✅ `OpenSSL.fixed_length_secure_compare` |
333
+ | Event type constants | ✅ `Webhook::EVENT_TYPES` — 32 values |
334
+
335
+ Categories: `EMAIL_EVENTS` (8), `SUBSCRIBER_EVENTS` (7), `BROADCAST_EVENTS` (8),
336
+ `SEQUENCE_EVENTS` (7), `SYSTEM_EVENTS` (2).
337
+
338
+ Not covered: payload deserialisation into typed objects. You get the parsed
339
+ JSON body and dispatch on `type` yourself.
340
+
341
+ ---
342
+
343
+ ## Not in the API at all
344
+
345
+ These are Broadcast features with no v1 endpoints. **No client can reach them** —
346
+ this is an API gap, not a gem gap, and nothing here is actionable in this repo.
347
+
348
+ | Feature | Where it lives | Notes |
349
+ |---|---|---|
350
+ | **Ask AI** | Web UI | Conversations, messages, tool-call approval |
351
+ | **Subscriber imports** | Web UI | CSV/TSV upload, mapping, processing |
352
+ | **CSV exports** | Web UI | Subscribers, broadcasts, activity. `GET /subscribers/export` is a web route |
353
+ | **Bulk operations** | Web UI | Subscriber, template, and file-asset bulk actions; broadcast bulk archive |
354
+ | **Channel management** | Web UI | Creating channels, domain/sender setup, onboarding |
355
+ | **User & permission management** | Web UI | Users, system permissions, per-channel permissions |
356
+ | **Settings** | Web UI | General, AI, DNS checks, ESP integrations, sender details |
357
+ | **Global suppressions** | Web UI + migration export | Read-only via `migration.suppressions`; no v1 CRUD |
358
+ | **File assets** | Web UI + migration export | Read-only via migration; no v1 upload |
359
+ | **Link tracking config** | Web UI | Click data readable via `statistics_links` and migration |
360
+ | **Admin / platform** | Web UI | Admin API tokens, SaaS platform admin |
361
+ | **Confirmation templates & pages** | Partly API | Configurable as template attributes; no dedicated endpoints |
362
+
363
+ ### Autopilot: this section used to be wrong, and how
364
+
365
+ Autopilot was listed here as web-UI-only. The reasoning was that `Token`
366
+ defined `permission_to_autopilot_read` / `_write` while no `api/v1/autopilots`
367
+ controller existed — inert flags. That was true when written, and the entry
368
+ predicted it would be the first thing to go stale. It was.
369
+
370
+ Autopilot **is** in the API as of app v2.20.0 and is covered
371
+ [above](#autopilot--1010). The build also closed the permission gap the flags
372
+ had exposed: `user_broadcast_channel_permissions` had no autopilot columns, so
373
+ the feature was silently sudo-only in the UI and its policy scopes raised
374
+ `NoMethodError` for any user holding a channel-permission record. Migration
375
+ `20260726180000` added them; `UserManagementPolicy` now derives assignable
376
+ attributes from the schema rather than a hand-written list.
377
+
378
+ Sub-resources are still web-only: autopilot **sources** and **tone samples**
379
+ have no v1 endpoints. `create`/`update` set the generator itself, and
380
+ `activate` requires an active source — so a source must exist before the API
381
+ can activate anything. That is the next gap in this resource.
382
+
383
+ ---
384
+
385
+ ## Demo instances answer differently
386
+
387
+ Nothing here is a gem concern, but a client pointed at a demo instance
388
+ (`DEMO_MODE` set) sees behaviour no error class distinguishes:
389
+
390
+ - **The migration API is off entirely.** Every `/api/migration/v1` route
391
+ returns `403` with `{"error": "The migration API is disabled in demo mode."}`,
392
+ raised *before* token verification — so a valid and an invalid admin token
393
+ get identical answers, deliberately, to stop the demo being used as a token
394
+ oracle. In the gem this surfaces as `Broadcast::AuthorizationError`.
395
+ - **Transactional sends are accepted but never delivered.** The request
396
+ succeeds and a record is created; the mailer returns early. `2xx` does not
397
+ mean an email left the building.
398
+ - **`/api/v1` itself is unguarded.** It honours any valid token — but tokens
399
+ cannot be minted in demo mode, so in practice the only usable credential is
400
+ the "Default transactional email" token each channel auto-provisions.
401
+
402
+ ---
403
+
404
+ ## Limits of the 100%
405
+
406
+ The figure means **every operation the router serves has a corresponding gem
407
+ method**. It does not mean the gem is complete. Specifically, it does not measure:
408
+
409
+ **Parameter completeness.** This is the important one. Resource methods take
410
+ `**attrs` and forward them, so a method covers its endpoint regardless of which
411
+ attributes it knows about. The v2.19 additions — template `confirmation_page_settings`,
412
+ opt-in-form `welcome_email_template_id`, admin-only `confirmed_at` on subscriber
413
+ create — all "passed through" at 100% coverage while being entirely undocumented.
414
+ They are documented now, but the metric would not have caught them and will not
415
+ catch the next ones. Closing this needs response schemas in `openapi/overlay.yml`
416
+ and a parameter-level check.
417
+
418
+ **Response shapes.** The gem returns parsed JSON. It does not model or validate
419
+ response bodies, so an API field that changes type is invisible to it.
420
+
421
+ **Semantics.** Coverage says a method issues the right request, not that it
422
+ handles the endpoint's behaviour correctly. `list` returning only main opt-in
423
+ forms, or `get_segment` recounting on read, are documented in comments — not
424
+ enforced by anything.
425
+
426
+ **Live correctness.** The suite mocks HTTP. `rake test_live` exercises a real
427
+ instance and is **not** part of the coverage figure; it needs a token and host.
428
+
429
+ ### Known unevenness
430
+
431
+ - **Pagination.** `Migration#each_record` auto-pages. The v1 endpoints do not
432
+ have an equivalent — callers loop on `page:` themselves.
433
+ - **Naming.** Getters are inconsistent: `get_broadcast`, `get_segment`,
434
+ `get_endpoint`, `get_opt_in_form`, `get_email_server`, `get_template`,
435
+ `get_transactional`, `get_step`. `Broadcast` collides with the top-level
436
+ module name, which is what forced the prefix originally; the rest followed for
437
+ symmetry. Worth settling before other SDKs copy the pattern.
438
+ - **Steps live on `Sequences`.** There is no `client.sequence_steps`; step
439
+ methods hang off the sequences resource, matching the nested routes.
440
+
441
+ ---
442
+
443
+ ## Keeping this current
444
+
445
+ This file is written by hand from generated data. Regenerate the underlying
446
+ numbers with:
447
+
448
+ ```bash
449
+ # in the broadcast repo
450
+ bin/rails openapi:generate
451
+ bin/rails "openapi:coverage[../broadcast-ruby]"
452
+ ```
453
+
454
+ `openapi:check` now runs in CI, so a changed API surface fails the build rather
455
+ than silently ageing this file. Note what that guard does **not** cover: it
456
+ compares the spec to the router, not this document to the spec. Both stale
457
+ entries found in the 2.20.0 pass — the version header, and Autopilot appearing
458
+ as both covered and absent — would have survived a green CI run indefinitely.
459
+
460
+ So on every API bump, re-check the three hand-maintained claims by hand:
461
+
462
+ 1. **The version header** at the top — it does not come from anywhere.
463
+ 2. **[Not in the API at all](#not-in-the-api-at-all)** — a new endpoint means a
464
+ row leaves that table. This is where the contradiction hid.
465
+ 3. **Prose in the endpoint map** — counts regenerate, the sentences under them
466
+ do not.
467
+
468
+ Cross-language plans live in [SDK-TODO.md](SDK-TODO.md); the v0.3.0 work log is
469
+ in [TODO.md](TODO.md).