ADR-0016: Notification channels ​
Status: accepted (2026-10-15). Builds on the outbox (ADR-0005), the permission tiers (ADR-0006), key rotation (ADR-0008) and the endpoint guard of the LLM gateway (ADR-0010).
Context ​
Budget alerts, failed reviews, outputs waiting for approval, fix pull requests and CI failure analyses were visible only inside CodeLotse. Teams asked to be told in Slack, Microsoft Teams, by e-mail or through their own systems. Every target is outside the code host, is configured by a tenant admin and receives data from CodeLotse, so it needs the same care as other external writes: secrets at rest, SSRF protection, exactly-once delivery and no leaking of code.
Decision ​
Sender in the publisher ​
The sender lives in @quavon/publisher (src/notify/), because AGENTS.md routes all external writes through the publisher and src/tracker/ is the precedent for writes that do not go to a code host. It reuses the secret scanner and the sanitiser. A separate package would duplicate them and need an exception to the rule. The publisher does not import @quavon/llm: callers inject the fetch functions and the SMTP settings (guardedFetch, webhookFetch, smtp), so the worker and the API decide the egress policy per edition.
E-mail uses nodemailer (MIT-0, recorded as a bundled exception in licenses/exceptions.yaml). The SMTP client of the hosted billing code is not moved or imported (ADR-0015 forbids imports from hosted/).
Data and secrets ​
notification_channels(tenant, kindslack|teams|email|webhook, name unique per tenant,encrypted_secret,target_hint,recipients,events,enabled,created_by,last_test_at,last_digest_at) andnotification_deliveries(channel, event,dedupe_key, statuspending|sent|failed|skipped, attempts,last_error≤ 500 characters,summary,sent_at; unique(channel_id, dedupe_key), cascade with the channel). Both tables have forced RLS.- The target URL, and for webhooks the signing secret, are stored as JSON in
encrypted_secret, AES-256-GCM with the encryption contextnotification_channels:<id>(encryptionContext(table, id)), and are covered bykeys rotate. E-mail channels have no secret ((kind = 'email') = (encrypted_secret is null)). Views show onlytarget_hint, the host and the last four characters. recipientsare personal data registered as unsearchable: they are entered by an admin and not linked to a code-host account.created_byis anonymised on erasure. Deliveries follow the tenant data retention.
Events and delivery ​
- The component that causes an event calls
submitNotification(tx, …)in its own transaction: budget alerts after the alert insert, review failures infinish()when the update tofailedreturned a row, the publisher after apublishedorreconciledintent offix_pror of anissue_commentwith featureci. The function inserts one delivery per enabled, subscribed channel (onConflictDoNothingon the dedupe key) and enqueuesnotifythrough the outbox with the idempotency keynotify:<delivery id>. A rolled-back transaction leaves neither a delivery nor a job. - An optional
channelIdlimits a submission to one channel. The approvals digest (maintenance tasknotification_digest, every 15 minutes) uses it: per channel subscribed toapprovals_waitingwhose last digest is older than one hour, it counts pending approvals created since then and submits one digest with the keydigest:<channel>:<hour>. No hooks are needed at the places that create pending outputs. - The
notifyworker loads the delivery, renders the message and sends it. 2xx issent; 429, 5xx and network errors are retried (5 attempts, exponential back-off) and the delivery is markedfailedon the final attempt; other 4xx, redirects and blocked addresses arefailedat once; a disabled channel, e-mail without SMTP or a secret-scanner hit isskipped. Errors never contain the URL. - Send test uses
sendTest(channel)with the same renderer and transport, writes no delivery and is limited to one test per channel every 10 seconds.
Content ​
Messages carry metadata only: event, repository, pull request number and title, a count or budget amounts, and a link to the code host. Never code, finding text, model output or logs. Titles lose control and invisible characters. Slack text escapes &, < and >, which also neutralises <!channel> and user mentions; Teams Adaptive Card text escapes Markdown; e-mail is plain text with fixed subjects and nodemailer's header encoding.
Signing ​
Generic webhooks carry X-CodeLotse-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, plus X-CodeLotse-Event and X-CodeLotse-Delivery. The secret is 32 random bytes, shown once on creation and rotation. Receivers check the signature in constant time, the timestamp window and the delivery id.
Egress ​
| Channel | Enterprise | Self-hosted |
|---|---|---|
| Slack | hooks.slack.com, https, default port, guarded fetch | Same |
| Teams | *.environment.api.powerplatform.com, https, guarded fetch | Same |
| Webhook | https, assertPublicEndpoint when saving, guarded fetch | https, private targets allowed, no-redirect fetch |
| Configured SMTP server only | Same |
The guarded fetch checks addresses inside the socket lookup and refuses redirects, so a validated name cannot rebind to a private address. Every request has a 10-second timeout and reads at most 64 KiB of the answer. The allowlist is checked by zod when saving and again before every send.
Teams hosts. Microsoft moved Power Automate HTTP and Teams webhook triggers from *.logic.azure.com to <environment>.environment.api.powerplatform.com (rollout from August 2025); the old URLs stopped working on 30 November 2025. The Office 365 connectors (*.webhook.office.com) were retired before that. CodeLotse therefore accepts only the .environment.api.powerplatform.com suffix. Sources, checked on 2026-10-15:
- Microsoft Q&A, "Power automate http triggered new URL" (Microsoft moderator answer on the migration from
logic.azure.comtoapi.powerplatform.comand the 30 November 2025 cut-off): https://learn.microsoft.com/en-us/answers/questions/5600533/power-automate-http-triggered-new-url-again-change - "HTTP and Teams Webhook trigger URLs must be updated in Power Automate and Copilot Studio agent flows" (summary of the Microsoft 365 message centre notice): https://blog-en.topedia.com/2025/10/http-and-teams-webhook-trigger-urls-must-be-updated-in-power-automate-and-copilot-studio-agent-flows/
- "Power Automate – HTTP and Teams webhook trigger flows are moving to new URLs": https://m365admin.handsontek.net/power-automate-http-teams-webhook-trigger-flows-moving-new-urls/
- Example of the new URL format
https://<id>.<nn>.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/…: https://dev.classmethod.jp/en/articles/aws-step-functions-teams-workflows-new-webhook-url/ - Vendor note that
logic.azure.comTeams URLs stopped working on 30 November 2025: https://docs.dynatrace.com/docs/platform-modules/automations/workflows/actions/microsoft-teams
Sovereign clouds (GCC High, DoD, China) use other domains and are not supported yet; they need an explicit allowlist entry.
SMTP ​
Instance-wide settings through @quavon/config: QUAVON_SMTP_HOST, QUAVON_SMTP_PORT (587), QUAVON_SMTP_SECURITY (starttls or tls; no plain text), QUAVON_SMTP_FROM, QUAVON_SMTP_USERNAME, QUAVON_SMTP_PASSWORD (secret, _FILE supported). They are an optional smtp section of the api and worker configuration; without a host, e-mail channels cannot be created and existing ones are skipped. At most 20 recipients per channel.
Consequences ​
- Tenant admins can wire events to their tools without code. Every channel change, test and secret rotation is audited (
notification_channel.*). - A slow or broken target cannot hold a database transaction: sending happens in the worker after commit, and the test endpoint sends after its audit transaction.
- Self-hosted instances can post to internal webhooks by design, like trackers and model endpoints. Slack and Teams are always allowlisted and guarded.
- E-mail recipients of one channel see each other.
- New event sources only need a
submitNotificationcall in their own transaction and a newNotificationEventvalue.fix_commitpublications (autofix to the pull request branch) emitfix_readyonce that publish kind exists.