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,1918 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Code-first wiring: declare **Connections** and decorate **Router**/**Handler** functions.
4
+
5
+ A config module (loaded from a directory via :func:`load_config`) declares named inbound/outbound
6
+ **Connections** and registers Router/Handler scripts — wired by name, with no enclosing "channel"
7
+ object::
8
+
9
+ from messagefoundry import inbound, outbound, router, handler, Send, MLLP, File
10
+
11
+ inbound("IB_Test_ADT", MLLP(port=2575), router="adt_router")
12
+ outbound("FILE-OUT_Test_ADT", File(directory="./out/adt"))
13
+
14
+ @router("adt_router")
15
+ def route(msg):
16
+ return ["archive"] if msg["MSH-9.1"] == "ADT" else [] # [] -> logged UNROUTED
17
+
18
+ @handler("archive")
19
+ def handle(msg):
20
+ if msg["MSH-9.2"] not in ("A01", "A04", "A08"):
21
+ return None # None -> logged FILTERED
22
+ msg["MSH-3"] = "FOUNDRY"
23
+ return Send("FILE-OUT_Test_ADT", msg)
24
+
25
+ This module only **declares** the graph (the registry); running it (inbound → router → handlers →
26
+ outbox → ACK) is the engine's job. Routers/Handlers are pure: they return where a message goes,
27
+ they never do network I/O (the outbox worker delivers, preserving at-least-once).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import importlib.util
34
+ import ipaddress
35
+ import os
36
+ import sys
37
+ import threading
38
+ from collections.abc import Callable, Mapping
39
+ from contextlib import contextmanager, suppress
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any, Iterator
43
+
44
+ from messagefoundry.config.code_sets import (
45
+ CODESETS_DIR_NAME,
46
+ CodeSet,
47
+ CodeSetError,
48
+ activated as _code_sets_activated,
49
+ code_set as _resolve_code_set,
50
+ load_code_sets,
51
+ )
52
+ from messagefoundry.config.models import (
53
+ AckAfter,
54
+ AckMode,
55
+ BuildupThreshold,
56
+ ConnectorType,
57
+ ContentType,
58
+ InternalErrorPolicy,
59
+ OrderingMode,
60
+ RetryPolicy,
61
+ Validation,
62
+ )
63
+ from messagefoundry.parsing.message import Message, RawMessage
64
+
65
+ __all__ = [
66
+ "ConnectionSpec",
67
+ "MLLP",
68
+ "Tcp",
69
+ "X12",
70
+ "File",
71
+ "Timer",
72
+ "Loopback",
73
+ "Rest",
74
+ "Database",
75
+ "DatabasePoll",
76
+ "Soap",
77
+ "Sftp",
78
+ "Ftp",
79
+ "Send",
80
+ "SetState",
81
+ "EnvRef",
82
+ "env",
83
+ "CodeSet",
84
+ "code_set",
85
+ "Reference",
86
+ "FileRef",
87
+ "DatabaseRef",
88
+ "ReferenceSpec",
89
+ "ReferenceSourceSpec",
90
+ "resolve_env_settings",
91
+ "referenced_env_keys",
92
+ "display_settings",
93
+ "redacted_settings",
94
+ "InboundConnection",
95
+ "OutboundConnection",
96
+ "Registry",
97
+ "WiringError",
98
+ "Diagnostic",
99
+ "inbound",
100
+ "outbound",
101
+ "build_inbound_connection",
102
+ "build_outbound_connection",
103
+ "parse_env_setting",
104
+ "router",
105
+ "handler",
106
+ "load_config",
107
+ "validate_config",
108
+ ]
109
+
110
+
111
+ class WiringError(ValueError):
112
+ """A connection/router/handler was declared wrong, or references something missing."""
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Diagnostic:
117
+ """One config problem, for tools (e.g. the IDE Problems panel) that want all errors at once."""
118
+
119
+ message: str
120
+ file: str | None = None
121
+ severity: str = "error"
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class ConnectionSpec:
126
+ """The transport bits of a Connection (type + settings); the logic lives in Router/Handler."""
127
+
128
+ type: ConnectorType
129
+ settings: dict[str, Any]
130
+
131
+
132
+ # --- environment-specific values (DEV/PROD) ----------------------------------
133
+
134
+ #: Sentinel for "no default" so ``env("k", default=None)`` (a deliberate None) is distinguishable
135
+ #: from "unset" (which makes a missing value a hard load error).
136
+ _UNSET: Any = object()
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class EnvRef:
141
+ """A reference to an environment-specific value (e.g. a downstream host that differs DEV vs PROD).
142
+
143
+ The graph carries the *reference*; the engine resolves it against the running instance's
144
+ environment values when it builds the connector. One graph therefore runs in every environment,
145
+ and a referenced-but-undefined value fails **loud** at load/promote rather than silently
146
+ becoming a blank host (the classic Mirth ``${key}`` footgun). Authored via :func:`env`."""
147
+
148
+ key: str
149
+ default: Any = _UNSET
150
+ cast: Callable[[Any], Any] | None = None
151
+
152
+
153
+ def env(key: str, *, default: Any = _UNSET, cast: Callable[[Any], Any] | None = None) -> EnvRef:
154
+ """Reference an environment-specific value, resolved per running instance (DEV/PROD).
155
+
156
+ Use it inside a connection spec for anything that differs by environment — a downstream peer,
157
+ a path, a credential::
158
+
159
+ outbound("OB_EPIC_ADT", MLLP(host=env("epic_host"), port=env("epic_port", cast=int)))
160
+
161
+ Values come from the instance's environment: ``environments/<env>.toml`` (non-secrets, versioned)
162
+ overlaid with ``MEFOR_VALUE_<KEY>`` env vars (secrets). A referenced key with no value and no
163
+ ``default`` makes the engine refuse to load/promote that graph — never a silent blank.
164
+
165
+ The key is matched case-insensitively (lower-cased here, as it is on the value side), so
166
+ ``env("EPIC_HOST")``, the file key ``epic_host``, and ``MEFOR_VALUE_EPIC_HOST`` all line up."""
167
+ return EnvRef(key=key.lower(), default=default, cast=cast)
168
+
169
+
170
+ #: Named casts a ``connections.toml`` env-ref may request (ADR 0007). A data file/GUI can't author an
171
+ #: arbitrary Python callable the way :func:`env` can, so the file form is restricted to these — and
172
+ #: ``int`` is the only cast used across the migration estate today.
173
+ _NAMED_CASTS: dict[str, Callable[[Any], Any]] = {
174
+ "int": int,
175
+ "float": float,
176
+ "bool": bool,
177
+ "str": str,
178
+ }
179
+
180
+ #: The only keys an env-ref inline table may carry (the inverse of :func:`display_settings`).
181
+ _ENVREF_KEYS = frozenset({"env", "default", "cast"})
182
+
183
+
184
+ def parse_env_setting(value: Any) -> Any:
185
+ """Decode one ``connections.toml`` settings value into a literal or an :class:`EnvRef` (ADR 0007).
186
+
187
+ An inline table carrying the reserved key ``env`` (and only ``env``/``default``/``cast``) becomes an
188
+ :class:`EnvRef` — the inverse of :func:`display_settings`'s ``{"env": key[, "default"]}`` encoding;
189
+ ``cast`` is a **named** cast (``"int"``/``"float"``/``"bool"``/``"str"``) since a file can't carry a
190
+ Python callable. Any other value (a scalar, list, or a plain dict like a REST ``headers`` map) is
191
+ returned verbatim. Raises :class:`WiringError` on a malformed env marker or an unknown cast name."""
192
+ if not (isinstance(value, dict) and "env" in value and set(value) <= _ENVREF_KEYS):
193
+ return value
194
+ key = value["env"]
195
+ if not isinstance(key, str) or not key:
196
+ raise WiringError(f"env reference must name a non-empty string key, got {key!r}")
197
+ cast_name = value.get("cast")
198
+ if cast_name is not None and cast_name not in _NAMED_CASTS:
199
+ raise WiringError(
200
+ f"env reference {key!r}: unknown cast {cast_name!r} "
201
+ f"(use one of {', '.join(sorted(_NAMED_CASTS))})"
202
+ )
203
+ cast = _NAMED_CASTS[cast_name] if cast_name is not None else None
204
+ default = value["default"] if "default" in value else _UNSET
205
+ return EnvRef(key=key.lower(), default=default, cast=cast)
206
+
207
+
208
+ # --- code sets (reference lookup tables) -------------------------------------
209
+
210
+
211
+ def code_set(name: str) -> CodeSet:
212
+ """Reference a managed reference table from ``codesets/<name>.{csv,toml}`` (next to ``--config``).
213
+
214
+ The code-first alternative to a hand-maintained dict: capture it once at a module's top level
215
+ (``DIET = code_set("epic_diets")``) or look it up at call time inside a handler
216
+ (``code_set("epic_diets").get(x)``) — both resolve against the active set the loader/runner has
217
+ published. Returns a frozen, read-only :class:`CodeSet` (a mapping: ``cs[k]`` / ``cs.get(k, d)`` /
218
+ ``k in cs`` / ``len(cs)`` / iteration); it is shared across transforms, so it must not be mutated.
219
+
220
+ A missing or malformed code set fails **loud** as a :class:`WiringError`, surfaced by ``validate`` /
221
+ ``check`` / reload exactly like a missing ``env()`` value — never a silent empty table. The
222
+ reference data is read-only, so the lookup stays pure (re-run-safe); see
223
+ :mod:`messagefoundry.config.code_sets` for the one reload-vs-re-run caveat."""
224
+ try:
225
+ return _resolve_code_set(name)
226
+ except CodeSetError as exc:
227
+ raise WiringError(str(exc)) from exc
228
+
229
+
230
+ # --- reference sets (external-data enrichment, ADR 0006 Tier 1) ---------------
231
+ # A reference set is declared in a wiring module with Reference(name, source=…); the engine's
232
+ # ReferenceSyncRunner materializes the source OFF the message path into a versioned, encrypted store
233
+ # snapshot, and a Handler reads it PURELY at run time via reference("name").get(key) (the read accessor
234
+ # lives in messagefoundry.config.reference). The DECLARATION here is the source + cadence only.
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class ReferenceSourceSpec:
239
+ """Where a reference set's data is materialized from (the analog of :class:`ConnectionSpec`).
240
+
241
+ ``kind`` selects the source connector (``"file"`` today; ``"database"`` is ADR-0006 increment 2);
242
+ ``settings`` carries its options (may hold :class:`EnvRef` values, resolved per environment)."""
243
+
244
+ kind: str
245
+ settings: dict[str, Any]
246
+
247
+
248
+ def FileRef(
249
+ *,
250
+ path: str | EnvRef,
251
+ encoding: str = "utf-8",
252
+ ) -> ReferenceSourceSpec:
253
+ """A reference **source** backed by a local CSV/TOML file (ADR 0006 Tier 1).
254
+
255
+ The file has the same shape as a code set (``code_set`` format: header row, first column the key;
256
+ one value column → scalar, several → ``{header: cell}``; or a flat/nested TOML). It is the path for
257
+ an externally-produced export (e.g. a nightly job dumps a provider directory to a share): the engine
258
+ re-reads it on the set's refresh cadence and materializes it into a versioned, encrypted snapshot,
259
+ so an updated export is picked up without a config reload. ``path`` may be an :func:`env` ref."""
260
+ return ReferenceSourceSpec("file", {"path": path, "encoding": encoding})
261
+
262
+
263
+ def DatabaseRef(
264
+ *,
265
+ server: str | EnvRef,
266
+ database: str | EnvRef,
267
+ statement: str,
268
+ key_column: str,
269
+ value_column: str | None = None,
270
+ auth: str = "sql",
271
+ username: str | EnvRef | None = None,
272
+ password: str | EnvRef | None = None,
273
+ port: int | EnvRef = 1433,
274
+ encrypt: bool = True,
275
+ trust_server_certificate: bool = False,
276
+ connect_timeout: int = 15,
277
+ app_name: str = "messagefoundry",
278
+ odbc_driver: str = "ODBC Driver 18 for SQL Server",
279
+ pool_max: int = 5,
280
+ ) -> ReferenceSourceSpec:
281
+ """A reference **source** backed by a SQL query (ADR 0006 increment 2; SQL Server via the
282
+ ``[sqlserver]`` extra + ODBC Driver 18 — **production / supported**, like the DATABASE connector).
283
+
284
+ The engine runs ``statement`` (a read-only ``SELECT``/proc) on the set's refresh cadence and builds
285
+ the snapshot from the rows: ``key_column`` is the lookup key; ``value_column`` (if given) is that
286
+ column's value, else the value is a dict of the remaining columns (the multi-column ``code_set``
287
+ shape). Put secrets (``password``) in :func:`env`. TLS is on by default; weakening it needs
288
+ ``MEFOR_ALLOW_INSECURE_TLS``. The dial-out is gated by the **fail-closed** ``[egress].allowed_db``
289
+ allowlist, exactly like a DATABASE poll source — point the engine only at allowed hosts."""
290
+ return ReferenceSourceSpec(
291
+ "database",
292
+ {
293
+ "server": server,
294
+ "database": database,
295
+ "statement": statement,
296
+ "key_column": key_column,
297
+ "value_column": value_column,
298
+ "auth": auth,
299
+ "username": username,
300
+ "password": password,
301
+ "port": port,
302
+ "encrypt": encrypt,
303
+ "trust_server_certificate": trust_server_certificate,
304
+ "connect_timeout": connect_timeout,
305
+ "app_name": app_name,
306
+ "odbc_driver": odbc_driver,
307
+ "pool_max": pool_max,
308
+ },
309
+ )
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class ReferenceSpec:
314
+ """A declared reference set: ``name`` + its :class:`ReferenceSourceSpec` + sync cadence.
315
+
316
+ Held in :class:`Registry` and consumed by the engine's ``ReferenceSyncRunner``; the data lives in
317
+ the store, read via ``reference(name)``. ``refresh_seconds`` is the materialization cadence (the
318
+ runner also syncs once on startup); ``max_staleness_seconds`` (0 = off) is a reserved freshness
319
+ knob for a follow-up."""
320
+
321
+ name: str
322
+ source: ReferenceSourceSpec
323
+ refresh_seconds: float = 3600.0
324
+ max_staleness_seconds: float = 0.0
325
+
326
+
327
+ def Reference(
328
+ name: str,
329
+ *,
330
+ source: ReferenceSourceSpec,
331
+ refresh_seconds: float = 3600.0,
332
+ max_staleness_seconds: float = 0.0,
333
+ ) -> None:
334
+ """Declare a reference set into the graph being loaded (side-effecting, like :func:`inbound`).
335
+
336
+ The engine materializes ``source`` into a versioned snapshot every ``refresh_seconds`` (and once at
337
+ startup); a Handler reads it purely with ``reference(name).get(key)``. Example::
338
+
339
+ Reference("provider_npi", source=FileRef(path=env("provider_npi_csv")), refresh_seconds=3600)
340
+ """
341
+ if refresh_seconds < 0:
342
+ raise WiringError(f"Reference({name!r}): refresh_seconds must be >= 0")
343
+ _active_registry().add_reference(
344
+ ReferenceSpec(
345
+ name=name,
346
+ source=source,
347
+ refresh_seconds=refresh_seconds,
348
+ max_staleness_seconds=max_staleness_seconds,
349
+ )
350
+ )
351
+
352
+
353
+ # --- live lookup connections (handler-callable db_lookup, ADR 0010) -----------
354
+ # A DatabaseLookup declares a NAMED, read-only database connection a Handler queries LIVE at run time via
355
+ # db_lookup(name, statement, params) (the read accessor lives in messagefoundry.config.db_lookup). Unlike
356
+ # a reference set (a synced snapshot read purely), there is no statement or cadence here — only the
357
+ # connection; each call supplies its own statement. The engine builds one pooled executor from these.
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class DatabaseLookupSpec:
362
+ """A declared live-lookup database connection: ``name`` + connection ``settings`` (no statement — the
363
+ statement is supplied per :func:`~messagefoundry.config.db_lookup.db_lookup` call). ``settings`` may
364
+ hold :class:`EnvRef` values (put secrets like ``password`` in :func:`env`)."""
365
+
366
+ name: str
367
+ settings: dict[str, Any]
368
+
369
+
370
+ def DatabaseLookup(
371
+ name: str,
372
+ *,
373
+ server: str | EnvRef,
374
+ database: str | EnvRef,
375
+ auth: str = "sql",
376
+ username: str | EnvRef | None = None,
377
+ password: str | EnvRef | None = None,
378
+ port: int | EnvRef = 1433,
379
+ encrypt: bool = True,
380
+ trust_server_certificate: bool = False,
381
+ connect_timeout: int = 15,
382
+ app_name: str = "messagefoundry",
383
+ odbc_driver: str = "ODBC Driver 18 for SQL Server",
384
+ pool_max: int = 5,
385
+ acquire_timeout: float = 30.0, # cap a pooled-connection borrow (s) — fail transiently, not forever
386
+ ) -> None:
387
+ """Declare a named live-lookup database connection (SQL Server via the ``[sqlserver]`` extra + ODBC
388
+ Driver 18 — **production / supported**, like the DATABASE connector). A Handler queries it at run time with
389
+ ``db_lookup(name, statement, params)`` (a read-only ``SELECT``/proc); the rows come back as
390
+ ``{column: value}`` dicts. Side-effecting, like :func:`Reference`/:func:`inbound`.
391
+
392
+ Put secrets (``password``) in :func:`env`. TLS is on by default; weakening it needs
393
+ ``MEFOR_ALLOW_INSECURE_TLS``. The dial-out is gated by the **fail-closed** ``[egress].allowed_db``
394
+ allowlist, like a DATABASE source — point the engine only at allowed hosts. Example::
395
+
396
+ DatabaseLookup("clarity", server=env("clarity_host"), database="Clarity",
397
+ username=env("clarity_user"), password=env("clarity_pw"))
398
+ """
399
+ _active_registry().add_lookup(
400
+ DatabaseLookupSpec(
401
+ name,
402
+ {
403
+ "server": server,
404
+ "database": database,
405
+ "auth": auth,
406
+ "username": username,
407
+ "password": password,
408
+ "port": port,
409
+ "encrypt": encrypt,
410
+ "trust_server_certificate": trust_server_certificate,
411
+ "connect_timeout": connect_timeout,
412
+ "app_name": app_name,
413
+ "odbc_driver": odbc_driver,
414
+ "pool_max": pool_max,
415
+ "acquire_timeout": acquire_timeout,
416
+ },
417
+ )
418
+ )
419
+
420
+
421
+ def resolve_env_settings(settings: Mapping[str, Any], values: Mapping[str, Any]) -> dict[str, Any]:
422
+ """Return a copy of ``settings`` with every :class:`EnvRef` resolved against ``values``.
423
+
424
+ Resolution order per ref: the environment value (cast if a ``cast`` was given), else its
425
+ ``default``, else it's *missing*. Raises a single :class:`WiringError` listing **all** problems
426
+ at once — both missing keys and values that fail their ``cast`` (naming setting/key/value) — so
427
+ the failure is loud and actionable, not a raw ``ValueError`` traceback that names nothing and
428
+ aborts on the first bad value (fail loud, never blank; review M-22)."""
429
+ resolved: dict[str, Any] = {}
430
+ missing: list[str] = []
431
+ bad: list[str] = []
432
+ for name, value in settings.items():
433
+ if isinstance(value, EnvRef):
434
+ if value.key in values:
435
+ raw = values[value.key]
436
+ if value.cast is None:
437
+ resolved[name] = raw
438
+ else:
439
+ try:
440
+ resolved[name] = value.cast(raw)
441
+ except (ValueError, TypeError) as exc:
442
+ bad.append(f"setting {name!r} (env {value.key!r}={raw!r}): {exc}")
443
+ elif value.default is not _UNSET:
444
+ resolved[name] = value.default
445
+ else:
446
+ missing.append(value.key)
447
+ else:
448
+ resolved[name] = value
449
+ problems: list[str] = []
450
+ if missing:
451
+ problems.append("missing: " + ", ".join(sorted(set(missing))))
452
+ if bad:
453
+ problems.append("uncastable: " + "; ".join(bad))
454
+ if problems:
455
+ raise WiringError(
456
+ "environment value(s) unusable — "
457
+ + "; ".join(problems)
458
+ + " — set/fix them in this environment's values (environments/<env>.toml or MEFOR_VALUE_*)"
459
+ )
460
+ return resolved
461
+
462
+
463
+ def referenced_env_keys(settings: Mapping[str, Any]) -> list[str]:
464
+ """The environment keys a settings dict references (sorted, de-duplicated) — for tooling."""
465
+ return sorted({v.key for v in settings.values() if isinstance(v, EnvRef)})
466
+
467
+
468
+ def display_settings(settings: Mapping[str, Any]) -> dict[str, Any]:
469
+ """A JSON-safe view of settings for introspection: each EnvRef becomes ``{"env": key[, default]}``."""
470
+ out: dict[str, Any] = {}
471
+ for name, value in settings.items():
472
+ if isinstance(value, EnvRef):
473
+ ref: dict[str, Any] = {"env": value.key}
474
+ if value.default is not _UNSET:
475
+ ref["default"] = value.default
476
+ out[name] = ref
477
+ else:
478
+ out[name] = value
479
+ return out
480
+
481
+
482
+ #: Settings keys whose values are credentials — redacted in the API metadata view. Secrets are
483
+ #: required to be ``env()`` refs (so they already render as ``{"env": ...}``); this is defence in
484
+ #: depth against an inline value, and it suppresses an ``env()`` *default* for a secret field. Covers
485
+ #: every credential-bearing connector setting (HTTP auth, DB user/password, SFTP key + passphrase).
486
+ _SECRET_SETTING_KEYS = frozenset(
487
+ {
488
+ "password",
489
+ "username",
490
+ "bearer_token",
491
+ "basic_password",
492
+ "basic_user",
493
+ "key_password",
494
+ "private_key",
495
+ "api_key",
496
+ "token",
497
+ }
498
+ )
499
+
500
+ #: Header names whose value is a credential — redacted inside a REST/SOAP ``headers`` table (the
501
+ #: project requires secrets via ``env()`` bearer/basic settings, not inline headers; this is defence
502
+ #: in depth for an operator who hard-codes one anyway). Compared case-insensitively.
503
+ _SECRET_HEADER_NAMES = frozenset(
504
+ {"authorization", "proxy-authorization", "x-api-key", "api-key", "cookie"}
505
+ )
506
+
507
+
508
+ def redacted_settings(settings: Mapping[str, Any]) -> dict[str, Any]:
509
+ """A JSON-safe, secret-scrubbed view of a connection's settings for the API ``/metadata`` endpoint:
510
+ each EnvRef becomes ``{"env": key}`` (the value is never resolved — only the key is shown), a
511
+ credential field rendered inline is replaced with ``"***"`` (an ``env()`` *default* is dropped for
512
+ a credential field so a fallback secret can't leak), and a credential header inside a ``headers``
513
+ table is redacted too."""
514
+ out: dict[str, Any] = {}
515
+ for name, value in settings.items():
516
+ is_secret = name in _SECRET_SETTING_KEYS
517
+ if isinstance(value, EnvRef):
518
+ ref: dict[str, Any] = {"env": value.key}
519
+ if value.default is not _UNSET and not is_secret:
520
+ ref["default"] = value.default
521
+ out[name] = ref
522
+ elif is_secret:
523
+ out[name] = "***"
524
+ elif name == "headers" and isinstance(value, dict):
525
+ out[name] = {
526
+ k: ("***" if str(k).lower() in _SECRET_HEADER_NAMES else v)
527
+ for k, v in value.items()
528
+ }
529
+ else:
530
+ out[name] = value
531
+ return out
532
+
533
+
534
+ def MLLP(
535
+ *,
536
+ host: str | EnvRef | None = None, # OUTBOUND: the downstream peer (required; may be env()).
537
+ # INBOUND: omit — the bind interface is a service setting ([inbound].bind_host), not authored.
538
+ port: int | EnvRef,
539
+ encoding: str = "utf-8",
540
+ # Inbound DoS guards (defaults mirror transports.mllp.DEFAULT_*; pass None/0 to disable):
541
+ max_connections: int | None = 256, # cap concurrent clients (connection-flood guard)
542
+ receive_timeout: float | None = 60.0, # close a client idle this many seconds (slowloris)
543
+ max_frame_bytes: int | None = 16 * 1024 * 1024, # cap one frame's bytes (OOM guard); both dirs
544
+ connect_timeout: float = 10.0, # outbound: TCP connect timeout (seconds)
545
+ timeout_seconds: float = 30.0, # outbound: wait this long for the ACK
546
+ encoding_characters: str | None = None, # OUTBOUND: re-encode MSH-1/MSH-2 delimiters per dest
547
+ capture_response: bool = False, # outbound: capture the application ACK (MSA/ERR) as a reply (ADR 0013)
548
+ reingress_to: str
549
+ | None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
550
+ # --- TLS (WP-13b, ADR 0002) — per-connection MLLP-over-TLS ---
551
+ tls: bool = False, # turn TLS on (inbound: present a server cert; outbound: verify the peer)
552
+ tls_cert_file: str
553
+ | None = None, # inbound: SERVER cert (required when tls); outbound: CLIENT cert (mTLS)
554
+ tls_key_file: str | None = None, # private key for tls_cert_file
555
+ tls_ca_file: str
556
+ | None = None, # trust anchor — inbound: verify client certs (mTLS); outbound: verify server
557
+ tls_verify: bool = True, # OUTBOUND: verify the server cert (false is MITM-able → needs MEFOR_ALLOW_INSECURE_TLS)
558
+ tls_check_hostname: bool = True, # OUTBOUND: require the server cert to match `host`
559
+ ) -> ConnectionSpec:
560
+ """An MLLP endpoint. Inbound uses port/max_connections/receive_timeout/max_frame_bytes (the
561
+ bind interface comes from the service's ``[inbound].bind_host``, so ``host`` is rejected on an
562
+ inbound); outbound uses host/port/connect_timeout/timeout_seconds/max_frame_bytes. ``encoding``
563
+ applies to framing in both directions. ``capture_response`` (outbound, ADR 0013) records the
564
+ application ACK as a captured reply (a negative ACK still dead-letters/retries unchanged).
565
+
566
+ ``encoding_characters`` (**outbound only**, Corepoint ``MsgSend -override component`` parity) makes
567
+ this destination re-encode each outgoing message with a different set of HL7 delimiters before
568
+ framing. Give the **5 MSH delimiter characters in MSH order** — MSH-1 (field separator) followed by
569
+ the four MSH-2 characters (component, repetition, escape, subcomponent) — e.g. the HL7 default is
570
+ ``"|^~\\\\&"``. The connector parses the payload with its *current* (MSH-derived) delimiters,
571
+ rewrites MSH-1/MSH-2, and re-serializes the whole body with the new ones, so a downstream re-parse
572
+ yields the same logical fields under the new delimiters. ``None`` (the default) leaves the payload
573
+ **byte-identical** — fully backward compatible. The string is validated at connector build (exactly
574
+ five characters, all distinct); a non-HL7 payload that can't be parsed fails the delivery loud
575
+ (``DeliveryError``) rather than being silently corrupted.
576
+
577
+ **TLS (WP-13b).** ``tls=True`` wraps the connection: inbound presents ``tls_cert_file``/``tls_key_file``
578
+ (a server identity; ``tls_ca_file`` adds opt-in mTLS — require + verify a client cert); outbound
579
+ verifies the server cert against ``tls_ca_file`` (or the system trust store) with hostname checking,
580
+ and may present ``tls_cert_file`` for mTLS. ``tls_verify=False`` (outbound) is MITM-able and refused
581
+ unless ``MEFOR_ALLOW_INSECURE_TLS`` is set (loud warning) — exactly like LDAPS / SQL Server. TLS is
582
+ TLS 1.2+ and composes with the ``[egress].allowed_mllp`` allowlist (both enforced)."""
583
+ return ConnectionSpec(
584
+ ConnectorType.MLLP,
585
+ {
586
+ "host": host,
587
+ "port": port,
588
+ "encoding": encoding,
589
+ "max_connections": max_connections,
590
+ "receive_timeout": receive_timeout,
591
+ "max_frame_bytes": max_frame_bytes,
592
+ "connect_timeout": connect_timeout,
593
+ "timeout_seconds": timeout_seconds,
594
+ "encoding_characters": encoding_characters,
595
+ "capture_response": capture_response,
596
+ "reingress_to": reingress_to,
597
+ "tls": tls,
598
+ "tls_cert_file": tls_cert_file,
599
+ "tls_key_file": tls_key_file,
600
+ "tls_ca_file": tls_ca_file,
601
+ "tls_verify": tls_verify,
602
+ "tls_check_hostname": tls_check_hostname,
603
+ },
604
+ )
605
+
606
+
607
+ def Tcp(
608
+ *,
609
+ host: str | EnvRef | None = None, # OUTBOUND: the downstream peer (required; may be env()).
610
+ # INBOUND: omit — the bind interface is a service setting ([inbound].bind_host), not authored.
611
+ port: int | EnvRef,
612
+ # Framing: a preset name ("stx_etx" | "vt_fs" | "mllp") OR explicit start/end[/trailer] byte ints.
613
+ framing: str | None = "stx_etx",
614
+ start: int | None = None, # explicit start delimiter byte (use instead of `framing`)
615
+ end: int | None = None, # explicit end delimiter byte
616
+ trailer: int | None = None, # explicit optional trailer byte
617
+ encoding: str = "utf-8",
618
+ # Inbound DoS guards (defaults mirror MLLP; pass None/0 to disable):
619
+ max_connections: int | None = 256, # cap concurrent clients (connection-flood guard)
620
+ receive_timeout: float | None = 60.0, # close a client idle this many seconds (slowloris)
621
+ max_frame_bytes: int | None = 16 * 1024 * 1024, # cap one frame's bytes (OOM guard); both dirs
622
+ connect_timeout: float = 10.0, # outbound: TCP connect timeout (seconds)
623
+ timeout_seconds: float = 30.0, # outbound: send/await-reply timeout
624
+ expect_reply: bool = False, # outbound: read one framed reply and treat it as confirmation
625
+ capture_response: bool = False, # outbound: capture the framed reply (requires expect_reply, ADR 0013)
626
+ reingress_to: str
627
+ | None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
628
+ ) -> ConnectionSpec:
629
+ """A raw-TCP endpoint with **configurable delimiter framing**, relaying the payload **opaquely**
630
+ (no structured parse) — built for X12-over-TCP feeds. Set ``framing`` to a preset
631
+ (``"stx_etx"`` = ``0x02``/``0x03``, the default; ``"vt_fs"``/``"mllp"`` = ``0x0B``/``0x1C``/``0x0D``)
632
+ **or** give explicit ``start``/``end`` (with optional ``trailer``) delimiter byte ints — not both.
633
+
634
+ Inbound takes no ``host`` (the bind interface is ``[inbound].bind_host``); pair it with
635
+ ``content_type="x12"`` on ``inbound(...)`` so the body routes as a ``RawMessage`` (ADR 0004).
636
+ There is **no HL7 ACK** — a Handler may still return a payload, which is framed back to the
637
+ sender. Outbound dials ``host``/``port``, frames + sends; with ``expect_reply`` it waits for one
638
+ framed reply and treats receiving it as confirmation (the reply is **not** parsed — X12 997/TA1
639
+ acks are a deferred follow-up). Delivery is at-least-once → the receiver **must be idempotent**."""
640
+ return ConnectionSpec(
641
+ ConnectorType.TCP,
642
+ {
643
+ "host": host,
644
+ "port": port,
645
+ "framing": framing,
646
+ "start": start,
647
+ "end": end,
648
+ "trailer": trailer,
649
+ "encoding": encoding,
650
+ "max_connections": max_connections,
651
+ "receive_timeout": receive_timeout,
652
+ "max_frame_bytes": max_frame_bytes,
653
+ "connect_timeout": connect_timeout,
654
+ "timeout_seconds": timeout_seconds,
655
+ "expect_reply": expect_reply,
656
+ "capture_response": capture_response,
657
+ "reingress_to": reingress_to,
658
+ },
659
+ )
660
+
661
+
662
+ def X12(
663
+ *,
664
+ host: str | EnvRef | None = None, # OUTBOUND: the downstream peer (required; may be env()).
665
+ # INBOUND: omit — the bind interface is a service setting ([inbound].bind_host), not authored.
666
+ port: int | EnvRef,
667
+ encoding: str = "utf-8",
668
+ # Inbound DoS guards (defaults mirror MLLP/TCP; pass None/0 to disable):
669
+ max_connections: int | None = 256, # cap concurrent clients (connection-flood guard)
670
+ receive_timeout: float | None = 60.0, # close a client idle this many seconds (slowloris)
671
+ max_interchange_bytes: int | None = 16
672
+ * 1024
673
+ * 1024, # cap one interchange's bytes (OOM); both dirs
674
+ connect_timeout: float = 10.0, # outbound: TCP connect timeout (seconds)
675
+ timeout_seconds: float = 30.0, # outbound: send/await-reply timeout
676
+ expect_reply: bool = False, # outbound: read one returned interchange and treat it as confirmation
677
+ # --- ADR 0016: synchronous request/response (real-time eligibility 270/271, 278N, 277) ---
678
+ capture_response: bool = False, # capture the returned interchange (271/TA1) as a reply (ADR 0013)
679
+ reingress_to: str
680
+ | None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
681
+ ta1_required: bool = False, # outbound: a delivery that reads no TA1/business reply is a retry
682
+ ) -> ConnectionSpec:
683
+ """A raw-TCP **ASC X12 EDI** endpoint (ADR 0012), framed by the interchange itself (``ISA…IEA``) —
684
+ there are **no delimiter-framing knobs**: the segment terminator is discovered from each ISA header.
685
+ Use this when the interchange is the frame; for partners who wrap each interchange in a fixed
686
+ sentinel (STX/ETX, VT/FS) use ``Tcp(framing=...)`` instead.
687
+
688
+ Inbound takes no ``host`` (the bind interface is ``[inbound].bind_host``); pair it with
689
+ ``content_type="x12"`` on ``inbound(...)`` so the body routes as a ``RawMessage`` (ADR 0004) that a
690
+ Router/Handler parses on demand via ``messagefoundry.parsing.x12``. The inbound is an opaque relay
691
+ (no TA1/997/999). Outbound dials ``host``/``port`` and writes the interchange verbatim; with
692
+ ``expect_reply`` it waits for one returned interchange as confirmation (not parsed). **Synchronous
693
+ request/response** (ADR 0016): set ``capture_response`` (or ``reingress_to=`` a ``Loopback()``
694
+ inbound) to capture the returned **271/TA1** as a reply — a **TA1** interchange acknowledgement is
695
+ classified (TA1*A → accepted; TA1*R → permanent reject/dead-letter; TA1*E → accepted-with-warning,
696
+ *not* retried), a business 271/277/278 returned instead is itself the confirmation; ``ta1_required``
697
+ makes a no-reply a retry. Egress is gated by ``[egress].allowed_tcp`` (X12 shares the raw-TCP
698
+ allowlist). Delivery is at-least-once → the receiver **must be idempotent** (a crash-re-send of a
699
+ non-idempotent 270 yields a fresh 271 captured at the next ``response_seq``)."""
700
+ return ConnectionSpec(
701
+ ConnectorType.X12,
702
+ {
703
+ "host": host,
704
+ "port": port,
705
+ "encoding": encoding,
706
+ "max_connections": max_connections,
707
+ "receive_timeout": receive_timeout,
708
+ "max_interchange_bytes": max_interchange_bytes,
709
+ "connect_timeout": connect_timeout,
710
+ "timeout_seconds": timeout_seconds,
711
+ "expect_reply": expect_reply,
712
+ "capture_response": capture_response,
713
+ "reingress_to": reingress_to,
714
+ "ta1_required": ta1_required,
715
+ },
716
+ )
717
+
718
+
719
+ def File(
720
+ *,
721
+ directory: str | EnvRef,
722
+ filename: str | EnvRef = "{MSH-10}.hl7",
723
+ pattern: str = "*.hl7",
724
+ poll_seconds: float = 1.0,
725
+ encoding: str = "utf-8",
726
+ min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes)
727
+ after_read: str = "move", # inbound: "move" (to processed_subdir) | "delete"
728
+ sort: str = "name", # inbound: process order — "name" | "mtime"
729
+ recursive: bool = False, # inbound: also scan subdirectories
730
+ max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard)
731
+ overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision
732
+ processed_subdir: str = ".processed",
733
+ error_subdir: str = ".error",
734
+ ) -> ConnectionSpec:
735
+ """A File endpoint. Inbound polls ``directory`` for ``pattern``; outbound writes ``filename``
736
+ (atomically). ``encoding`` is the file charset (outbound). ``max_file_bytes`` mirrors
737
+ transports.file.DEFAULT_MAX_FILE_BYTES (pass None/0 to disable)."""
738
+ return ConnectionSpec(
739
+ ConnectorType.FILE,
740
+ {
741
+ "directory": directory,
742
+ "filename": filename,
743
+ "pattern": pattern,
744
+ "poll_seconds": poll_seconds,
745
+ "encoding": encoding,
746
+ "min_age_seconds": min_age_seconds,
747
+ "after_read": after_read,
748
+ "sort": sort,
749
+ "recursive": recursive,
750
+ "max_file_bytes": max_file_bytes,
751
+ "overwrite": overwrite,
752
+ "processed_subdir": processed_subdir,
753
+ "error_subdir": error_subdir,
754
+ },
755
+ )
756
+
757
+
758
+ def Timer(
759
+ *,
760
+ body: str,
761
+ interval_seconds: float | None = None,
762
+ run_once: bool = False,
763
+ encoding: str = "utf-8",
764
+ ) -> ConnectionSpec:
765
+ """A Timer **source** (inbound): emit ``body`` on a schedule (ADR 0011).
766
+
767
+ Set ``interval_seconds`` to fire every N seconds (heartbeat starts at t=0), or ``run_once=True`` to
768
+ fire a single time. ``body`` is emitted verbatim — declare its format with
769
+ ``inbound(..., content_type=...)``: the default ``hl7v2`` runs the HL7 peek/validate/ACK path, while
770
+ ``text``/``json`` route a :class:`RawMessage` (ADR 0004). In a cluster the schedule is leader-gated,
771
+ so exactly one node fires it (single-node fires as normal). ``cron`` scheduling is a follow-up."""
772
+ return ConnectionSpec(
773
+ ConnectorType.TIMER,
774
+ {
775
+ "body": body,
776
+ "interval_seconds": interval_seconds,
777
+ "run_once": run_once,
778
+ "encoding": encoding,
779
+ },
780
+ )
781
+
782
+
783
+ def Loopback() -> ConnectionSpec:
784
+ """A Loopback **inbound** (ADR 0013 Increment 2): an inert inbound with **no source**. Messages
785
+ arrive *only* via the engine-internal ``ingress_handoff`` — a captured reply re-ingressed as a new
786
+ inbound message (a capturing outbound names this inbound with ``reingress_to=...``).
787
+
788
+ It is an ordinary ``inbound(...)`` otherwise: declare its ``router`` (which routes the answer) and
789
+ ``content_type`` (``hl7v2`` → :class:`Message`; ``x12``/``text``/``json`` → :class:`RawMessage`). It
790
+ takes **no** ``ack_mode`` (no external peer to ACK — forced to ``NONE``), no ``bind_address``/
791
+ ``source_ip_allowlist`` (no socket), and no ``strict`` validation (no untrusted intake)."""
792
+ return ConnectionSpec(ConnectorType.LOOPBACK, {})
793
+
794
+
795
+ def Rest(
796
+ *,
797
+ url: str | EnvRef, # the endpoint; may be env() for DEV/PROD-specific hosts
798
+ method: str = "POST",
799
+ content_type: str = "application/json",
800
+ headers: dict[str, str] | None = None, # static extra headers (no secrets — not env()-resolved)
801
+ bearer_token: str | EnvRef | None = None, # Authorization: Bearer … (use env() for the secret)
802
+ basic_user: str
803
+ | EnvRef
804
+ | None = None, # HTTP Basic (with basic_password); use env() for secrets
805
+ basic_password: str | EnvRef | None = None,
806
+ timeout_seconds: float = 30.0,
807
+ verify_tls: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
808
+ encoding: str = "utf-8",
809
+ capture_response: bool = False, # capture the HTTP response body as a reply (ADR 0013)
810
+ reingress_to: str
811
+ | None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
812
+ ) -> ConnectionSpec:
813
+ """An HTTP(S) endpoint (**outbound only** today — there is no REST source yet, ADR 0003). The
814
+ Handler produces the request body; this delivers it to ``url`` via ``method`` with ``content_type``
815
+ + ``headers`` and optional bearer/basic auth. A 2xx is delivered; 5xx/408/429/connection errors
816
+ retry; other 4xx dead-letter (a permanent rejection). Redirects are refused and the egress host is
817
+ gated by ``[egress].allowed_http``. Put secrets in ``env()`` (``bearer_token``/``basic_*``), never
818
+ in ``headers``. The receiving endpoint **must be idempotent** (delivery is at-least-once)."""
819
+ return ConnectionSpec(
820
+ ConnectorType.REST,
821
+ {
822
+ "url": url,
823
+ "method": method,
824
+ "content_type": content_type,
825
+ "headers": headers or {},
826
+ "bearer_token": bearer_token,
827
+ "basic_user": basic_user,
828
+ "basic_password": basic_password,
829
+ "timeout_seconds": timeout_seconds,
830
+ "verify_tls": verify_tls,
831
+ "encoding": encoding,
832
+ "capture_response": capture_response,
833
+ "reingress_to": reingress_to,
834
+ },
835
+ )
836
+
837
+
838
+ def Database(
839
+ *,
840
+ server: str | EnvRef, # SQL Server host (may be env())
841
+ database: str | EnvRef,
842
+ statement: str, # parameterized SQL / proc call with :name placeholders
843
+ auth: str = "sql", # sql | integrated | entra
844
+ username: str | EnvRef | None = None,
845
+ password: str | EnvRef | None = None, # secret — use env()
846
+ port: int | EnvRef = 1433,
847
+ encrypt: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
848
+ trust_server_certificate: bool = False,
849
+ connect_timeout: int = 15,
850
+ app_name: str = "messagefoundry",
851
+ odbc_driver: str = "ODBC Driver 18 for SQL Server",
852
+ pool_max: int = 5,
853
+ acquire_timeout: float = 30.0, # cap a pooled-connection borrow (s) — fail transiently, not forever
854
+ capture_response: bool = False, # capture the statement's RETURNING/OUTPUT result-set (ADR 0013)
855
+ reingress_to: str
856
+ | None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
857
+ capture_max_rows: int = 100, # cap captured rows (over-cap → outcome='unparseable', empty body)
858
+ ) -> ConnectionSpec:
859
+ """A SQL database endpoint (**outbound only** today; SQL Server via the ``[sqlserver]`` extra + ODBC
860
+ Driver 18 — **production / supported**). The Handler produces a JSON-object body; the connector binds its keys
861
+ to the ``:name`` parameters in ``statement`` (translated to positional ``?`` — always parameterized,
862
+ never string-built) and runs it. A transient DB error retries; a constraint/data error (or a payload
863
+ that doesn't match) dead-letters. Put secrets (``password``) in ``env()``. TLS is on by default;
864
+ weakening it needs ``MEFOR_ALLOW_INSECURE_TLS``. The write **must be idempotent** (at-least-once)."""
865
+ return ConnectionSpec(
866
+ ConnectorType.DATABASE,
867
+ {
868
+ "server": server,
869
+ "database": database,
870
+ "statement": statement,
871
+ "auth": auth,
872
+ "username": username,
873
+ "password": password,
874
+ "port": port,
875
+ "encrypt": encrypt,
876
+ "trust_server_certificate": trust_server_certificate,
877
+ "connect_timeout": connect_timeout,
878
+ "app_name": app_name,
879
+ "odbc_driver": odbc_driver,
880
+ "pool_max": pool_max,
881
+ "acquire_timeout": acquire_timeout,
882
+ "capture_response": capture_response,
883
+ "reingress_to": reingress_to,
884
+ "capture_max_rows": capture_max_rows,
885
+ },
886
+ )
887
+
888
+
889
+ def DatabasePoll(
890
+ *,
891
+ server: str | EnvRef, # SQL Server host (may be env())
892
+ database: str | EnvRef,
893
+ poll_statement: str, # SELECT of the next batch (e.g. WHERE status='NEW' ORDER BY id)
894
+ mark_statement: str
895
+ | None = None, # UPDATE/DELETE run per row after the handler succeeds (:name)
896
+ body_column: str | None = None, # None → whole row as JSON; set → that column's value verbatim
897
+ poll_seconds: float = 5.0,
898
+ auth: str = "sql", # sql | integrated | entra
899
+ username: str | EnvRef | None = None,
900
+ password: str | EnvRef | None = None, # secret — use env()
901
+ port: int | EnvRef = 1433,
902
+ encrypt: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
903
+ trust_server_certificate: bool = False,
904
+ connect_timeout: int = 15,
905
+ app_name: str = "messagefoundry",
906
+ odbc_driver: str = "ODBC Driver 18 for SQL Server",
907
+ pool_max: int = 5,
908
+ acquire_timeout: float = 30.0, # cap a pooled-connection borrow (s) — fail transiently, not forever
909
+ encoding: str = "utf-8",
910
+ ) -> ConnectionSpec:
911
+ """A SQL database polling **source** (inbound, ADR 0003 §3; SQL Server via the ``[sqlserver]`` extra +
912
+ ODBC Driver 18 — **production / supported**). Every ``poll_seconds`` it runs ``poll_statement`` (a ``SELECT``),
913
+ hands each row to the bound router as a body, then runs ``mark_statement`` (bound from the row's
914
+ columns) so the row isn't re-read — the File source's *process-then-mark-done* shape. At-least-once:
915
+ a crash before the mark re-emits the row, so the downstream pipeline **must tolerate duplicates**.
916
+
917
+ Lead pattern is a status column: ``poll_statement='SELECT id, payload FROM mf_inbox WHERE status=\\'NEW\\''``
918
+ + ``mark_statement='UPDATE mf_inbox SET status=\\'DONE\\' WHERE id=:id'`` (a ``DELETE`` or a
919
+ high-water-mark ``UPDATE`` work through the same ``mark_statement``). ``body_column`` unset → the
920
+ whole row as a JSON object (pair with ``content_type=json``); set → that one column's value verbatim
921
+ (e.g. a column holding an HL7 message → ``content_type=hl7v2``). Put secrets (``password``) in
922
+ ``env()``; TLS is on by default (weakening needs ``MEFOR_ALLOW_INSECURE_TLS``); the polled ``server``
923
+ is gated by ``[egress].allowed_db``."""
924
+ return ConnectionSpec(
925
+ ConnectorType.DATABASE,
926
+ {
927
+ "server": server,
928
+ "database": database,
929
+ "poll_statement": poll_statement,
930
+ "mark_statement": mark_statement,
931
+ "body_column": body_column,
932
+ "poll_seconds": poll_seconds,
933
+ "auth": auth,
934
+ "username": username,
935
+ "password": password,
936
+ "port": port,
937
+ "encrypt": encrypt,
938
+ "trust_server_certificate": trust_server_certificate,
939
+ "connect_timeout": connect_timeout,
940
+ "app_name": app_name,
941
+ "odbc_driver": odbc_driver,
942
+ "pool_max": pool_max,
943
+ "acquire_timeout": acquire_timeout,
944
+ "encoding": encoding,
945
+ },
946
+ )
947
+
948
+
949
+ def Soap(
950
+ *,
951
+ url: str | EnvRef, # the SOAP endpoint (may be env())
952
+ soap_action: str | EnvRef | None = None, # SOAPAction (1.1 header / 1.2 content-type param)
953
+ soap_version: str = "1.1", # "1.1" | "1.2"
954
+ headers: dict[str, str] | None = None, # static extra headers (no secrets — not env()-resolved)
955
+ bearer_token: str | EnvRef | None = None, # Authorization: Bearer … (use env() for the secret)
956
+ basic_user: str | EnvRef | None = None,
957
+ basic_password: str | EnvRef | None = None,
958
+ timeout_seconds: float = 30.0,
959
+ verify_tls: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
960
+ encoding: str = "utf-8",
961
+ capture_response: bool = False, # capture the SOAP response envelope as a reply (ADR 0013)
962
+ reingress_to: str
963
+ | None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
964
+ # --- ADR 0015: mutual TLS + WS-* (Timestamp / UsernameToken / WS-Addressing) ---
965
+ client_cert_file: str
966
+ | EnvRef
967
+ | None = None, # PEM client cert (mTLS); requires client_key_file
968
+ client_key_file: str | EnvRef | None = None, # PEM private key (path or env() text)
969
+ client_key_password: str | EnvRef | None = None, # key passphrase — secret, use env()
970
+ ws_security: bool = False, # stamp <wsse:Security> (Timestamp + optional UsernameToken) in send()
971
+ ws_username: str | EnvRef | None = None, # UsernameToken username (defaults to basic_user)
972
+ ws_password: str | EnvRef | None = None, # UsernameToken password (defaults to basic_password)
973
+ ws_password_type: str = "text", # "text" (PasswordText; recommended over mTLS) | "digest"
974
+ ws_addressing: bool = False, # stamp <wsa:Action/To/MessageID> in send(); requires soap_version 1.2
975
+ ws_timestamp_ttl_seconds: int = 300, # Created→Expires window (must be >= max retry backoff)
976
+ ) -> ConnectionSpec:
977
+ """A SOAP web-service endpoint (**outbound only**, ADR 0003 + 0015).
978
+
979
+ *Plain mode* (default): the Handler produces the **full SOAP envelope** and this POSTs it to ``url``
980
+ with the SOAP ``Content-Type`` (+ a ``SOAPAction`` header for 1.1). *WS-\\* mode* (``ws_addressing``
981
+ / ``ws_security``, ADR 0015): the Handler produces only the operation **``<Body>`` fragment** and
982
+ the transport wraps it + stamps the non-deterministic ``<wsa:MessageID>`` / ``<wsu:Timestamp>`` /
983
+ optional ``<wsse:UsernameToken>`` headers in ``send()`` (so a pure transform never mints them);
984
+ WS-\\* requires ``soap_version="1.2"``. ``client_cert_file``/``client_key_file`` enable **mutual
985
+ TLS** (incompatible with ``verify_tls=False``).
986
+
987
+ A WS-Security auth/expiry fault, a **Sender/Client** fault, or an unrecognized fault dead-letters;
988
+ a **Receiver/Server** fault retries; otherwise the HTTP status decides. Put secrets in ``env()``
989
+ (``bearer_token``/``basic_*``/``client_key_password``/``ws_password``); the host is gated by
990
+ ``[egress].allowed_http`` (shared with REST — **populate it for a PHI mTLS destination**). The
991
+ operation **must be idempotent**: an at-least-once re-send mints a fresh ``<wsa:MessageID>`` (correct
992
+ WS-\\* retry semantics), so the partner's dedup must treat a re-send as a retry, not a duplicate."""
993
+ return ConnectionSpec(
994
+ ConnectorType.SOAP,
995
+ {
996
+ "url": url,
997
+ "soap_action": soap_action,
998
+ "soap_version": soap_version,
999
+ "headers": headers or {},
1000
+ "bearer_token": bearer_token,
1001
+ "basic_user": basic_user,
1002
+ "basic_password": basic_password,
1003
+ "timeout_seconds": timeout_seconds,
1004
+ "verify_tls": verify_tls,
1005
+ "encoding": encoding,
1006
+ "capture_response": capture_response,
1007
+ "reingress_to": reingress_to,
1008
+ "client_cert_file": client_cert_file,
1009
+ "client_key_file": client_key_file,
1010
+ "client_key_password": client_key_password,
1011
+ "ws_security": ws_security,
1012
+ "ws_username": ws_username,
1013
+ "ws_password": ws_password,
1014
+ "ws_password_type": ws_password_type,
1015
+ "ws_addressing": ws_addressing,
1016
+ "ws_timestamp_ttl_seconds": ws_timestamp_ttl_seconds,
1017
+ },
1018
+ )
1019
+
1020
+
1021
+ def Sftp(
1022
+ *,
1023
+ host: str | EnvRef, # the SFTP/SSH server (may be env())
1024
+ port: int | EnvRef = 22,
1025
+ username: str | EnvRef | None = None,
1026
+ password: str | EnvRef | None = None, # secret — use env()
1027
+ private_key: str | EnvRef | None = None, # PEM private key text/path — secret, use env()
1028
+ key_password: str | EnvRef | None = None, # passphrase for an encrypted key — secret, use env()
1029
+ known_hosts: str | EnvRef | None = None, # extra known_hosts file (system hosts always loaded)
1030
+ remote_dir: str | EnvRef,
1031
+ filename: str | EnvRef = "{MSH-10}.hl7", # outbound: upload name (may template HL7 fields)
1032
+ pattern: str = "*.hl7", # inbound: glob of files to poll
1033
+ poll_seconds: float = 5.0, # inbound: poll interval
1034
+ after_read: str = "move", # inbound: "move" (to processed_subdir) | "delete"
1035
+ min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes)
1036
+ max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard)
1037
+ overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision
1038
+ processed_subdir: str = ".processed",
1039
+ error_subdir: str = ".error",
1040
+ encoding: str = "utf-8",
1041
+ ) -> ConnectionSpec:
1042
+ """An **SFTP** (SSH file transfer) endpoint — source **and** destination (ADR 0003 follow-on).
1043
+
1044
+ Inbound polls ``remote_dir`` for ``pattern`` (process-then-move/delete, at-least-once); outbound
1045
+ uploads to ``remote_dir``/``filename`` (write to a temp name then rename, so a poller never sees a
1046
+ partial). Needs the ``[sftp]`` extra (``pip install 'messagefoundry[sftp]'``; paramiko is lazily
1047
+ imported). **Host-key verification is ON by default** (system + ``known_hosts``; an unknown key is
1048
+ refused) — accepting an unknown key needs ``MEFOR_ALLOW_INSECURE_TLS``. Put secrets (``password``/
1049
+ ``private_key``/``key_password``) in ``env()``. The host is gated by ``[egress].allowed_remote``
1050
+ (both directions). At-least-once: an upload may re-send and a poll may re-emit, so downstreams
1051
+ **must be idempotent**."""
1052
+ return ConnectionSpec(
1053
+ ConnectorType.REMOTEFILE,
1054
+ {
1055
+ "protocol": "sftp",
1056
+ "host": host,
1057
+ "port": port,
1058
+ "username": username,
1059
+ "password": password,
1060
+ "private_key": private_key,
1061
+ "key_password": key_password,
1062
+ "known_hosts": known_hosts,
1063
+ "remote_dir": remote_dir,
1064
+ "filename": filename,
1065
+ "pattern": pattern,
1066
+ "poll_seconds": poll_seconds,
1067
+ "after_read": after_read,
1068
+ "min_age_seconds": min_age_seconds,
1069
+ "max_file_bytes": max_file_bytes,
1070
+ "overwrite": overwrite,
1071
+ "processed_subdir": processed_subdir,
1072
+ "error_subdir": error_subdir,
1073
+ "encoding": encoding,
1074
+ },
1075
+ )
1076
+
1077
+
1078
+ def Ftp(
1079
+ *,
1080
+ host: str | EnvRef, # the FTP server (may be env())
1081
+ port: int | EnvRef = 21,
1082
+ tls: bool = False, # True → FTPS (explicit TLS, PROT P); False → plain ftp
1083
+ username: str | EnvRef | None = None,
1084
+ password: str | EnvRef | None = None, # secret — use env()
1085
+ remote_dir: str | EnvRef,
1086
+ filename: str | EnvRef = "{MSH-10}.hl7", # outbound: upload name (may template HL7 fields)
1087
+ pattern: str = "*.hl7", # inbound: glob of files to poll
1088
+ poll_seconds: float = 5.0, # inbound: poll interval
1089
+ after_read: str = "move", # inbound: "move" (to processed_subdir) | "delete"
1090
+ min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes)
1091
+ max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard)
1092
+ overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision
1093
+ processed_subdir: str = ".processed",
1094
+ error_subdir: str = ".error",
1095
+ encoding: str = "utf-8",
1096
+ ) -> ConnectionSpec:
1097
+ """An **FTP** (``tls=False``) or **FTPS** (``tls=True`` — explicit TLS) endpoint, source **and**
1098
+ destination (stdlib ``ftplib`` — no extra). Same poll/upload shape as :func:`Sftp`.
1099
+
1100
+ Plain ``ftp`` transmits credentials in **cleartext**: supplying a ``username``/``password`` over
1101
+ plain ``ftp`` is **refused** unless ``MEFOR_ALLOW_INSECURE_TLS`` is set (use ``tls=True`` for FTPS,
1102
+ or :func:`Sftp`). FTPS encrypts the control + data channels, so credentials are fine there. Put
1103
+ secrets (``password``) in ``env()``. The host is gated by ``[egress].allowed_remote`` (both
1104
+ directions). At-least-once → downstreams **must be idempotent**."""
1105
+ return ConnectionSpec(
1106
+ ConnectorType.REMOTEFILE,
1107
+ {
1108
+ "protocol": "ftps" if tls else "ftp",
1109
+ "host": host,
1110
+ "port": port,
1111
+ "username": username,
1112
+ "password": password,
1113
+ "remote_dir": remote_dir,
1114
+ "filename": filename,
1115
+ "pattern": pattern,
1116
+ "poll_seconds": poll_seconds,
1117
+ "after_read": after_read,
1118
+ "min_age_seconds": min_age_seconds,
1119
+ "max_file_bytes": max_file_bytes,
1120
+ "overwrite": overwrite,
1121
+ "processed_subdir": processed_subdir,
1122
+ "error_subdir": error_subdir,
1123
+ "encoding": encoding,
1124
+ },
1125
+ )
1126
+
1127
+
1128
+ @dataclass(frozen=True)
1129
+ class Send:
1130
+ """A Handler's instruction to deliver ``message`` to a named outbound connection."""
1131
+
1132
+ to: str
1133
+ message: Message | RawMessage | str
1134
+
1135
+
1136
+ #: JSON-serializable scalar/container types a :class:`SetState` value may carry. Validated at
1137
+ #: construction (fail loud in the author's code, not deep in a store INSERT), and what
1138
+ #: :func:`messagefoundry.config.state.state_get` returns on a hit.
1139
+ StateValue = str | int | float | bool | None | list[Any] | dict[str, Any]
1140
+
1141
+
1142
+ @dataclass(frozen=True)
1143
+ class SetState:
1144
+ """A Handler's instruction to **declare** a state write (cross-message correlation, ADR 0005).
1145
+
1146
+ A Handler does not mutate state imperatively; it returns ``SetState(namespace, key, value)``
1147
+ alongside its :class:`Send`\\ s, and the engine applies the upsert **inside the routed→outbound
1148
+ handoff transaction** — so a crash before commit leaves no state and a re-run applies it exactly
1149
+ once (preserving the staged pipeline's pure-re-run invariant). ``value`` must be JSON-serializable
1150
+ (validated here); read it back synchronously with
1151
+ :func:`messagefoundry.config.state.state_get`."""
1152
+
1153
+ namespace: str
1154
+ key: str
1155
+ value: StateValue
1156
+
1157
+ def __post_init__(self) -> None:
1158
+ # Validate at construction so a non-serializable value fails in the author's handler (with a
1159
+ # clear message) rather than deep inside the store's INSERT during a handoff. namespace/key
1160
+ # are the composite PK and must be non-empty strings.
1161
+ if not isinstance(self.namespace, str) or not self.namespace:
1162
+ raise WiringError("SetState namespace must be a non-empty string")
1163
+ if not isinstance(self.key, str) or not self.key:
1164
+ raise WiringError("SetState key must be a non-empty string")
1165
+ try:
1166
+ import json
1167
+
1168
+ json.dumps(self.value)
1169
+ except (TypeError, ValueError) as exc:
1170
+ raise WiringError(
1171
+ f"SetState({self.namespace!r}, {self.key!r}, ...): value must be JSON-serializable "
1172
+ f"(str/int/float/bool/None/list/dict) — {exc}"
1173
+ ) from exc
1174
+
1175
+
1176
+ #: What a Router/Handler receives: a mutable HL7 :class:`Message`, or a :class:`RawMessage` for a
1177
+ #: non-HL7 inbound (ADR 0004). The author knows which — a Router/Handler is bound to one inbound.
1178
+ Payload = Message | RawMessage
1179
+ RouterFn = Callable[[Payload], "list[str] | str | None"]
1180
+ #: A Handler returns deliveries and/or state writes (ADR 0005): a single :class:`Send`/:class:`SetState`,
1181
+ #: a mixed list, or ``None`` (filtered). ``Send``-only returns are unchanged — backward compatible.
1182
+ HandlerFn = Callable[[Payload], "Send | SetState | list[Send | SetState] | None"]
1183
+
1184
+
1185
+ @dataclass(frozen=True)
1186
+ class InboundConnection:
1187
+ name: str
1188
+ spec: ConnectionSpec
1189
+ router: str
1190
+ ack_mode: AckMode = AckMode.ORIGINAL
1191
+ # None = inherit the global [inbound] ack_after default; an explicit value overrides it. Resolved
1192
+ # in the RegistryRunner, which rejects 'delivered' until that path is implemented (ADR 0001).
1193
+ ack_after: AckAfter | None = None
1194
+ validation: Validation = field(default_factory=Validation)
1195
+ content_type: ContentType = ContentType.HL7V2 # payload format (ADR 0004); HL7V2 = the HL7 path
1196
+ # Operability (Tier 4): free-form operator metadata (owner/runbook/env labels — surfaced by the
1197
+ # API, never used for routing); a per-connection inbound bind interface that overrides the service
1198
+ # [inbound].bind_host; and an inbound peer-IP allowlist (MLLP/TCP listen sources only). All
1199
+ # default to None/absent = unchanged behaviour.
1200
+ metadata: Mapping[str, Any] | None = None
1201
+ bind_address: str | None = None
1202
+ source_ip_allowlist: tuple[str, ...] | None = None
1203
+ source_file: str | None = None # where it was declared (for IDE go-to-definition)
1204
+ source_line: int | None = None
1205
+
1206
+
1207
+ @dataclass(frozen=True)
1208
+ class OutboundConnection:
1209
+ name: str
1210
+ spec: ConnectionSpec
1211
+ # None = inherit the global [delivery] default; an explicit value overrides it. Resolution
1212
+ # (per-connection override > [delivery] global default > built-in) happens in the RegistryRunner.
1213
+ retry: RetryPolicy | None = None
1214
+ ordering: OrderingMode | None = None
1215
+ internal_error: InternalErrorPolicy | None = None
1216
+ buildup: BuildupThreshold | None = None
1217
+ # Shadow / parallel-run egress suppression (#15). False = deliver normally; True = the delivery
1218
+ # worker suppresses the real egress + finalizes PROCESSED. [shadow].simulate_all_egress forces it on.
1219
+ simulate: bool = False
1220
+ metadata: Mapping[str, Any] | None = (
1221
+ None # operability labels (Tier 4); API-surfaced, not routing
1222
+ )
1223
+ source_file: str | None = None
1224
+ source_line: int | None = None
1225
+
1226
+
1227
+ @dataclass
1228
+ class Registry:
1229
+ """The wired graph produced by loading config modules."""
1230
+
1231
+ inbound: dict[str, InboundConnection] = field(default_factory=dict)
1232
+ outbound: dict[str, OutboundConnection] = field(default_factory=dict)
1233
+ routers: dict[str, RouterFn] = field(default_factory=dict)
1234
+ handlers: dict[str, HandlerFn] = field(default_factory=dict)
1235
+ # Reference lookup tables loaded from <config_dir>/codesets/ — attached so a runner can re-publish
1236
+ # this graph's code sets as the active set while its routers/handlers run (call-time resolution).
1237
+ code_sets: dict[str, CodeSet] = field(default_factory=dict)
1238
+ # Reference-set declarations (ADR 0006): name -> source + cadence. The engine's ReferenceSyncRunner
1239
+ # materializes each into a store snapshot; reference(name) reads the snapshot (data lives in the
1240
+ # store, not here). Carried with the graph so a reload re-arms the sync set atomically.
1241
+ references: dict[str, ReferenceSpec] = field(default_factory=dict)
1242
+ # Live-lookup connection declarations (ADR 0010): name -> connection settings. The RegistryRunner
1243
+ # builds one pooled executor from these; db_lookup(name, ...) queries it at handler run time. Carried
1244
+ # with the graph so a reload re-arms the executor atomically.
1245
+ lookups: dict[str, DatabaseLookupSpec] = field(default_factory=dict)
1246
+
1247
+ def add_inbound(self, conn: InboundConnection) -> None:
1248
+ self._add(self.inbound, conn.name, conn, "inbound connection")
1249
+
1250
+ def add_outbound(self, conn: OutboundConnection) -> None:
1251
+ self._add(self.outbound, conn.name, conn, "outbound connection")
1252
+
1253
+ def add_router(self, name: str, fn: RouterFn) -> None:
1254
+ self._add(self.routers, name, fn, "router")
1255
+
1256
+ def add_handler(self, name: str, fn: HandlerFn) -> None:
1257
+ self._add(self.handlers, name, fn, "handler")
1258
+
1259
+ def add_reference(self, spec: ReferenceSpec) -> None:
1260
+ self._add(self.references, spec.name, spec, "reference set")
1261
+
1262
+ def add_lookup(self, spec: DatabaseLookupSpec) -> None:
1263
+ self._add(self.lookups, spec.name, spec, "database lookup")
1264
+
1265
+ @staticmethod
1266
+ def _add(table: dict[str, Any], name: str, value: Any, kind: str) -> None:
1267
+ if name in table:
1268
+ raise WiringError(f"duplicate {kind} name: {name!r}")
1269
+ table[name] = value
1270
+
1271
+ def validate(self) -> None:
1272
+ """Statically check references (inbound → router) and literal inbound port collisions."""
1273
+ for conn in self.inbound.values():
1274
+ if conn.router not in self.routers:
1275
+ raise WiringError(
1276
+ f"inbound connection {conn.name!r} references unknown router {conn.router!r}"
1277
+ )
1278
+ collisions = self.port_collisions()
1279
+ if collisions:
1280
+ port, first, second = collisions[0]
1281
+ raise WiringError(f"inbound connections {first!r} and {second!r} both bind port {port}")
1282
+
1283
+ def port_collisions(self) -> list[tuple[int, str, str]]:
1284
+ """Inbound connections sharing a literal bind port, as ``(port, first, colliding)`` tuples.
1285
+
1286
+ Caught statically so a duplicate port surfaces at validate/``check`` time naming both
1287
+ connections, instead of aborting the whole engine with a bare bind ``OSError`` (review
1288
+ low-13). ``EnvRef`` ports resolve per-environment, so only ``int`` literals are checkable."""
1289
+ seen: dict[int, str] = {}
1290
+ out: list[tuple[int, str, str]] = []
1291
+ for conn in self.inbound.values():
1292
+ port = conn.spec.settings.get("port")
1293
+ if isinstance(port, int) and not isinstance(port, bool):
1294
+ if port in seen:
1295
+ out.append((port, seen[port], conn.name))
1296
+ else:
1297
+ seen[port] = conn.name
1298
+ return out
1299
+
1300
+
1301
+ # --- declaration API (writes to the registry being loaded) -------------------
1302
+
1303
+ _active: Registry | None = None
1304
+
1305
+
1306
+ def _active_registry() -> Registry:
1307
+ if _active is None:
1308
+ raise WiringError(
1309
+ "inbound/outbound/router/handler must be declared in a config module loaded "
1310
+ "via load_config()"
1311
+ )
1312
+ return _active
1313
+
1314
+
1315
+ def _call_site() -> tuple[str | None, int | None]:
1316
+ """File + line of the config module that called the declaration (for IDE go-to-definition)."""
1317
+ caller = sys._getframe(2) # _call_site -> inbound/outbound -> config module
1318
+ return caller.f_code.co_filename, caller.f_lineno
1319
+
1320
+
1321
+ def _check_metadata(name: str, metadata: Mapping[str, Any] | None) -> None:
1322
+ """Operability metadata must be a key/value table (or absent) — operator labels, not config."""
1323
+ if metadata is not None and not isinstance(metadata, Mapping):
1324
+ raise WiringError(f"connection {name!r}: metadata must be a table (key/value mapping)")
1325
+
1326
+
1327
+ def _check_source_ip_allowlist(
1328
+ name: str, listens: bool, allowlist: list[str] | None
1329
+ ) -> tuple[str, ...] | None:
1330
+ """Validate an inbound peer-IP allowlist and freeze it to a tuple. Each entry must parse as an IP
1331
+ address or a CIDR network; the allowlist is only meaningful for an MLLP/TCP **listen** source.
1332
+ ``None``/empty = no restriction (the ``[egress]`` allowlist convention)."""
1333
+ if not allowlist:
1334
+ return None
1335
+ if not listens:
1336
+ raise WiringError(
1337
+ f"inbound connection {name!r}: source_ip_allowlist is only valid for an MLLP/TCP "
1338
+ "listen source"
1339
+ )
1340
+ for entry in allowlist:
1341
+ if not isinstance(entry, str) or not entry.strip():
1342
+ raise WiringError(
1343
+ f"inbound connection {name!r}: source_ip_allowlist entries must be non-empty strings"
1344
+ )
1345
+ try:
1346
+ if "/" in entry:
1347
+ ipaddress.ip_network(entry, strict=False)
1348
+ else:
1349
+ ipaddress.ip_address(entry)
1350
+ except ValueError as exc:
1351
+ raise WiringError(
1352
+ f"inbound connection {name!r}: source_ip_allowlist entry {entry!r} is not a valid "
1353
+ f"IP address or CIDR network ({exc})"
1354
+ ) from exc
1355
+ return tuple(allowlist)
1356
+
1357
+
1358
+ def _coerce_content_type(name: str, content_type: ContentType | str) -> ContentType:
1359
+ """Coerce a ``content_type`` argument to the :class:`ContentType` enum (or fail loud).
1360
+
1361
+ A code-first author may pass the bare string (``content_type="x12"``) rather than the enum member;
1362
+ coerce it here, at the one shared inbound boundary, so a raw string can't flow into the pipeline and
1363
+ blow up later as ``'str' object has no attribute 'value'`` deep in dry-run. An unrecognized value
1364
+ fails loud as a :class:`WiringError` naming the connection and the allowed values — the same loud
1365
+ failure the ``connections.toml`` loader already gives. A member passed in is returned unchanged."""
1366
+ if isinstance(content_type, ContentType):
1367
+ return content_type
1368
+ try:
1369
+ return ContentType(content_type)
1370
+ except ValueError as exc:
1371
+ allowed = ", ".join(repr(member.value) for member in ContentType)
1372
+ raise WiringError(
1373
+ f"inbound connection {name!r}: invalid content_type {content_type!r} (allowed: {allowed})"
1374
+ ) from exc
1375
+
1376
+
1377
+ def build_inbound_connection(
1378
+ name: str,
1379
+ spec: ConnectionSpec,
1380
+ *,
1381
+ router: str,
1382
+ ack_mode: AckMode = AckMode.ORIGINAL,
1383
+ ack_after: AckAfter | None = None,
1384
+ strict: bool = False,
1385
+ hl7_version: str | None = None,
1386
+ content_type: ContentType | str = ContentType.HL7V2,
1387
+ metadata: Mapping[str, Any] | None = None,
1388
+ bind_address: str | None = None,
1389
+ source_ip_allowlist: list[str] | None = None,
1390
+ source_file: str | None = None,
1391
+ source_line: int | None = None,
1392
+ ) -> InboundConnection:
1393
+ """Validate the inbound-connection invariants and build an :class:`InboundConnection`.
1394
+
1395
+ The shared core of code-first :func:`inbound` **and** the ``connections.toml`` loader (ADR 0007),
1396
+ so both authoring surfaces enforce identical guards. Pure — it does not touch the active registry;
1397
+ the caller is responsible for ``add_inbound``. ``content_type`` accepts a :class:`ContentType`
1398
+ member **or** its bare string value (``"x12"``, ``"json"``, …); it is coerced to the enum here so a
1399
+ raw string can't reach the pipeline and crash later."""
1400
+ content_type = _coerce_content_type(name, content_type)
1401
+ if (
1402
+ spec.type in (ConnectorType.MLLP, ConnectorType.TCP, ConnectorType.X12)
1403
+ and spec.settings.get("host") is not None
1404
+ ):
1405
+ # The bind interface is an environment/service decision (which NIC this instance exposes),
1406
+ # not a per-connection one — and exposing an unauthenticated raw listener on 0.0.0.0 must be
1407
+ # an admin choice, not a developer default. Set it service-side via [inbound].bind_host.
1408
+ kind = spec.type.value.upper()
1409
+ raise WiringError(
1410
+ f"inbound connection {name!r}: {kind} inbound takes no host; the bind interface is a "
1411
+ f"service setting ([inbound].bind_host). Declare it as {kind.title()}(port=...)."
1412
+ )
1413
+ if ack_after == AckAfter.DELIVERED:
1414
+ # Deferred-until-delivered ACK needs the listener to hold/replay the ACK from the delivery
1415
+ # worker (sender socket details, held connection) — not built in Step A. Fail loud at wiring
1416
+ # so a config asking for it is caught in dry-run / `messagefoundry check`, not silently
1417
+ # downgraded. (This also rules out the incoherent DELIVERED + ack_mode=NONE combination.)
1418
+ # Compared by VALUE not identity: AckAfter is a str-Enum, so a raw-string ack_after='delivered'
1419
+ # (== the member but not `is` it) must still be caught rather than slipping through as INGEST.
1420
+ raise WiringError(
1421
+ f"inbound connection {name!r}: ack_after='delivered' is not yet implemented "
1422
+ "(Step A ships ACK-on-receipt only — use ack_after='ingest', the default)"
1423
+ )
1424
+ if content_type is not ContentType.HL7V2 and strict:
1425
+ # Strict validation is hl7apy structure/cardinality validation — meaningless for a JSON/XML/text
1426
+ # body. Fail loud at wiring (caught in dry-run / `messagefoundry check`) rather than silently
1427
+ # ignoring it; non-HL7 payloads are validated in the Handler instead (ADR 0004).
1428
+ raise WiringError(
1429
+ f"inbound connection {name!r}: validation.strict is HL7-specific and can't apply to a "
1430
+ f"{content_type.value!r} content_type — validate non-HL7 payloads in the Handler instead"
1431
+ )
1432
+ if spec.type is ConnectorType.LOOPBACK:
1433
+ # A loopback inbound (ADR 0013) has no socket and no untrusted intake: strict HL7 validation is
1434
+ # meaningless, and there is no external peer to ACK. Messages arrive only via ingress_handoff.
1435
+ if strict:
1436
+ raise WiringError(
1437
+ f"inbound connection {name!r}: validation.strict is meaningless for a Loopback() "
1438
+ "inbound (no socket / no untrusted intake)"
1439
+ )
1440
+ if ack_mode in (AckMode.NONE, AckMode.ORIGINAL):
1441
+ ack_mode = AckMode.NONE # unset/default → NONE (no external peer to ACK)
1442
+ else:
1443
+ raise WiringError(
1444
+ f"inbound connection {name!r}: Loopback() takes no ACK (no external peer) — "
1445
+ "ack_mode must be NONE"
1446
+ )
1447
+ _check_metadata(name, metadata)
1448
+ listens = spec.type in (ConnectorType.MLLP, ConnectorType.TCP)
1449
+ if bind_address is not None:
1450
+ if not listens:
1451
+ # Only a listen source (MLLP/TCP) binds an interface; File/DB/etc. have nothing to bind.
1452
+ raise WiringError(
1453
+ f"inbound connection {name!r}: bind_address is only valid for an MLLP/TCP "
1454
+ "listen source"
1455
+ )
1456
+ if not bind_address.strip():
1457
+ # A present-but-blank bind_address would crash asyncio.start_server at boot (getaddrinfo
1458
+ # fails on whitespace) — fail loud at wiring so it's caught in dry-run / `messagefoundry
1459
+ # check`, like the allowlist. (Omit bind_address to inherit [inbound].bind_host.)
1460
+ raise WiringError(
1461
+ f"inbound connection {name!r}: bind_address must be a non-empty host/IP, not blank"
1462
+ )
1463
+ allowlist = _check_source_ip_allowlist(name, listens, source_ip_allowlist)
1464
+ return InboundConnection(
1465
+ name=name,
1466
+ spec=spec,
1467
+ router=router,
1468
+ ack_mode=ack_mode,
1469
+ ack_after=ack_after,
1470
+ validation=Validation(strict=strict, hl7_version=hl7_version),
1471
+ content_type=content_type,
1472
+ metadata=metadata,
1473
+ bind_address=bind_address,
1474
+ source_ip_allowlist=allowlist,
1475
+ source_file=source_file,
1476
+ source_line=source_line,
1477
+ )
1478
+
1479
+
1480
+ def inbound(
1481
+ name: str,
1482
+ spec: ConnectionSpec,
1483
+ *,
1484
+ router: str,
1485
+ ack_mode: AckMode = AckMode.ORIGINAL,
1486
+ ack_after: AckAfter | None = None,
1487
+ strict: bool = False,
1488
+ hl7_version: str | None = None,
1489
+ content_type: ContentType | str = ContentType.HL7V2,
1490
+ metadata: Mapping[str, Any] | None = None,
1491
+ bind_address: str | None = None,
1492
+ source_ip_allowlist: list[str] | None = None,
1493
+ ) -> None:
1494
+ """Declare an inbound connection that feeds every received message to ``router``.
1495
+
1496
+ ``ack_after`` selects ACK *timing* (staged pipeline, ADR 0001): the default ``INGEST``
1497
+ (ACK-on-receipt) is the only value supported in Step A — ``DELIVERED`` (defer the ACK until
1498
+ delivery) is not yet implemented and raises ``WiringError``. ``ack_after`` is distinct from
1499
+ ``ack_mode`` (the ACK code family).
1500
+
1501
+ ``content_type`` (ADR 0004) selects the payload format: the default ``HL7V2`` runs the HL7
1502
+ peek/validate/ACK path and the Router/Handler receive a :class:`Message`; any other value skips HL7
1503
+ parsing and they receive a :class:`RawMessage` (``.raw``/``.text``/``.json()``). It may be a
1504
+ :class:`ContentType` member **or** its bare string value (``content_type="x12"``), coerced at load —
1505
+ an unrecognized string fails loud as a :class:`WiringError`. ``strict`` validation is HL7-only, so it
1506
+ cannot combine with a non-HL7 ``content_type``.
1507
+
1508
+ Operability (Tier 4, all optional): ``metadata`` attaches free-form operator labels
1509
+ (owner/runbook/environment) surfaced by the API and never used for routing; ``bind_address``
1510
+ overrides the service ``[inbound].bind_host`` for this MLLP/TCP listener only; ``source_ip_allowlist``
1511
+ restricts an MLLP/TCP listener to the given peer IPs / CIDR networks (absent/empty = no restriction)."""
1512
+ file, line = _call_site()
1513
+ _active_registry().add_inbound(
1514
+ build_inbound_connection(
1515
+ name,
1516
+ spec,
1517
+ router=router,
1518
+ ack_mode=ack_mode,
1519
+ ack_after=ack_after,
1520
+ strict=strict,
1521
+ hl7_version=hl7_version,
1522
+ content_type=content_type,
1523
+ metadata=metadata,
1524
+ bind_address=bind_address,
1525
+ source_ip_allowlist=source_ip_allowlist,
1526
+ source_file=file,
1527
+ source_line=line,
1528
+ )
1529
+ )
1530
+
1531
+
1532
+ def build_outbound_connection(
1533
+ name: str,
1534
+ spec: ConnectionSpec,
1535
+ *,
1536
+ retry: RetryPolicy | None = None,
1537
+ ordering: OrderingMode | None = None,
1538
+ internal_error: InternalErrorPolicy | None = None,
1539
+ buildup: BuildupThreshold | None = None,
1540
+ simulate: bool = False,
1541
+ metadata: Mapping[str, Any] | None = None,
1542
+ source_file: str | None = None,
1543
+ source_line: int | None = None,
1544
+ ) -> OutboundConnection:
1545
+ """Validate the outbound-connection invariants and build an :class:`OutboundConnection`.
1546
+
1547
+ The shared core of code-first :func:`outbound` **and** the ``connections.toml`` loader (ADR 0007).
1548
+ Pure — it does not touch the active registry; the caller is responsible for ``add_outbound``."""
1549
+ if (
1550
+ spec.type in (ConnectorType.MLLP, ConnectorType.TCP, ConnectorType.X12)
1551
+ and spec.settings.get("host") is None
1552
+ ):
1553
+ # Outbound MLLP/TCP/X12 dials a downstream peer, so a host is mandatory. (It's the value that
1554
+ # legitimately differs per environment — see env() for DEV/PROD-specific peers.)
1555
+ kind = spec.type.value.upper()
1556
+ raise WiringError(
1557
+ f"outbound connection {name!r}: {kind} outbound requires a host (the downstream peer), "
1558
+ f"e.g. {kind.title()}(host=..., port=...)."
1559
+ )
1560
+ _check_metadata(name, metadata)
1561
+ # ADR 0013 Increment 2: reingress_to (route this outbound's reply back as a new inbound message)
1562
+ # IMPLIES capture (the reply must be captured to re-ingress it). Force capture_response here so the
1563
+ # capture-validity guards below also gate a re-ingress declaration; the cross-registry check that
1564
+ # reingress_to names an existing Loopback() inbound runs in build_check_registry (it sees the whole
1565
+ # registry). A re-ingress on FILE/REMOTEFILE therefore fails with the "no synchronous response" error.
1566
+ reingress_to = spec.settings.get("reingress_to")
1567
+ if reingress_to is not None:
1568
+ if not isinstance(reingress_to, str) or not reingress_to.strip():
1569
+ raise WiringError(
1570
+ f"outbound connection {name!r}: reingress_to must be a non-empty inbound name (ADR 0013)"
1571
+ )
1572
+ spec.settings["capture_response"] = True
1573
+ # ADR 0013: response capture must be wiring-valid at `check`/dry-run time (no store needed), and
1574
+ # this is the choke point for BOTH the code-first factories and the connections.toml desugar.
1575
+ if spec.settings.get("capture_response"):
1576
+ if spec.type in (ConnectorType.FILE, ConnectorType.REMOTEFILE):
1577
+ raise WiringError(
1578
+ f"outbound connection {name!r}: {spec.type.value.upper()} has no synchronous response, "
1579
+ "so capture_response=True is invalid (ADR 0013)."
1580
+ )
1581
+ if spec.type is ConnectorType.TCP and not spec.settings.get("expect_reply"):
1582
+ raise WiringError(
1583
+ f"outbound connection {name!r}: TCP capture_response=True requires expect_reply=True "
1584
+ "(there is no reply to capture otherwise) (ADR 0013)."
1585
+ )
1586
+ if spec.type is ConnectorType.X12 and not spec.settings.get("expect_reply"):
1587
+ raise WiringError(
1588
+ f"outbound connection {name!r}: X12 capture_response=True requires expect_reply=True "
1589
+ "(there is no returned interchange to capture otherwise) (ADR 0016)."
1590
+ )
1591
+ if spec.type is ConnectorType.DATABASE:
1592
+ stmt = str(spec.settings.get("statement") or "").lower()
1593
+ if "returning" not in stmt and "output" not in stmt:
1594
+ raise WiringError(
1595
+ f"outbound connection {name!r}: DATABASE capture_response=True requires a "
1596
+ "RETURNING/OUTPUT clause in the statement (it is fetched from the same cursor "
1597
+ "before commit), not a separate SELECT (ADR 0013)."
1598
+ )
1599
+ # ADR 0015: WS-* / mutual-TLS validity for SOAP, at `check`/dry-run time (no store). The url-scheme
1600
+ # checks (https required for a client cert, cleartext-credential refusal) need the resolved url and
1601
+ # run in SoapDestination.__init__; the structural ones below work on the unresolved spec (an EnvRef
1602
+ # is truthy, so presence/pairing checks hold even before env() resolution).
1603
+ if spec.type is ConnectorType.SOAP:
1604
+ cert = spec.settings.get("client_cert_file")
1605
+ key = spec.settings.get("client_key_file")
1606
+ if bool(cert) != bool(key):
1607
+ raise WiringError(
1608
+ f"outbound connection {name!r}: SOAP client_cert_file and client_key_file must be set "
1609
+ "together (a client cert needs its key) (ADR 0015)."
1610
+ )
1611
+ if cert and spec.settings.get("verify_tls") is False:
1612
+ raise WiringError(
1613
+ f"outbound connection {name!r}: SOAP client cert is incompatible with verify_tls=false "
1614
+ "(presenting an identity to an unverified peer is incoherent) (ADR 0015)."
1615
+ )
1616
+ pw_type = spec.settings.get("ws_password_type", "text")
1617
+ if pw_type not in ("text", "digest"):
1618
+ raise WiringError(
1619
+ f"outbound connection {name!r}: SOAP ws_password_type must be 'text' or 'digest', "
1620
+ f"got {pw_type!r} (ADR 0015)."
1621
+ )
1622
+ if (spec.settings.get("ws_security") or spec.settings.get("ws_addressing")) and str(
1623
+ spec.settings.get("soap_version", "1.1")
1624
+ ) != "1.2":
1625
+ raise WiringError(
1626
+ f"outbound connection {name!r}: SOAP ws_security/ws_addressing require "
1627
+ "soap_version='1.2' (WS-Addressing/WS-Security are coherent only on SOAP 1.2) (ADR 0015)."
1628
+ )
1629
+ return OutboundConnection(
1630
+ name=name,
1631
+ spec=spec,
1632
+ retry=retry,
1633
+ ordering=ordering,
1634
+ internal_error=internal_error,
1635
+ buildup=buildup,
1636
+ simulate=simulate,
1637
+ metadata=metadata,
1638
+ source_file=source_file,
1639
+ source_line=source_line,
1640
+ )
1641
+
1642
+
1643
+ def outbound(
1644
+ name: str,
1645
+ spec: ConnectionSpec,
1646
+ *,
1647
+ retry: RetryPolicy | None = None,
1648
+ ordering: OrderingMode | None = None,
1649
+ internal_error: InternalErrorPolicy | None = None,
1650
+ buildup: BuildupThreshold | None = None,
1651
+ simulate: bool = False,
1652
+ metadata: Mapping[str, Any] | None = None,
1653
+ ) -> None:
1654
+ """Declare an outbound connection that Handlers can ``Send`` to.
1655
+
1656
+ ``retry``/``ordering``/``internal_error``/``buildup`` override the global ``[delivery]`` defaults
1657
+ for this connection only (omit to inherit). ``ordering`` defaults to FIFO — strict in-order
1658
+ delivery per connection; ``internal_error`` defaults to continue (dead-letter a code-error row and
1659
+ advance); ``buildup`` sets the ``queue_buildup`` alert thresholds for this lane. ``simulate=True``
1660
+ runs the full pipeline but **suppresses the real egress** (shadow / parallel-run mode, #15) — no
1661
+ bytes leave the box and the message still finalizes PROCESSED. ``metadata`` attaches free-form
1662
+ operator labels (Tier 4) surfaced by the API, never used for delivery."""
1663
+ file, line = _call_site()
1664
+ _active_registry().add_outbound(
1665
+ build_outbound_connection(
1666
+ name,
1667
+ spec,
1668
+ retry=retry,
1669
+ ordering=ordering,
1670
+ internal_error=internal_error,
1671
+ buildup=buildup,
1672
+ simulate=simulate,
1673
+ metadata=metadata,
1674
+ source_file=file,
1675
+ source_line=line,
1676
+ )
1677
+ )
1678
+
1679
+
1680
+ def router(name: str) -> Callable[[RouterFn], RouterFn]:
1681
+ """Register a Router: ``def route(msg) -> list[str] | str | None`` (handler names; [] => unrouted)."""
1682
+
1683
+ def decorate(fn: RouterFn) -> RouterFn:
1684
+ _active_registry().add_router(name, fn)
1685
+ return fn
1686
+
1687
+ return decorate
1688
+
1689
+
1690
+ def handler(name: str) -> Callable[[HandlerFn], HandlerFn]:
1691
+ """Register a Handler: ``def handle(msg) -> Send | SetState | list[Send | SetState] | None``
1692
+ (``None`` => filtered; :class:`SetState` declares a state write applied exactly-once in the
1693
+ handoff, ADR 0005)."""
1694
+
1695
+ def decorate(fn: HandlerFn) -> HandlerFn:
1696
+ _active_registry().add_handler(name, fn)
1697
+ return fn
1698
+
1699
+ return decorate
1700
+
1701
+
1702
+ # --- loader ------------------------------------------------------------------
1703
+
1704
+
1705
+ class _SiblingHelperFinder:
1706
+ """Resolve a config module's top-level ``import _helpers`` to a sibling ``.py`` in the config dir.
1707
+
1708
+ The loader runs non-``_`` modules under mangled names and skips ``_``-prefixed files as top-level
1709
+ modules, but CLAUDE.md §4 documents importing shared ``_``-prefixed helpers from siblings. Those
1710
+ files aren't on ``sys.path``, so without a finder Python can't locate them and the import fails
1711
+ (review low-10). Installed on ``sys.meta_path`` only while a config dir loads, and resolves a name
1712
+ only when ``<name>.py`` exists in that dir. :func:`_assert_safe_config_source` already vets every
1713
+ ``*.py`` (including ``_*``), so a helper sits inside the same trust boundary as its importers."""
1714
+
1715
+ def __init__(self, directory: Path, created: set[str]) -> None:
1716
+ self._dir = directory
1717
+ self._created = created
1718
+
1719
+ def find_spec(self, fullname: str, path: Any, target: Any = None) -> Any:
1720
+ if path is not None or "." in fullname:
1721
+ return None # only top-level absolute imports, resolved against the config dir
1722
+ candidate = self._dir / f"{fullname}.py"
1723
+ if not candidate.is_file():
1724
+ return None
1725
+ self._created.add(fullname)
1726
+ return importlib.util.spec_from_file_location(fullname, candidate)
1727
+
1728
+
1729
+ # Serializes the shared module-global load state (_active, sys.meta_path/sys.modules mutations) so a
1730
+ # reload offloaded to a worker thread can't race a concurrent validate/load (review low-3).
1731
+ _load_lock = threading.Lock()
1732
+
1733
+
1734
+ @contextmanager
1735
+ def _loading(directory: Path, registry: Registry) -> Iterator[None]:
1736
+ """Hold the load lock, publish ``registry`` as the active declaration target **and its code sets
1737
+ as the active set** (so a module-top-level ``code_set(...)`` resolves), and install the
1738
+ sibling-helper import finder for ``directory`` — tearing all of it down (including any helper
1739
+ modules registered under their plain name) on exit."""
1740
+ global _active
1741
+ helpers: set[str] = set()
1742
+ finder = _SiblingHelperFinder(directory, helpers)
1743
+ with _load_lock:
1744
+ _active = registry
1745
+ sys.meta_path.insert(0, finder)
1746
+ # Code sets are published BEFORE the modules run so a top-level capture resolves; the registry
1747
+ # already holds them (loaded in load_config/validate_config), and activated() restores cleanly.
1748
+ try:
1749
+ with _code_sets_activated(registry.code_sets):
1750
+ yield
1751
+ finally:
1752
+ _active = None
1753
+ with suppress(ValueError):
1754
+ sys.meta_path.remove(finder)
1755
+ for name in helpers:
1756
+ sys.modules.pop(name, None)
1757
+
1758
+
1759
+ def load_config(directory: str | Path) -> Registry:
1760
+ """Load every ``*.py`` config module in ``directory`` (sorted; ``_*`` skipped) into a Registry.
1761
+
1762
+ Config modules are **executed** in-process with the engine's full privilege, so the source
1763
+ location is part of the trust boundary: :func:`_assert_safe_config_source` refuses a
1764
+ group/world-writable directory before any code runs. Blocking: an async caller (engine reload)
1765
+ should run this via ``asyncio.to_thread`` so heavy user-config imports don't stall listeners."""
1766
+ directory = Path(directory)
1767
+ # Fail loudly on a missing/typo'd dir: Path.glob() on a nonexistent dir yields nothing, so the
1768
+ # engine would otherwise start with an empty graph — a silently dead interface (review M-24).
1769
+ if not directory.is_dir():
1770
+ raise FileNotFoundError(f"config directory not found: {directory}")
1771
+ _assert_safe_config_source(directory)
1772
+ registry = Registry()
1773
+ # Load the bundle's reference tables (codesets/ relative to the config dir) BEFORE importing the
1774
+ # config modules, so a module-top-level code_set(...) capture resolves. A bad/duplicate table is a
1775
+ # WiringError here (fail loud), like a bad env value; a missing codesets/ dir is fine (no tables).
1776
+ try:
1777
+ registry.code_sets = load_code_sets(directory / CODESETS_DIR_NAME)
1778
+ except CodeSetError as exc:
1779
+ raise WiringError(str(exc)) from exc
1780
+ with _loading(directory, registry):
1781
+ for path in sorted(p for p in directory.glob("*.py") if not p.name.startswith("_")):
1782
+ _exec_module(path)
1783
+ # Connections may also be authored as data (ADR 0007): merge connections.toml into the SAME
1784
+ # registry the code-first inbound()/outbound() calls populated, before validating the whole graph.
1785
+ # Imported lazily to avoid a wiring<->connections_file import cycle. A name in both surfaces is a
1786
+ # duplicate WiringError via add_inbound/add_outbound (no silent precedence).
1787
+ from messagefoundry.config.connections_file import (
1788
+ CONNECTIONS_FILE_NAME,
1789
+ load_connections_file,
1790
+ )
1791
+
1792
+ conn_file = directory / CONNECTIONS_FILE_NAME
1793
+ if conn_file.is_file():
1794
+ load_connections_file(conn_file, registry)
1795
+ registry.validate()
1796
+ return registry
1797
+
1798
+
1799
+ # Group-write (0o020) | world-write (0o002): a writable bit for anyone but the owner.
1800
+ _GROUP_WORLD_WRITABLE = 0o022
1801
+
1802
+
1803
+ def _assert_safe_config_source(directory: Path) -> None:
1804
+ """Refuse to execute config Python from a group/world-writable location (POSIX).
1805
+
1806
+ Because :func:`_exec_module` runs arbitrary Python as the engine's service account, a
1807
+ lower-privileged user who can write into the config dir (or a module file) could execute
1808
+ code as that account on the next reload. On POSIX we hard-fail on a group/world-writable
1809
+ directory or module. On Windows, NTFS DACL enforcement is delegated to the documented
1810
+ install-time ACL (see docs/SERVICE.md) — reading DACLs reliably needs platform APIs out of
1811
+ scope here — and to running under a least-privilege account (docs/SERVICE.md, DEPLOY-1)."""
1812
+ if os.name != "posix" or not directory.is_dir():
1813
+ return
1814
+ # getattr keeps mypy happy on win32 (os.getuid is POSIX-only); we already returned on non-posix.
1815
+ _getuid = getattr(os, "getuid", None)
1816
+ self_uid: int | None = _getuid() if _getuid is not None else None
1817
+ # Include _*.py: the loader skips them as top-level modules, but a sibling can import them, so a
1818
+ # writable/foreign-owned helper is just as much an injection vector (review M-21).
1819
+ candidates = [directory, *directory.glob("*.py")]
1820
+ for path in candidates:
1821
+ try:
1822
+ st = path.stat()
1823
+ except OSError:
1824
+ continue
1825
+ if st.st_mode & _GROUP_WORLD_WRITABLE:
1826
+ raise WiringError(
1827
+ f"refusing to load config from group/world-writable path {path} "
1828
+ f"(mode {oct(st.st_mode & 0o777)}); see docs/SERVICE.md for required permissions"
1829
+ )
1830
+ # Code here runs as the engine's account, so a file owned by a *different* unprivileged user
1831
+ # is an escalation vector even at 0644 — that user can rewrite it (CONFIG-2 / review M-21).
1832
+ if self_uid is not None and self_uid != 0 and st.st_uid != self_uid:
1833
+ raise WiringError(
1834
+ f"refusing to load config from {path} owned by uid {st.st_uid} — the engine runs as "
1835
+ f"uid {self_uid}; that owner could rewrite the executed code (see docs/SERVICE.md)"
1836
+ )
1837
+
1838
+
1839
+ def _exec_module(path: Path) -> None:
1840
+ # Derive a collision-free module name from the resolved absolute path (not just the stem):
1841
+ # two same-stem files in different dirs must not share __module__ (breaks pickling, dataclass
1842
+ # __module__, get_type_hints). Register it in sys.modules so intra-config imports and anything
1843
+ # relying on sys.modules[__name__] resolve correctly; remove it again on failure.
1844
+ digest = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:12]
1845
+ mod_name = f"mefor_config_{path.stem}_{digest}"
1846
+ spec = importlib.util.spec_from_file_location(mod_name, path)
1847
+ if spec is None or spec.loader is None:
1848
+ raise WiringError(f"cannot load config module: {path}")
1849
+ module = importlib.util.module_from_spec(spec)
1850
+ sys.modules[mod_name] = module
1851
+ try:
1852
+ spec.loader.exec_module(module)
1853
+ except WiringError:
1854
+ sys.modules.pop(mod_name, None)
1855
+ raise
1856
+ except Exception as exc:
1857
+ sys.modules.pop(mod_name, None)
1858
+ raise WiringError(f"error loading config module {path.name}: {exc}") from exc
1859
+
1860
+
1861
+ def validate_config(directory: str | Path) -> list[Diagnostic]:
1862
+ """Load ``directory`` best-effort and return **all** problems (not just the first).
1863
+
1864
+ Unlike :func:`load_config`, a bad module is recorded and loading continues, and every
1865
+ unresolved ``inbound → router`` reference is reported — so an editor can show the full set
1866
+ at once. Returns ``[]`` when the config is valid.
1867
+ """
1868
+ directory = Path(directory)
1869
+ if not directory.is_dir(): # fail loudly, not silently empty (review M-24)
1870
+ return [Diagnostic(message=f"config directory not found: {directory}", file=str(directory))]
1871
+ try:
1872
+ # Same trust boundary as load_config: never execute Python from an unsafe source (review low-11).
1873
+ _assert_safe_config_source(directory)
1874
+ except WiringError as exc:
1875
+ return [Diagnostic(message=str(exc), file=str(directory))]
1876
+ registry = Registry()
1877
+ diagnostics: list[Diagnostic] = []
1878
+ # Load reference tables first (so a module-top-level code_set(...) resolves during import). A
1879
+ # bad/duplicate table is recorded as a diagnostic, not raised, so the editor sees every problem.
1880
+ codesets_dir = directory / CODESETS_DIR_NAME
1881
+ try:
1882
+ registry.code_sets = load_code_sets(codesets_dir)
1883
+ except CodeSetError as exc:
1884
+ diagnostics.append(Diagnostic(message=str(exc), file=str(codesets_dir)))
1885
+ with _loading(directory, registry):
1886
+ for path in sorted(p for p in directory.glob("*.py") if not p.name.startswith("_")):
1887
+ try:
1888
+ _exec_module(path)
1889
+ except WiringError as exc:
1890
+ diagnostics.append(Diagnostic(message=str(exc), file=str(path)))
1891
+ # Merge connections.toml best-effort too (ADR 0007), so the editor sees TOML problems alongside the
1892
+ # *.py ones and the router/port checks below cover TOML-authored connections. Lazy import (cycle).
1893
+ from messagefoundry.config.connections_file import (
1894
+ CONNECTIONS_FILE_NAME,
1895
+ load_connections_file,
1896
+ )
1897
+
1898
+ conn_file = directory / CONNECTIONS_FILE_NAME
1899
+ if conn_file.is_file():
1900
+ try:
1901
+ load_connections_file(conn_file, registry)
1902
+ except WiringError as exc:
1903
+ diagnostics.append(Diagnostic(message=str(exc), file=str(conn_file)))
1904
+ for conn in registry.inbound.values():
1905
+ if conn.router not in registry.routers:
1906
+ diagnostics.append(
1907
+ Diagnostic(
1908
+ message=f"inbound connection {conn.name!r} references unknown router "
1909
+ f"{conn.router!r}"
1910
+ )
1911
+ )
1912
+ for port, first, second in registry.port_collisions(): # low-13
1913
+ diagnostics.append(
1914
+ Diagnostic(
1915
+ message=f"inbound connections {first!r} and {second!r} both bind port {port}"
1916
+ )
1917
+ )
1918
+ return diagnostics