Skip to content
Registry StackDocsv0.38.0

Author a Messaging package

View as Markdown

You decided what your service sends and to whom, and now you want to author what Registry Messaging may send on its behalf. This page covers the package: the providers messages travel through, the sender profiles callers select, the templates Messaging renders, and the access profiles that decide who may do what. At the end, messagingctl check accepts the package and reports the digest an operator records before a runtime serves it.

The package holds no endpoint, credential, or database address. Those belong to the operator’s runtime file, which Deploy Registry Messaging covers.

To watch the starter package send before you change it, run Send your first message first: it delivers one SMS and one email on your machine with messagingctl dev.

The first release eligible to publish messagingctl is v0.38.0, after image-package onboarding is complete. For a published release that contains Messaging, choose <tag> from the latest release, authenticate the messagingctl-<tag>-linux-amd64 asset through release/VERIFY.md at that tag, then install it:

Terminal window
tag="${TAG:?set TAG to a published tag that includes Registry Messaging}"
mkdir -p ~/.local/bin
install -m 0755 "messagingctl-${tag}-linux-amd64" ~/.local/bin/messagingctl
export PATH="$HOME/.local/bin:$PATH"
messagingctl --version

The install replaces an existing ~/.local/bin/messagingctl. Preserve that file first when you need a rollback path. Until a published release contains Messaging, or for another platform, build messagingctl from a Registry Stack checkout:

Terminal window
cargo build --release --locked -p registry-messagingctl
export PATH="$PWD/target/release:$PATH"

On macOS, Cargo builds the FIPS crypto library as a dynamic library the shell cannot find on its own. Build through the repository’s helper instead, in the terminal you run the binary from:

Terminal window
. scripts/cargo-runtime-library-path.sh
registry_cargo_build "$PWD" --release --locked -p registry-messagingctl
export PATH="$PWD/target/release:$PATH"

A source build writes target/release/messagingctl. The command reads files and prints reports; check and preview open no connection. Every command takes --format human|json and exits 0 on success, 1 on a refusal, 2 on a usage error, and 3 when a file, secret, or database could not be reached. Run messagingctl <command> --help for each command’s arguments.

A human failure is one error[CODE] PATH: MESSAGE line on standard error, followed by a next: line naming what to do. With --format json, every report opens with ok, command, and status, and a failure lists its diagnostics, each with a code, path, message, and suggestedAction. The one exception is a successful preview, which prints the route body the HTTP preview answers, unwrapped.

messagingctl init writes an editable project into a directory that must not exist yet:

Terminal window
messagingctl init ./notices
created: ./notices
messaging.yaml
providers/sms-gateway/provider.yaml
providers/sms-gateway/scripts/interpret.rhai
providers/sms-gateway/scripts/prepare.rhai
providers/sms-gateway/scripts/receipt.rhai
runtime.example.yaml
templates/appointment-reminder-sms/1/en/text.j2
templates/appointment-reminder-sms/1/sample.json
templates/appointment-reminder-sms/1/schema.json
templates/appointment-reminder-sms/1/template.yaml
templates/appointment-reminder/1/en/html.j2
templates/appointment-reminder/1/en/subject.j2
templates/appointment-reminder/1/en/text.j2
templates/appointment-reminder/1/fr/html.j2
templates/appointment-reminder/1/fr/subject.j2
templates/appointment-reminder/1/fr/text.j2
templates/appointment-reminder/1/sample.json
templates/appointment-reminder/1/schema.json
templates/appointment-reminder/1/template.yaml
next: Run messagingctl check --project DIRECTORY, then messagingctl dev DIRECTORY to run it locally against PostgreSQL and Mailpit containers.
next: Run messagingctl package DIRECTORY --output PACKAGE, point runtime.yaml at PACKAGE, run messagingctl plan, then messagingctl apply, then messaging serve.

The directory is an editable project. messaging.yaml, templates/, and providers/ describe what the runtime may send. Keep runtime.example.yaml for deployment configuration; packaging excludes that file and development state. Use project-mode checks while editing, then produce a separate installed directory for handover.

