messagefoundry 0.1.0__py3-none-any.whl

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.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,486 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Real :class:`~messagefoundry.pipeline.alerts.AlertSink` transports — webhook + email.
4
+
5
+ The :class:`AlertSink` emit methods are called **inline on a delivery worker** and must be cheap and
6
+ non-blocking (the lane is already stalled when we're alerting — the notification must never block or
7
+ hang it). So :class:`NotifierAlertSink` does no network I/O inline: each event is built and dropped on
8
+ a bounded in-memory queue, and a **background task** drains the queue and performs the actual sends
9
+ (webhook POST / SMTP) off the event loop via :func:`asyncio.to_thread`. A send failure is logged and
10
+ the other transports still run — alerting is best-effort and never breaks delivery.
11
+
12
+ Transports use only the **standard library** (``urllib.request`` / ``smtplib``), so wiring real
13
+ notifications adds no dependency. Payloads carry the connection name + queue shape only — **no PHI**.
14
+
15
+ Construction is config-driven: :func:`notifier_from_settings` turns an
16
+ :class:`~messagefoundry.config.settings.AlertsSettings` into a :class:`NotifierAlertSink`, or ``None``
17
+ when neither transport is configured (the engine then falls back to the logging sink).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import abc
23
+ import asyncio
24
+ import fnmatch
25
+ import json
26
+ import logging
27
+ import smtplib
28
+ import time
29
+ import urllib.parse
30
+ import urllib.request
31
+ from collections.abc import Mapping, Sequence
32
+ from dataclasses import dataclass
33
+ from email.message import EmailMessage
34
+ from typing import Any, Generic, Protocol, TypeVar
35
+
36
+ from messagefoundry.config.settings import (
37
+ INSECURE_TLS_ESCAPE_ENV,
38
+ AlertRule,
39
+ AlertSeverity,
40
+ AlertsSettings,
41
+ insecure_tls_allowed,
42
+ )
43
+
44
+ __all__ = [
45
+ "AlertTransport",
46
+ "WebhookTransport",
47
+ "EmailTransport",
48
+ "AlertRuleSet",
49
+ "NotifierAlertSink",
50
+ "notifier_from_settings",
51
+ ]
52
+
53
+ log = logging.getLogger(__name__)
54
+
55
+ # Bound the in-memory backlog so a wedged transport (unreachable webhook) can't grow without limit;
56
+ # excess events are dropped with a warning rather than stalling the worker that enqueues them.
57
+ _MAX_QUEUE = 1000
58
+
59
+ _T = TypeVar("_T")
60
+
61
+
62
+ class _BackgroundDispatcher(abc.ABC, Generic[_T]):
63
+ """A bounded in-memory queue drained by a single background task — the shared lifecycle behind
64
+ :class:`NotifierAlertSink` (operator alerts) and the per-user security-event notifier. The queue
65
+ caps the backlog so a wedged downstream can't grow memory without bound; excess items are dropped
66
+ with a warning (see :meth:`_enqueue`).
67
+
68
+ Subclasses implement :meth:`_handle` for the per-item work. It runs inside the background task,
69
+ must push any blocking I/O off the loop (``asyncio.to_thread``), and must be **best-effort** — it
70
+ swallows its own errors and never raises, since an exception out of ``_handle`` would kill the
71
+ drain loop. Lifecycle: :meth:`start` once an event loop is running (the ASGI lifespan does this),
72
+ :meth:`aclose` on shutdown (enqueues a ``None`` sentinel, drains what's queued, then stops)."""
73
+
74
+ def __init__(self, *, max_queue: int = _MAX_QUEUE) -> None:
75
+ self._queue: asyncio.Queue[_T | None] = asyncio.Queue(maxsize=max_queue)
76
+ self._task: asyncio.Task[None] | None = None
77
+
78
+ def _enqueue(self, item: _T, *, dropped: str) -> None:
79
+ """Non-blocking enqueue; on a full queue, drop the item with a warning (``dropped`` names it)."""
80
+ try:
81
+ self._queue.put_nowait(item)
82
+ except asyncio.QueueFull:
83
+ log.warning("%s queue full; dropping %s", type(self).__name__, dropped)
84
+
85
+ def start(self) -> None:
86
+ if self._task is None:
87
+ self._task = asyncio.create_task(self._run())
88
+
89
+ async def aclose(self) -> None:
90
+ if self._task is None:
91
+ return
92
+ await self._queue.put(None) # sentinel: drain what's queued, then stop
93
+ try:
94
+ await self._task
95
+ finally:
96
+ self._task = None
97
+
98
+ async def _run(self) -> None:
99
+ while True:
100
+ item = await self._queue.get()
101
+ if item is None:
102
+ return
103
+ await self._handle(item)
104
+
105
+ @abc.abstractmethod
106
+ async def _handle(self, item: _T) -> None:
107
+ """Process one queued item (best-effort, blocking I/O off-loop). Must not raise."""
108
+
109
+
110
+ class AlertTransport(Protocol):
111
+ """One delivery channel for an alert event. ``send`` does the actual (blocking) I/O — the
112
+ :class:`NotifierAlertSink` always calls it from a background task, never inline on a worker."""
113
+
114
+ name: str
115
+
116
+ async def send(self, event: dict[str, Any]) -> None: ...
117
+
118
+
119
+ def _subject(event: dict[str, Any]) -> str:
120
+ severity = str(event.get("severity", "warning")).upper()
121
+ return f"[MessageFoundry] {severity} {event['type']} — {event['connection']}"
122
+
123
+
124
+ def _body(event: dict[str, Any]) -> str:
125
+ lines = [f"{k}: {v}" for k, v in event.items()]
126
+ return "\n".join(lines)
127
+
128
+
129
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
130
+ """Refuse to follow HTTP redirects on the outbound webhook POST (ASVS 15.3.2): a 3xx could
131
+ divert the alert to an unintended host or be chained to probe an internal endpoint. Returning
132
+ ``None`` makes urllib raise on the redirect instead of following it, so the send fails (logged)
133
+ — the safe default for a fire-and-forget webhook."""
134
+
135
+ def redirect_request(
136
+ self,
137
+ req: urllib.request.Request,
138
+ fp: Any,
139
+ code: int,
140
+ msg: str,
141
+ headers: Any,
142
+ newurl: str,
143
+ ) -> urllib.request.Request | None:
144
+ return None
145
+
146
+
147
+ # A shared opener that never follows redirects; reused for every webhook POST.
148
+ _NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler)
149
+
150
+
151
+ class WebhookTransport:
152
+ """POST the event as JSON to a configured URL (fronts Slack/Teams/PagerDuty/custom webhooks)."""
153
+
154
+ def __init__(
155
+ self, url: str, *, timeout: float = 10.0, allowed_hosts: tuple[str, ...] = ()
156
+ ) -> None:
157
+ # Refuse a plaintext http:// webhook target unless the explicit dev escape is set: the alert
158
+ # POST otherwise crosses the network in cleartext (ASVS 12.2.1 — no insecure fallback). https
159
+ # is the only scheme accepted by default; http(s) remain the only schemes at all (see _post).
160
+ # Same refuse-unless-MEFOR_ALLOW_INSECURE_TLS pattern as LDAPS / SQL Server / MLLP — stricter
161
+ # than the credentialed-only http refusal on REST/SOAP, since a webhook has no PHI but should
162
+ # still never fall back to cleartext.
163
+ scheme = urllib.parse.urlsplit(url).scheme.lower()
164
+ if scheme not in ("http", "https"):
165
+ raise ValueError(f"webhook url must be http or https, got scheme {scheme!r}")
166
+ if scheme == "http" and not insecure_tls_allowed():
167
+ raise ValueError(
168
+ f"webhook url {url!r} uses plaintext http; refused unless "
169
+ f"{INSECURE_TLS_ESCAPE_ENV} is set (dev/trusted-network only) — use https"
170
+ )
171
+ if scheme == "http":
172
+ log.warning(
173
+ "webhook target uses plaintext http; permitted only because %s is set "
174
+ "(cleartext, MITM-able — trusted-network/dev use only)",
175
+ INSECURE_TLS_ESCAPE_ENV,
176
+ )
177
+ self.url = url
178
+ self.timeout = timeout
179
+ # Optional egress allowlist (lower-cased); empty = any host. SSRF defense-in-depth (1.3.6).
180
+ self.allowed_hosts = tuple(h.lower() for h in allowed_hosts)
181
+ self.name = "webhook"
182
+
183
+ async def send(self, event: dict[str, Any]) -> None:
184
+ await asyncio.to_thread(self._post, event)
185
+
186
+ def _post(self, event: dict[str, Any]) -> None:
187
+ # Guard the scheme before opening: urllib would otherwise honour file:/ftp:/custom schemes
188
+ # (bandit B310). The URL is operator-configured, but an http(s)-only check is cheap
189
+ # defense-in-depth so a typo'd or hostile config can't turn an alert POST into a local-file
190
+ # read. With the scheme provably http/https here, the Request nosec is justified.
191
+ split = urllib.parse.urlsplit(self.url)
192
+ if split.scheme.lower() not in ("http", "https"):
193
+ raise ValueError(f"webhook url must be http or https, got scheme {split.scheme!r}")
194
+ host = (split.hostname or "").lower()
195
+ if self.allowed_hosts and host not in self.allowed_hosts:
196
+ raise ValueError(f"webhook host {host!r} is not in the configured allowlist")
197
+ data = json.dumps(event).encode("utf-8")
198
+ req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme guarded to http(s) above
199
+ self.url,
200
+ data=data,
201
+ headers={"Content-Type": "application/json"},
202
+ method="POST",
203
+ )
204
+ # The no-redirect opener (not urllib.request.urlopen) so a 3xx can't divert the POST (15.3.2).
205
+ with _NO_REDIRECT_OPENER.open(req, timeout=self.timeout) as resp:
206
+ resp.read() # drain so the connection can be reused/closed cleanly
207
+
208
+
209
+ def send_plain_email(
210
+ *,
211
+ host: str,
212
+ port: int,
213
+ sender: str,
214
+ recipients: list[str],
215
+ subject: str,
216
+ body: str,
217
+ use_tls: bool = True,
218
+ username: str | None = None,
219
+ password: str | None = None,
220
+ timeout: float = 30.0,
221
+ allowed_hosts: tuple[str, ...] = (),
222
+ ) -> None:
223
+ """Send one plain-text email via SMTP (STARTTLS by default). Blocking — call via
224
+ ``asyncio.to_thread``. Shared by :class:`EmailTransport` (ops alerts) and the per-user
225
+ security-event notifier. An optional ``allowed_hosts`` egress allowlist gates the SMTP host."""
226
+ allowed = tuple(h.lower() for h in allowed_hosts)
227
+ if allowed and host.lower() not in allowed:
228
+ raise ValueError(f"SMTP host {host!r} is not in the configured allowlist")
229
+ msg = EmailMessage()
230
+ msg["Subject"] = subject
231
+ msg["From"] = sender
232
+ msg["To"] = ", ".join(recipients)
233
+ msg.set_content(body)
234
+ with smtplib.SMTP(host, port, timeout=timeout) as smtp:
235
+ if use_tls:
236
+ smtp.starttls()
237
+ if username is not None:
238
+ smtp.login(username, password or "")
239
+ smtp.send_message(msg)
240
+
241
+
242
+ class EmailTransport:
243
+ """Send the event as a short plain-text email via SMTP (STARTTLS by default)."""
244
+
245
+ def __init__(
246
+ self,
247
+ *,
248
+ host: str,
249
+ port: int,
250
+ sender: str,
251
+ recipients: list[str],
252
+ use_tls: bool = True,
253
+ username: str | None = None,
254
+ password: str | None = None,
255
+ timeout: float = 30.0,
256
+ allowed_hosts: tuple[str, ...] = (),
257
+ ) -> None:
258
+ self.host = host
259
+ self.port = port
260
+ self.sender = sender
261
+ self.recipients = recipients
262
+ self.use_tls = use_tls
263
+ self.username = username
264
+ self.password = password
265
+ self.timeout = timeout
266
+ # Optional egress allowlist (lower-cased) for the SMTP host; empty = any (WP-11c, parity with
267
+ # the webhook allowlist). The alert payload carries no PHI, so this is general egress control.
268
+ self.allowed_hosts = tuple(h.lower() for h in allowed_hosts)
269
+ self.name = "email"
270
+
271
+ async def send(self, event: dict[str, Any]) -> None:
272
+ await asyncio.to_thread(self._send, event)
273
+
274
+ def _send(self, event: dict[str, Any]) -> None:
275
+ send_plain_email(
276
+ host=self.host,
277
+ port=self.port,
278
+ sender=self.sender,
279
+ recipients=self.recipients,
280
+ subject=_subject(event),
281
+ body=_body(event),
282
+ use_tls=self.use_tls,
283
+ username=self.username,
284
+ password=self.password,
285
+ timeout=self.timeout,
286
+ allowed_hosts=self.allowed_hosts,
287
+ )
288
+
289
+
290
+ @dataclass(frozen=True)
291
+ class _RuleDecision:
292
+ """How to handle one event: its ``severity``, the transports to fire (``None`` = every configured
293
+ transport; ``()`` = suppress entirely), and the re-alert ``cooldown_seconds`` (``None`` = the
294
+ notifier's global ``realert_seconds``)."""
295
+
296
+ severity: str
297
+ transports: tuple[str, ...] | None
298
+ cooldown_seconds: float | None
299
+
300
+
301
+ # An event matching no rule keeps today's behaviour: warning, every transport, the global throttle.
302
+ _DEFAULT_DECISION = _RuleDecision(
303
+ severity=AlertSeverity.WARNING.value, transports=None, cooldown_seconds=None
304
+ )
305
+
306
+
307
+ class AlertRuleSet:
308
+ """Evaluate operator alert rules (ADR 0014) against an event, returning the **first** matching
309
+ rule's :class:`_RuleDecision` (severity / transports / cooldown) or the default. Pure and
310
+ synchronous, so the notifier consults it inline on the worker and it is unit-testable without the
311
+ async sink."""
312
+
313
+ def __init__(self, rules: Sequence[AlertRule]) -> None:
314
+ self._rules = tuple(rules)
315
+
316
+ def decide(self, event: Mapping[str, Any]) -> _RuleDecision:
317
+ for rule in self._rules:
318
+ if self._matches(rule, event):
319
+ transports = None if rule.transports is None else tuple(rule.transports)
320
+ return _RuleDecision(rule.severity.value, transports, rule.cooldown_seconds)
321
+ return _DEFAULT_DECISION
322
+
323
+ @staticmethod
324
+ def _matches(rule: AlertRule, event: Mapping[str, Any]) -> bool:
325
+ etype = str(event.get("type", ""))
326
+ if rule.event_type != "any" and rule.event_type != etype:
327
+ return False
328
+ # Case-sensitive, OS-independent glob (connection names are case-sensitive).
329
+ if not fnmatch.fnmatchcase(str(event.get("connection", "")), rule.connection):
330
+ return False
331
+ # Depth/age thresholds apply only to queue_buildup — a rule that sets one never matches another
332
+ # event type (you can't be "over depth" on a stopped connection).
333
+ if rule.min_depth is not None:
334
+ if etype != "queue_buildup" or int(event.get("depth", 0)) < rule.min_depth:
335
+ return False
336
+ if rule.min_oldest_seconds is not None:
337
+ if etype != "queue_buildup" or (
338
+ float(event.get("oldest_age_seconds", 0.0)) < rule.min_oldest_seconds
339
+ ):
340
+ return False
341
+ return True
342
+
343
+
344
+ class NotifierAlertSink(_BackgroundDispatcher[dict[str, Any]]):
345
+ """:class:`AlertSink` that fans each event out to one or more :class:`AlertTransport` on a
346
+ background task, with a per-(event, connection) re-alert throttle. An optional :class:`AlertRuleSet`
347
+ (ADR 0014) refines each event's severity, which transports fire, the cooldown, and suppression;
348
+ with no rules the behaviour is byte-identical (every event → every transport, global throttle).
349
+
350
+ The emit methods are synchronous and return immediately (enqueue only). Call :meth:`start` once a
351
+ loop is running (the ASGI lifespan does this) and :meth:`aclose` on shutdown."""
352
+
353
+ def __init__(
354
+ self,
355
+ transports: list[AlertTransport],
356
+ *,
357
+ realert_seconds: float = 300.0,
358
+ rules: Sequence[AlertRule] | None = None,
359
+ ) -> None:
360
+ super().__init__()
361
+ self._transports = transports
362
+ self._realert_seconds = realert_seconds
363
+ self._rules = AlertRuleSet(rules or [])
364
+ self._last_sent: dict[str, float] = {}
365
+
366
+ # --- AlertSink emit methods (sync, non-blocking) -------------------------
367
+
368
+ def connection_stopped(self, name: str, *, detail: str) -> None:
369
+ self._emit({"type": "connection_stopped", "connection": name, "detail": detail})
370
+
371
+ def queue_buildup(self, name: str, *, depth: int, oldest_age_seconds: float) -> None:
372
+ self._emit(
373
+ {
374
+ "type": "queue_buildup",
375
+ "connection": name,
376
+ "depth": depth,
377
+ "oldest_age_seconds": round(oldest_age_seconds, 1),
378
+ }
379
+ )
380
+
381
+ def storage_threshold(self, path: str, *, size_bytes: int, limit_bytes: int) -> None:
382
+ # The DB path stands in for "connection" so the realert throttle + subject keying work
383
+ # uniformly; the event carries no message content (no PHI), only sizes.
384
+ self._emit(
385
+ {
386
+ "type": "storage_threshold",
387
+ "connection": path,
388
+ "size_bytes": size_bytes,
389
+ "limit_bytes": limit_bytes,
390
+ }
391
+ )
392
+
393
+ def cert_expiry(self, name: str, *, path: str, not_after: str, days_remaining: int) -> None:
394
+ # The cert label stands in for "connection" so the realert throttle keys per cert; the payload
395
+ # carries the PEM path + expiry only (no key material, no message content — no PHI).
396
+ self._emit(
397
+ {
398
+ "type": "cert_expiry",
399
+ "connection": name,
400
+ "path": path,
401
+ "not_after": not_after,
402
+ "days_remaining": days_remaining,
403
+ }
404
+ )
405
+
406
+ def _emit(self, event: dict[str, Any]) -> None:
407
+ decision = self._rules.decide(event)
408
+ if decision.transports is not None and not decision.transports:
409
+ return # a rule suppressed this event (transports = [])
410
+ cooldown = (
411
+ decision.cooldown_seconds
412
+ if decision.cooldown_seconds is not None
413
+ else self._realert_seconds
414
+ )
415
+ key = f"{event['type']}:{event['connection']}"
416
+ now = time.monotonic()
417
+ last = self._last_sent.get(key)
418
+ if last is not None and now - last < cooldown:
419
+ return # throttled — same event/connection notified too recently
420
+ self._last_sent[key] = now
421
+ event["ts"] = time.time()
422
+ event["severity"] = (
423
+ decision.severity
424
+ ) # carried in the payload (webhook JSON / email subject)
425
+ if decision.transports is not None:
426
+ event["_transports"] = list(decision.transports) # internal routing; popped before send
427
+ self._enqueue(event, dropped=f"{event['type']} for {event['connection']!r}")
428
+
429
+ async def _handle(self, event: dict[str, Any]) -> None:
430
+ targets = event.pop("_transports", None) # a rule's transport subset (None = all)
431
+ for transport in self._transports:
432
+ if targets is not None and transport.name not in targets:
433
+ continue # this transport isn't in the matching rule's routing
434
+ try:
435
+ await transport.send(event)
436
+ except Exception:
437
+ # Best-effort: a failing transport must not drop the event for the others, nor
438
+ # ever propagate into the engine. Detail (not PHI) is in the event itself.
439
+ log.warning(
440
+ "alert transport %s failed for %s/%r",
441
+ transport.name,
442
+ event.get("type"),
443
+ event.get("connection"),
444
+ exc_info=True,
445
+ )
446
+
447
+
448
+ def notifier_from_settings(alerts: AlertsSettings) -> NotifierAlertSink | None:
449
+ """Build a :class:`NotifierAlertSink` from ``[alerts]`` settings, or ``None`` when no transport is
450
+ configured (the caller then leaves the engine on its default logging sink)."""
451
+ transports: list[AlertTransport] = []
452
+ if alerts.webhook_url:
453
+ transports.append(
454
+ WebhookTransport(
455
+ alerts.webhook_url,
456
+ timeout=alerts.webhook_timeout,
457
+ allowed_hosts=tuple(alerts.webhook_allowed_hosts),
458
+ )
459
+ )
460
+ if alerts.email_smtp_host and alerts.email_from and alerts.email_to:
461
+ transports.append(
462
+ EmailTransport(
463
+ host=alerts.email_smtp_host,
464
+ port=alerts.email_smtp_port,
465
+ sender=alerts.email_from,
466
+ recipients=list(alerts.email_to),
467
+ use_tls=alerts.email_use_tls,
468
+ username=alerts.email_username,
469
+ password=alerts.email_password,
470
+ timeout=alerts.email_timeout,
471
+ allowed_hosts=tuple(alerts.smtp_allowed_hosts),
472
+ )
473
+ )
474
+ if not transports:
475
+ return None
476
+ # Fail loud at config time if a rule routes to a transport that isn't configured (a typo or a
477
+ # missing webhook_url/email block) — caught at startup/reload, not silently swallowed at send.
478
+ configured = {t.name for t in transports}
479
+ for i, rule in enumerate(alerts.rules):
480
+ unknown = [t for t in (rule.transports or []) if t not in configured]
481
+ if unknown:
482
+ raise ValueError(
483
+ f"[alerts].rules[{i}] routes to unconfigured transport(s) {unknown}; "
484
+ f"configured: {sorted(configured)}"
485
+ )
486
+ return NotifierAlertSink(transports, realert_seconds=alerts.realert_seconds, rules=alerts.rules)
@@ -0,0 +1,100 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Operational alert emit-points for the delivery pipeline.
4
+
5
+ The conservative ordering defaults (FIFO head-of-line blocking, retry-forever, stop-connection on
6
+ internal error) are only *safe* if an operator is told when a lane stalls — a stopped connection or a
7
+ building backlog needs a human. A full alerting/notification framework is future work
8
+ (``docs/BACKLOG.md`` item 5); until it lands, the delivery worker emits these events to an
9
+ :class:`AlertSink` whose default implementation simply **logs** them at ``WARNING``. Wiring a real
10
+ notifier later is then a matter of passing a different sink to the
11
+ :class:`~messagefoundry.pipeline.wiring_runner.RegistryRunner` — the emit-points don't change.
12
+
13
+ This module is engine-side and dependency-light (stdlib logging only), so it never pulls the API or
14
+ console into the engine.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Protocol
21
+
22
+ __all__ = ["AlertSink", "LoggingAlertSink"]
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ class AlertSink(Protocol):
28
+ """Where the delivery pipeline reports operational stalls. A real notifier (email/PagerDuty/…)
29
+ implements this later; today the default :class:`LoggingAlertSink` just logs.
30
+
31
+ Implementations must be cheap and non-blocking — they run inline on a delivery worker, so a slow
32
+ sink would stall the lane it's reporting on. Never raise: an alert failure must not break delivery.
33
+ """
34
+
35
+ def connection_stopped(self, name: str, *, detail: str) -> None:
36
+ """An outbound connection's delivery worker halted (``InternalErrorPolicy.STOP`` fired on an
37
+ internal/code error). The lane is frozen until an operator intervenes (fix + reload/restart)."""
38
+ ...
39
+
40
+ def queue_buildup(self, name: str, *, depth: int, oldest_age_seconds: float) -> None:
41
+ """An outbound connection's backlog crossed a depth / oldest-in-lane-age threshold — e.g. a
42
+ retry-forever head is blocking the lane. (Emitted by the buildup detector — ordering Layer 4b.)"""
43
+ ...
44
+
45
+ def storage_threshold(self, path: str, *, size_bytes: int, limit_bytes: int) -> None:
46
+ """The message store grew past the configured ``[retention] max_db_mb`` advisory threshold.
47
+ Emitted by the :class:`~messagefoundry.pipeline.retention.RetentionRunner` once per pass while
48
+ over the limit; ``path`` identifies the DB, never any message content (no PHI)."""
49
+ ...
50
+
51
+ def cert_expiry(self, name: str, *, path: str, not_after: str, days_remaining: int) -> None:
52
+ """A served TLS certificate is expired or within the configured warn window. ``name`` labels
53
+ which cert (``"api"`` or the connection name); ``path`` is the PEM file; ``not_after`` is the
54
+ ISO expiry; ``days_remaining`` is negative once expired. No key material is read or logged.
55
+ Emitted by the :class:`~messagefoundry.pipeline.cert_expiry.CertExpiryRunner`."""
56
+ ...
57
+
58
+
59
+ class LoggingAlertSink:
60
+ """Default :class:`AlertSink`: log each event at ``WARNING``. No PHI — only the connection name
61
+ and queue shape are recorded, never a message body."""
62
+
63
+ def connection_stopped(self, name: str, *, detail: str) -> None:
64
+ log.warning(
65
+ "ALERT connection_stopped: outbound %r halted on internal error: %s", name, detail
66
+ )
67
+
68
+ def queue_buildup(self, name: str, *, depth: int, oldest_age_seconds: float) -> None:
69
+ log.warning(
70
+ "ALERT queue_buildup: outbound %r backlog depth=%d oldest=%.0fs",
71
+ name,
72
+ depth,
73
+ oldest_age_seconds,
74
+ )
75
+
76
+ def storage_threshold(self, path: str, *, size_bytes: int, limit_bytes: int) -> None:
77
+ log.warning(
78
+ "ALERT storage_threshold: store %r is %.1f MB, over the %.1f MB retention limit",
79
+ path,
80
+ size_bytes / 1_000_000,
81
+ limit_bytes / 1_000_000,
82
+ )
83
+
84
+ def cert_expiry(self, name: str, *, path: str, not_after: str, days_remaining: int) -> None:
85
+ if days_remaining < 0:
86
+ log.warning(
87
+ "ALERT cert_expiry: %r certificate (%s) EXPIRED %d day(s) ago (not_after=%s)",
88
+ name,
89
+ path,
90
+ -days_remaining,
91
+ not_after,
92
+ )
93
+ else:
94
+ log.warning(
95
+ "ALERT cert_expiry: %r certificate (%s) expires in %d day(s) (not_after=%s)",
96
+ name,
97
+ path,
98
+ days_remaining,
99
+ not_after,
100
+ )