Skip to content

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, kind slack|teams|email|webhook, name unique per tenant, encrypted_secret, target_hint, recipients, events, enabled, created_by, last_test_at, last_digest_at) and notification_deliveries (channel, event, dedupe_key, status pending|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 context notification_channels:<id> (encryptionContext(table, id)), and are covered by keys rotate. E-mail channels have no secret ((kind = 'email') = (encrypted_secret is null)). Views show only target_hint, the host and the last four characters.
  • recipients are personal data registered as unsearchable: they are entered by an admin and not linked to a code-host account. created_by is anonymised on erasure. Deliveries follow the tenant data retention.

Events and delivery &ZeroWidthSpace;

  • The component that causes an event calls submitNotification(tx, …) in its own transaction: budget alerts after the alert insert, review failures in finish() when the update to failed returned a row, the publisher after a published or reconciled intent of fix_pr or of an issue_comment with feature ci. The function inserts one delivery per enabled, subscribed channel (onConflictDoNothing on the dedupe key) and enqueues notify through the outbox with the idempotency key notify:<delivery id>. A rolled-back transaction leaves neither a delivery nor a job.
  • An optional channelId limits a submission to one channel. The approvals digest (maintenance task notification_digest, every 15 minutes) uses it: per channel subscribed to approvals_waiting whose last digest is older than one hour, it counts pending approvals created since then and submits one digest with the key digest:<channel>:<hour>. No hooks are needed at the places that create pending outputs.
  • The notify worker loads the delivery, renders the message and sends it. 2xx is sent; 429, 5xx and network errors are retried (5 attempts, exponential back-off) and the delivery is marked failed on the final attempt; other 4xx, redirects and blocked addresses are failed at once; a disabled channel, e-mail without SMTP or a secret-scanner hit is skipped. 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 &ZeroWidthSpace;

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 &ZeroWidthSpace;

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 &ZeroWidthSpace;

ChannelEnterpriseSelf-hosted
Slackhooks.slack.com, https, default port, guarded fetchSame
Teams*.environment.api.powerplatform.com, https, guarded fetchSame
Webhookhttps, assertPublicEndpoint when saving, guarded fetchhttps, private targets allowed, no-redirect fetch
E-mailConfigured SMTP server onlySame

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:

Sovereign clouds (GCC High, DoD, China) use other domains and are not supported yet; they need an explicit allowlist entry.

SMTP &ZeroWidthSpace;

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 &ZeroWidthSpace;

  • 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 submitNotification call in their own transaction and a new NotificationEvent value. fix_commit publications (autofix to the pull request branch) emit fix_ready once that publish kind exists.

The open core is released under the Apache License 2.0.