ADR-0038: Adapter behaviour v2 with declared capabilities, config schemas and a config-driven registry
| Status | Accepted |
| Date | 2026-10-10 |
| Issue | #36 |
| Pull request | #130 |
| Related | ADR-0003, ADR-0009, ADR-0019, ADR-0033, ADR-0036 |
Context and problem statement
Converger.Channels.Adapter covered delivery, inbound parsing and config validation, and
ADR-0033 added an optional capabilities/0 so the pipeline
no longer listed deliverable types. Everything else about a channel type was still spread over the code
base as type-string literals:
Channel.@channel_types(accepted types and the admin dropdown) and theadapter_for/1case;["webhook", "whatsapp_meta", "whatsapp_infobip"]inHealth.check_all_channels/1and again inAdmin.DashboardLive;config_fields/1andconfig_summary/1per type inAdmin.ChannelLive;@provider_ack_typesinInboundController, and a"whatsapp_meta"clause for the webhook verification handshake;- the Meta
app_secretrule inChannel.validate_signature_config/1; ["webhook", "websocket"]for conversation lifecycle events inPipeline;whatsapp_message_id/infobip_message_idinDeliveries.mark_sent/2.
Adding a channel type meant editing seven files outside the adapter. A fork could not add one without patching core, and a missed list failed silently: the new type simply got no health checks, no admin form, or the wrong inbound status code. The roadmap adds Telegram, Slack, email and SMS adapters (#38, #39), so the cost would grow with every one.
Decision drivers
- Adding an adapter is one module plus one config line; it then appears in the admin UI, health checks and the pipeline.
- No channel type literal outside the adapters (acceptance criterion of #36).
- Existing adapters, channel configs and error messages keep working; no migration.
- Channel types stay strings, never atoms from input.
- Misconfiguration fails at boot, not at the first delivery.
Considered options
- Keep the lists, document them (the checklist that
writing-an-adapter.mdcarried before). - Declarations on the adapter, a registry from built-ins plus config (chosen).
- Discover adapters at runtime by scanning loaded modules for the behaviour.
- The full adapter list in config only (
config :converger, :adapters, [all modules]).
Pros and cons of the options
Option 1: keep the lists
- Good, because nothing changes.
- Bad, because forks must patch core, and every missed list is a silent bug.
Option 2: declarations and a registry from built-ins plus config
- Good, because each consumer asks the adapter (
capability?/2,config_schema/1,types_with/1), so a new type needs no edit elsewhere. - Good, because a fork adds
config :converger, :adapters, [MyAdapter]without restating the built-ins, and can replace a built-in by registering a module with the same type. - Bad, because the capability vocabulary has to be kept meaningful: a capability that nothing reads is only documentation.
Option 3: runtime discovery
- Good, because no config line at all.
- Bad, because it depends on which modules are loaded (releases load lazily), is slow, and makes the set of channel types implicit.
Option 4: the full list in config
- Good, because it is explicit and lets an installation remove built-ins.
- Bad, because
configlists replace rather than merge, so every fork would restate the built-ins and silently lose new ones on upgrade.
Decision
Chosen option: "Declarations on the adapter, a registry from built-ins plus config".
use Converger.Channels.Adapter, type: "<type>"sets@behaviour, defines the new required callbacktype/0, and gives the optional callbacks overridable defaults:capabilities/0([:inbound, :outbound]),supported_modes/0(derived from:inboundand:outbound),config_schema/0([]),validate_config/1(:ok),retry_policy/0,rate_limit/0,normalize_error/1.- Capabilities are a list of atoms plus the
activity_types:entry of ADR-0036::inbound,:outbound,:external_delivery(health checks and dashboard),:receipts,:typing,:lifecycle_events(close and reopen events),:provider_ack(handled inbound requests always get200), and:media,:templates,:reactions,:editsfor native outbound content. The last four are descriptive until #37 and #68 read them. config_schema/0returns field specs (name,typeof:string | :url | :integer | :boolean | :map,requiredoftrue | false | :with_signature,secret,label,placeholder,help,summary,form).Adapter.validate_config/3checks it before the adapter's ownvalidate_config/1, and the admin form and channel list are rendered from it.required: :with_signaturereplaces the Meta-specific changeset rule. Unknown keys stay allowed, because some keys apply to every type (conversation_idle_timeout_seconds,unsupported_activities).- Registry:
Adapter.adapters/0is the built-in modules followed byconfig :converger, :adapters; a later module with the sametype/0replaces an earlier one. The list is cached in:persistent_term, keyed by the configured list, so dispatch does not rebuild it.Adapter.validate_registry!/0runs at the start ofConverger.Application.start/2and raises for a missing module, missing required functions or an empty type. - New optional callbacks:
verify_subscription/2(theGETwebhook handshake),health_probe/1andnormalize_error/1.verify_inbound_signature/3andsend_typing/2already existed (ADR-0009, ADR-0032). normalize_error/1turns any{:error, reason}fromdeliver_activity/2into aDeliveryError(a map withreason,retryable?,retry_after_msis accepted) before the circuit breaker and the retry policy see it. The default keeps aDeliveryErrorand treats anything else as retryable, as ADR-0019 did. The pipeline therefore handles one error shape.- Health probes: a channel with
:external_deliveryand no deliveries in the window is probed withhealth_probe/1when the adapter has one.:okstoreshealthy; an error storesdegraded, neverunhealthy, because the worker opens the circuit breaker onunhealthyand one failed call must not hold deliveries.whatsapp_metaprobes its phone number on the Graph API. Probes can be switched off (config :converger, :channel_health, probe_idle_channels: false; off in tests). deliver_activity/2may returnprovider_message_idin its metadata;Deliveries.mark_sent/2reads it before the older provider-specific keys.
Consequences
Positive
lib/contains no list of channel types;grep whatsapp_meta libfinds only the adapters.- A new adapter (in core, a fork or a dependency) is one module plus one config line, verified by
test/converger/channels/adapter_test.exswith a test-only adapter registered from config. - The admin form and validation cannot drift apart: both come from the schema.
- Idle WhatsApp Meta channels report a real status, so an expired token is visible before a message fails.
Negative and trade-offs
- Echo's capabilities are now
[:outbound, ...]instead of the implicit default; its supported modes (outbound) are unchanged. - The webhook's missing-URL error is now the generic
webhook config missing: urlinstead ofwebhook config requires a 'url' field. Pipeline.deliver/2returns{:error, %DeliveryError{}}for every retryable failure, instead of the adapter's raw reason. Callers only testPipeline.retryable?/1, which is unchanged.- The registry cache is keyed by the configured list: changing
:adaptersat runtime (tests do) creates a new:persistent_termentry. That is cheap for the handful of lists an installation or test suite uses. - Health probes make outbound calls from the health worker every five minutes per idle Meta channel.
Follow-ups
- #37 and #68:
read
:media,:templates,:reactionsand:editswhen rendering outbound content. - An Infobip health probe.
- New adapters (#38, #39) are written against this behaviour.
Implementation
lib/converger/channels/adapter.ex: behaviour,__using__/1, registry (adapters/0,types/0,types_with/1,validate_registry!/0),validate_config/3,validate_schema/4and dispatch.lib/converger/channels/delivery_error.ex:normalize/1.- Built-in adapters under
lib/converger/channels/adapters/declare capabilities and schemas;whatsapp_meta.exaddsverify_subscription/2andhealth_probe/1. - Consumers:
Channel(types, schema validation),Health.list_monitored_channels/0(also used by the dashboard),Pipeline(:lifecycle_events,normalize_error),InboundController(:provider_ack,verify_subscription/2),Admin.ChannelLive(generated form, summary, masking),Converger.Application(boot check). - Config:
config :converger, :adaptersandconfig :converger, :channel_healthinconfig/config.exs. - Tests:
test/converger/channels/adapter_test.exs(registry, schema, probes, pipeline, errors),test/converger_web/live/admin_channel_schema_test.exs(generated form), and the test-onlytest/support/test_sms_adapter.ex.