messaging.yaml opens with its type and declares the providers messages travel through:

apiVersion: registry.registrystack.org/messaging-package/v1alpha1
kind: MessagingPackage
providers:
- id: mail-relay
kind: smtp
- id: sms-gateway
kind: http
idempotentSubmit: true

A provider’s kind is smtp, which carries email, or http, which carries whatever its scripts send. idempotentSubmit: true declares that the provider drops a repeat submission carrying the same idempotency key. Only an http provider may set it, and only then does its prepare script see the key. Every key in the manifest is closed: an unknown field is a refusal, not a line that is ignored.

A sender profile is what a caller names when it submits. It fixes the channel, the provider carrying it, and the sender identity:

senderProfiles:
- id: transactional
channel: email
provider: mail-relay
sender: notices@example.org
- id: reminders-sms
channel: sms
provider: sms-gateway
sender: Registry
maximumSegments: 2

An SMS sender profile sets maximumSegments, from 1 to 10, and a rendered SMS needing more is refused with 422 content.too-many-segments.

A sender profile also sets how the worker sends its messages. Each message keeps the policy its profile had when it was accepted, so a later package change does not alter a queued message.

KeyDefaultBounds
retry.maximumAttempts51 to 20
retry.initialDelaySeconds30at least 1
retry.maximumDelaySeconds3600initialDelaySeconds to 86400
onUncertainholdhold or retry
acceptDuplicatesfalse
defaultExpirySeconds8640060 to 2592000

A send that definitely failed is retried with exponential backoff, doubling from the initial delay up to the maximum, with jitter. A send that may have reached the provider, including one cut off by its time budget, is uncertain. Under hold, the message stops as unknown until an operator settles it. Under retry, it is sent again under the same provider idempotency key, and the package is refused unless the profile’s provider declares idempotentSubmit: true or the profile sets acceptDuplicates: true. Set acceptDuplicates only when a duplicate message is better for the recipient than a missed one.

A message whose request names no expiresAt expires defaultExpirySeconds after acceptance, or at the end of the operator’s payload retention if that comes first, and is never sent after it expires.

Each access profile names the callers it admits and what they may do. A token resolves to a profile by the client it was issued to, the scopes it carries, and the actor kind when the profile names one:

accessProfiles:
- id: case-notices
principalClaim: sub
requiredScopes: [messaging:send]
requesterClients: [case-system]
actorKind: service
role: sender
senderProfiles: [transactional, reminders-sms]
templates: [appointment-reminder, appointment-reminder-sms]
requestsPerMinute: 60
burst: 10
- id: operations
principalClaim: sub
requiredScopes: [messaging:operate]
requesterClients: [operations-console]
role: operator
requestsPerMinute: 60
burst: 10
KeyMeaning
principalClaimThe token claim naming the caller. With the issuer, it scopes the messages a sender may read and cancel
requiredScopesScopes the token must carry
requesterClientsThe OAuth clients this profile admits. A client belongs to exactly one profile, and the operator’s runtime file must admit it
actorKindhuman, agent, or service. Omitted means any
rolesender submits and previews. operator reads and cancels every message and may not submit
senderProfiles, templatesWhat a sender may use: at least one of each. An operator lists none
allowDirectContentLets a sender submit text instead of naming a template. An operator may not set it
requestsPerMinute, burst, dailyLimitPositive limits on the profile’s callers

Messaging inherits no authorization from a calling product. A service allowed to act on a case is not thereby allowed to send; this profile decides. Keep allowDirectContent off unless a caller has a reason to send text no reviewer saw: a template is the reviewed wording.

requestsPerMinute and burst bound how fast each caller of the profile submits. Every POST /v1/messages that passes the role check is charged to a bucket kept per caller, by token issuer and subject, and a submission past the burst is refused 429 rate-limit.exceeded with a Retry-After header. The bucket lives in the runtime process, so each replica enforces its own and a restart refills it.

dailyLimit bounds the messages the whole profile accepted in the last 24 hours. It is counted from the accepted messages when a submission is accepted, so it holds across replicas and restarts. A submission past it is refused 429 quota.exceeded, with Retry-After set to when the oldest counted message leaves the window. A replayed submission is charged to the rate but not to the daily limit.

templates in messaging.yaml lists every version the package ships, and each lives under templates/<id>/<version>/. A version is a label, so quote a numeric one:

templates:
- id: appointment-reminder
version: "1"
- id: appointment-reminder-sms
version: "1"

A version directory holds:

  • template.yaml, which declares the channel, up to 32 locales such as en or pt-BR, and the parts. An email version renders subject, text, and optionally html. An SMS version renders exactly text.
  • schema.json, a JSON Schema (draft 2020-12) the caller’s data must satisfy before anything renders. It may not reference anything outside itself.
  • sample.json, optional data every locale must render at load, which is how messagingctl check reports each SMS locale’s segment count.
  • <locale>/<part>.j2 for every declared locale and part, at most 64 KiB each.

The starter’s email version declares:

channel: email
locales: [en, fr]
parts: [subject, text, html]

and its English text part reads:

Hello {{ name }},
Your appointment at {{ office }} is on {{ day|date }}.
Please bring this notice with you.

Templates are Jinja without a loader: no include, import, extends, or macros, no built-in filters or globals, and no file, network, or clock access. A missing or null value refuses the render instead of printing nothing. Two filters format for the requested locale: date for an ISO calendar date and number(decimals) for a number. HTML parts escape every value and cannot opt out, text and SMS parts strip control characters, and a subject strips newlines. A locale the version does not declare is refused with 422 template.locale-unavailable, never answered in another language.

To change wording a deployment already sends, add a new version directory and list it, and leave the old version in place until no caller names it.

Every http provider has a directory providers/<id>/ holding provider.yaml and the Rhai scripts it names. The starter’s sms-gateway is a generic JSON gateway that deduplicates on an idempotency key and reports delivery through a signed callback:

prepareScript: scripts/prepare.rhai
interpretScript: scripts/interpret.rhai
receiptScript: scripts/receipt.rhai
request:
method: post
headers: [idempotency-key, x-request-id]
responseHeaders: [x-request-id]
capabilities:
receipts: callback
concurrencyLimit: 8
KeyMeaning
prepareScriptRequired. Shapes one message into a request
interpretScriptOptional. Classifies the provider’s response; without it, the status code decides
receiptScriptRequired exactly when capabilities.receipts is callback
request.methodpost, or get, which the operator’s runtime file must acknowledge
request.headers, responseHeadersThe only header names the prepare script may set and the interpret script may read
capabilities.receiptsnone, callback, or reconcile. Only callback gives messages a delivery report
capabilities.concurrencyLimit1 to 64. The operator’s connection may lower it, never raise it
capabilities.ratePerSecondOptional, 1 to 1000. Paces how often this runtime process starts a send

With ratePerSecond set, at most one send to the provider starts every 1 / ratePerSecond seconds in each runtime process. An attempt first waits for one of the connection’s concurrencyLimit sends in flight, then for its slot, so the rate spaces requests as they leave, even when sends queue behind a slow one. Both waits together last up to ten seconds on top of the send’s own time budget, and never past the message’s expiresAt. An attempt whose slot does not open in time sends nothing and is retried under its dispatch policy; a message whose expiry passes first is not sent again, and expires, or stays unknown when an earlier attempt may have reached the provider and its policy retries.

provider.yaml is closed and at most 64 KiB, and each script is at most 64 KiB and must compile with exactly its entry point when the package loads. A file the provider does not name, a hidden entry, or a directory for an smtp provider is refused.

The scripts see plain data only, never a credential, a URL, or a header value the runtime owns:

ScriptReceivesReturns
prepare(message, profile)message: messageId, generation, attempt, channel, recipient, parts, and idempotencyKey when the provider declares idempotentSubmit. profile: id, channel, sender, maximumSegmentstarget relative to the operator’s baseUrl, declared headers, bodyFormat json or form, and body. Messaging serializes the body
interpret(response)status, the declared response headers, and the decoded bodyoutcome: accepted with a providerReference, transient with an optional retryAfter, permanent with an optional code, or maybe-sent
receipt(request)A verified callback’s method, form, query, and json, each only when the callback verifier signs it: hmac-sha256-body leaves query empty, and hmac-sha1-url-form leaves json as ()providerReference and report (sent, delivered, or undelivered) with an optional code, or () to ignore the callback. A throw answers 422 callback.unreadable

Without an interpret script, a 2xx is accepted, 429 and 503 are transient, 408 and any other 5xx are maybe-sent, because the request was written and the provider may have acted on it, and any other status is permanent with the code http.<status>. An interpret script that fails falls back to the status, except that an unreadable 2xx is maybe-sent, because a provider may answer 200 with an error body. Report maybe-sent whenever the provider may have taken the message: that is what keeps a message from being sent twice under onUncertain: hold.

The Registry Stack repository carries two more example provider directories under products/messaging/examples/providers/: mock, a loopback mock for local runs, and form-sms-gateway, a form-encoded SMS provider with a form callback signed by hmac-sha1-url-form.

messagingctl check --project validates the editable project, renders every sample, and reports the digest:

Terminal window
messagingctl check --project ./notices
ok: ./notices
package digest: sha256:beb567ec1a2813ac361cc1765151e398dbbb6e77bd886889769ed21c029f43f4
template: appointment-reminder 1 (email)
sample en: renders
sample fr: renders
template: appointment-reminder-sms 1 (sms)
sample en: 1 segment(s), gsm7 85 units
access profile: case-notices (sender)
access profile: operations (operator)

messagingctl preview renders one template version with the data you give it, so you can read each locale before a caller sends it:

Terminal window
messagingctl preview --project ./notices appointment-reminder 1 \
--locale fr --data ./notices/templates/appointment-reminder/1/sample.json
template: appointment-reminder 1 (email, fr)
package digest: sha256:beb567ec1a2813ac361cc1765151e398dbbb6e77bd886889769ed21c029f43f4
--- subject
Votre rendez-vous du 01/10/2026
--- text
Bonjour Ada Lovelace,
Votre rendez-vous à Central Registry Office est fixé au 01/10/2026.
Merci d'apporter cet avis.
--- html
<p>Bonjour Ada Lovelace,</p>
<p>Votre rendez-vous à Central Registry Office est fixé au 01&#x2f;10&#x2f;2026.</p>
<p>Merci d'apporter cet avis.</p>

An SMS preview adds the encoding and segment count the runtime charges against maximumSegments:

Terminal window
messagingctl preview --project ./notices appointment-reminder-sms 1 \
--locale en --data ./notices/templates/appointment-reminder-sms/1/sample.json
template: appointment-reminder-sms 1 (sms, en)
package digest: sha256:beb567ec1a2813ac361cc1765151e398dbbb6e77bd886889769ed21c029f43f4
--- text
Reminder: Ada Lovelace, your appointment at Central Registry Office is on 01/10/2026.
--- sms: 1 segment(s), gsm7 85 units

A refusal exits 1 and names what failed. Data missing two of the schema’s required members:

error[template.data-invalid] /: the data does not satisfy the template schema: `/` fails `required`; `/` fails `required`;
data /: required
data /: required
next: Correct the template, locale, or data the message names, then retry.

A locale the version does not declare:

error[template.locale-unavailable] /: the template does not ship this locale
next: Correct the template, locale, or data the message names, then retry.

The same preview is available to a sender over HTTP, and it answers the same bytes messagingctl --format json preview prints for the same package, template, locale, and data.

Produce an installed package in a directory that does not exist yet:

Terminal window
messagingctl package ./notices --output ./notices-installed
messagingctl check --package ./notices-installed

Use --dry-run to inspect the selected files without writing, and --revision TEXT when you need explicit source provenance. Give the operator the installed directory and its reported digest. The directory contains the shared checksum envelope, product configuration, providers, and templates, with no runtime credentials or development state.

The operator reviews messagingctl plan, records the digest with messagingctl apply, and restarts onto it. The runtime verifies the package and optional package.expectedDigest before database work, then uses only the verified file buffers. Editing the authoring project never changes what a running deployment sends.