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,1311 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Operational **service settings** — deployment config, distinct from the code-first message graph.
4
+
5
+ The message graph (Connections/Routers/Handlers) is authored in Python and loaded from ``--config``;
6
+ this module covers the *operational* knobs an admin sets to run the service: where the store lives,
7
+ the API bind address, logging. They load from a TOML file + environment + CLI, with precedence::
8
+
9
+ CLI flag > environment variable > messagefoundry.toml > built-in default
10
+
11
+ Secrets (e.g. a future DB password) belong in **env** (``MEFOR_<SECTION>_<KEY>``), never in the file.
12
+ This is the first cut (build-order step 1 of docs/CONFIGURATION.md): ``[store]`` (backend/path/
13
+ synchronous), ``[api]`` (host/port), and ``[logging]`` (level + structured-JSON ``format`` + off-box
14
+ ``forward_*`` syslog shipping — sec-offbox-log). ``[retention]`` is now enforced (the
15
+ ``RetentionRunner``), except its ``audit_days`` key, which is reserved/keep-forever by design.
16
+ Remaining planned keys (some server-DB ``[store]`` keys) are accepted-but-ignored for now so a
17
+ forward-looking config file still loads.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import re
25
+ import tomllib
26
+ from enum import Enum
27
+ from pathlib import Path
28
+ from typing import Any, Mapping
29
+
30
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
31
+
32
+ from messagefoundry.config.ai_policy import AiDataScope, AiMode, DataClass
33
+ from messagefoundry.config.models import (
34
+ AckAfter,
35
+ BuildupThreshold,
36
+ InternalErrorPolicy,
37
+ OrderingMode,
38
+ RetryPolicy,
39
+ )
40
+ from messagefoundry.config.tls_policy import validate_tls_ciphers
41
+ from messagefoundry.logging_setup import LOG_LEVELS
42
+
43
+ __all__ = [
44
+ "StoreBackend",
45
+ "SqliteSync",
46
+ "SqlAuth",
47
+ "StoreSettings",
48
+ "ApiSettings",
49
+ "InboundSettings",
50
+ "DeliverySettings",
51
+ "PipelineSettings",
52
+ "EnvironmentsSettings",
53
+ "LoggingSettings",
54
+ "LogFormat",
55
+ "SyslogProtocol",
56
+ "ReferenceSettings",
57
+ "RetentionSettings",
58
+ "AuthSettings",
59
+ "AiSettings",
60
+ "AiMode",
61
+ "AiDataScope",
62
+ "DataClass",
63
+ "EgressSettings",
64
+ "ShadowSettings",
65
+ "AlertsSettings",
66
+ "ClusterSettings",
67
+ "ApprovalsSettings",
68
+ "ServiceSettings",
69
+ "load_settings",
70
+ ]
71
+
72
+ #: Known config sections (used to parse ``MEFOR_<SECTION>_<KEY>`` env vars).
73
+ _SECTIONS = (
74
+ "store",
75
+ "api",
76
+ "inbound",
77
+ "delivery",
78
+ "environments",
79
+ "logging",
80
+ "reference",
81
+ "retention",
82
+ "auth",
83
+ "ai",
84
+ "egress",
85
+ "shadow",
86
+ "alerts",
87
+ "cluster",
88
+ "approvals",
89
+ )
90
+ _ENV_PREFIX = "MEFOR_"
91
+ _DEFAULT_FILE = "messagefoundry.toml"
92
+
93
+ _log = logging.getLogger(__name__)
94
+
95
+ #: (section, key) secrets that belong in env, never the config file (see _warn_file_secrets).
96
+ _FILE_SECRET_KEYS = (
97
+ ("store", "password"),
98
+ ("store", "encryption_key"),
99
+ ("store", "encryption_keys_retired"),
100
+ ("auth", "ad_bind_password"),
101
+ ("alerts", "email_password"),
102
+ ("api", "tls_key_password"),
103
+ )
104
+
105
+
106
+ class StoreBackend(str, Enum):
107
+ SQLITE = "sqlite"
108
+ SQLSERVER = (
109
+ "sqlserver" # production server-DB backend; full staged pipeline (see store/sqlserver.py)
110
+ )
111
+ POSTGRES = (
112
+ "postgres" # production server-DB backend with single-node parity (see store/postgres.py)
113
+ )
114
+
115
+
116
+ class SqliteSync(str, Enum):
117
+ NORMAL = "normal" # crash-safe under WAL, no per-commit fsync (default)
118
+ FULL = "full"
119
+
120
+
121
+ class SqlAuth(str, Enum):
122
+ SQL = "sql" # SQL login (username + password)
123
+ INTEGRATED = "integrated" # Windows Integrated auth
124
+ ENTRA = "entra" # Microsoft Entra ID (Azure AD)
125
+
126
+
127
+ class _Section(BaseModel):
128
+ # Ignore unknown keys so a forward-looking file (planned retention/delivery keys) still loads.
129
+ model_config = ConfigDict(extra="ignore")
130
+
131
+
132
+ #: Env var that explicitly permits MITM-able TLS overrides for a trusted-network dev/test bind.
133
+ INSECURE_TLS_ESCAPE_ENV = "MEFOR_ALLOW_INSECURE_TLS"
134
+
135
+
136
+ def insecure_tls_allowed() -> bool:
137
+ """Whether the explicit dev escape to permit insecure TLS overrides is set (ASVS 12.3.2).
138
+
139
+ Certificate-validation overrides (``ad_tls_verify=false`` for LDAPS, ``trust_server_certificate
140
+ =true`` for SQL Server) are MITM-able, so they now **refuse** at startup unless
141
+ ``MEFOR_ALLOW_INSECURE_TLS`` is truthy. This means a production deployment can't silently disable
142
+ server-cert validation; an operator must opt in loudly for a trusted-network dev/test bind."""
143
+ return os.environ.get(INSECURE_TLS_ESCAPE_ENV, "").strip().lower() in ("1", "true", "yes", "on")
144
+
145
+
146
+ class StoreSettings(_Section):
147
+ backend: StoreBackend = StoreBackend.SQLITE
148
+
149
+ # --- SQLite (default backend) -------------------------------------------
150
+ path: str = "messagefoundry.db"
151
+ synchronous: SqliteSync = SqliteSync.NORMAL
152
+
153
+ # --- PHI-at-rest encryption (both backends; STORE-1 / WP-5) -------------
154
+ # Base64 32-byte ACTIVE key; when set, PHI columns (raw bodies + error/last_error/detail) are
155
+ # AES-256-GCM-encrypted at rest. Secret — supply via MEFOR_STORE_ENCRYPTION_KEY, never the file.
156
+ # Empty = off (values stored as-is).
157
+ encryption_key: str | None = None
158
+ # Comma-separated base64 RETIRED keys, kept available for *decrypt only* during a key rotation
159
+ # (ASVS 11.2.2) until `messagefoundry rotate-key` finishes re-encrypting under the active key.
160
+ # Secret — env-only (MEFOR_STORE_ENCRYPTION_KEYS_RETIRED). Empty = none.
161
+ encryption_keys_retired: str = ""
162
+ # When true, `serve` refuses to start without an encryption key (any environment). Off by default;
163
+ # with it off, a 'prod' environment still gets a loud startup warning. See docs/PHI.md §3.
164
+ require_encryption: bool = False
165
+ # Windows DPAPI-protected key file (WP-11d, ASVS 13.3.1): a path produced by
166
+ # `messagefoundry protect-key`. When `encryption_key` is unset and this is set, the active key is
167
+ # CryptUnprotectData'd from this file at open — so the plaintext key never sits in the service
168
+ # environment. This is a *path*, not a secret, so it may live in the config file. Windows-only;
169
+ # the env key takes precedence. Empty = use `encryption_key` (the cross-platform default).
170
+ encryption_key_file: str | None = None
171
+ # KeyProvider seam (ADR 0019, ASVS 13.3.3): selects HOW the active/retired DEK bytes are *sourced* —
172
+ # never how they are used (the cipher, keyring, and `mfenc:v1` format are unchanged). `auto` (the
173
+ # default) is the env-then-DPAPI ladder, BYTE-IDENTICAL to the pre-seam behavior; `env`/`dpapi` pin a
174
+ # single built-in source; `aws_kms`|`azure_kv`|`gcp_kms`|`vault`|`pkcs11` are external HSM/KMS/Vault
175
+ # envelope-decrypt providers (lazy, optional extras — not built yet, fail closed if selected). This
176
+ # names a *provider*, not key material, so it is NOT a secret — it must never be added to
177
+ # `_FILE_SECRET_KEYS`. Unknown/unresolvable values fail closed at `open_store` (store/keyprovider.py).
178
+ key_provider: str = "auto"
179
+
180
+ # --- Server-DB backends (backend = "sqlserver" | "postgres") ------------
181
+ # These connection fields are shared by every server-database backend. SQL Server consumes them
182
+ # via an ODBC DSN (store/sqlserver.py); Postgres maps them onto asyncpg connection params
183
+ # (store/postgres.py). trust_server_certificate/encrypt drive the TLS posture identically.
184
+ server: str | None = None
185
+ # Default is SQL Server's port (1433); for the Postgres backend a left-at-default 1433 is treated
186
+ # as "use Postgres's conventional 5432" by the model_validator below, so a Postgres deployment that
187
+ # omits `port` still connects (set MEFOR_STORE_PORT explicitly to override either default).
188
+ port: int = 1433
189
+ database: str | None = None
190
+ auth: SqlAuth = SqlAuth.SQL
191
+ username: str | None = None
192
+ password: str | None = None # secret — supply via MEFOR_STORE_PASSWORD, never the file
193
+ encrypt: bool = True
194
+ trust_server_certificate: bool = False
195
+ pool_size: int = 5
196
+ connect_timeout: int = 15 # seconds
197
+ command_timeout: int = 30 # seconds
198
+ db_schema: str | None = (
199
+ None # 'db_schema' avoids shadowing BaseModel.schema; env: MEFOR_STORE_DB_SCHEMA
200
+ )
201
+ application_name: str = "messagefoundry"
202
+ # Inflight-row lease TTL (seconds) for the multi-node server-DB backends (Track B Step 2). When a
203
+ # worker claims a row it stamps owner + a lease_expires_at = now + this; a renew timer extends it
204
+ # while processing, and a leader sweep reclaims only rows whose lease has expired (so a crashed
205
+ # node's work is recovered without stealing a live sibling's in-flight rows). A shared server-DB
206
+ # field — harmless to SQL Server / SQLite, which don't lease and ignore it. The lease is wall-clock
207
+ # across nodes, so the no-theft guarantee assumes clocks are NTP-synced to well within this TTL;
208
+ # set it comfortably larger than expected clock skew + the renew interval.
209
+ lease_ttl_seconds: float = 60.0
210
+
211
+ @field_validator("lease_ttl_seconds")
212
+ @classmethod
213
+ def _positive_lease_ttl(cls, value: float) -> float:
214
+ if value <= 0:
215
+ raise ValueError("lease_ttl_seconds must be > 0")
216
+ return value
217
+
218
+ @field_validator("server", "database", "username", "application_name")
219
+ @classmethod
220
+ def _no_odbc_injection(cls, value: str | None) -> str | None:
221
+ """Reject ODBC connection-string metacharacters in identity fields (STORE-5).
222
+
223
+ These go into the DSN; a ``;``/``{``/``}``/``=`` or newline could smuggle extra keywords
224
+ (e.g. downgrade TLS or redirect the server). Passwords legitimately contain these, so they
225
+ are brace-escaped at build time instead (see ``sqlserver.connection_string``)."""
226
+ if value is not None and any(ch in value for ch in ";{}=\r\n"):
227
+ raise ValueError(
228
+ "must not contain ';', '{', '}', '=', or newlines (ODBC injection risk)"
229
+ )
230
+ return value
231
+
232
+ @model_validator(mode="after")
233
+ def _require_server_db_fields(self) -> "StoreSettings":
234
+ """When a server-database backend (SQL Server or Postgres) is selected, its connection
235
+ essentials must be present. Both backends share the ``server``/``database`` (+ ``username``
236
+ for SQL auth) connection fields; Postgres additionally only supports SQL (username/password)
237
+ auth in this phase — INTEGRATED/ENTRA are SQL-Server-only until a Postgres equivalent
238
+ (Kerberos/IAM) is built."""
239
+ if self.backend in (StoreBackend.SQLSERVER, StoreBackend.POSTGRES):
240
+ label = self.backend.value
241
+ if self.backend is StoreBackend.POSTGRES:
242
+ if self.auth is not SqlAuth.SQL:
243
+ raise ValueError(
244
+ "postgres backend supports only auth='sql' (username + MEFOR_STORE_PASSWORD) "
245
+ f"in this phase, not auth={self.auth.value!r}"
246
+ )
247
+ if self.port == 1433:
248
+ # Left at the SQL-Server default → fall back to Postgres's conventional port so a
249
+ # Postgres deployment that omits `port` doesn't silently dial 1433 and fail.
250
+ self.port = 5432
251
+ missing = [name for name in ("server", "database") if getattr(self, name) is None]
252
+ if self.auth is SqlAuth.SQL and self.username is None:
253
+ missing.append("username") # SQL login needs a user (+ MEFOR_STORE_PASSWORD)
254
+ if missing:
255
+ raise ValueError(f"{label} backend requires: " + ", ".join(missing))
256
+ return self
257
+
258
+
259
+ class ApiSettings(_Section):
260
+ host: str = "127.0.0.1" # Phase 1 = localhost only
261
+ port: int = 8765
262
+ expose_docs: bool = False # serve /docs, /redoc, /openapi.json (off by default; widens surface)
263
+ # Extra directories /config/reload may load from, besides the startup --config dir. The loader
264
+ # EXECUTES Python from these, so list only admin-owned, trusted roots (e.g. an IDE staging dir).
265
+ config_reload_roots: list[str] = []
266
+
267
+ # Browser Origins allowed to open the /ws/stats WebSocket (ASVS 4.4.2). The only shipped client
268
+ # is the PySide6 desktop console, which sends NO Origin header, so the secure default is empty:
269
+ # a request that carries an Origin (i.e. a browser) is rejected unless its Origin is listed here.
270
+ ws_allowed_origins: list[str] = []
271
+
272
+ # --- In-process API/WebSocket TLS (WP-13a, ADR 0002) --------------------
273
+ # When tls_cert_file is set the engine terminates TLS in uvicorn, so the API serves https/wss and
274
+ # HSTS (already emitted on https) engages — the first-class way to bind off-loopback safely. PEM
275
+ # paths (not secrets); the key may be in the cert PEM (tls_key_file optional).
276
+ tls_cert_file: str | None = None
277
+ tls_key_file: str | None = None
278
+ # Passphrase for an encrypted private key. Secret — supply via MEFOR_API_TLS_KEY_PASSWORD, never
279
+ # the file.
280
+ tls_key_password: str | None = None
281
+ # Minimum negotiated TLS version floor (NIST SP 800-52r2: 1.2+). "1.2" or "1.3".
282
+ tls_min_version: str = "1.2"
283
+ # Optional OpenSSL cipher string (default = the interpreter's secure defaults).
284
+ tls_ciphers: str | None = None
285
+ # Optional CA bundle to verify CLIENT certs (mTLS for the console; opt-in, future).
286
+ tls_client_ca_file: str | None = None
287
+
288
+ # --- Reverse-proxy / upstream TLS termination (WP-15, ADR 0002) --------
289
+ # Proxy IPs whose X-Forwarded-For/-Proto headers are trusted (uvicorn forwarded_allow_ips). Empty =
290
+ # trust nothing (the audit/rate-limit source IP is then the direct TCP peer). Set this ONLY to the
291
+ # reverse proxy's address(es), or XFF spoofing returns.
292
+ trusted_proxies: list[str] = []
293
+ # Declare that a reverse proxy / load balancer terminates TLS in front of the engine. Lets a
294
+ # non-loopback bind satisfy the exposed-gate WITHOUT in-process TLS — but only when trusted_proxies
295
+ # is set (so the engine knows a terminator is really in front).
296
+ tls_terminated_upstream: bool = False
297
+
298
+ @property
299
+ def tls_enabled(self) -> bool:
300
+ """Whether in-process API TLS is configured (a server cert is present)."""
301
+ return bool(self.tls_cert_file)
302
+
303
+ @property
304
+ def exposure_protected(self) -> bool:
305
+ """Whether an off-loopback bind is safe: in-process TLS (WP-13a) OR a declared upstream TLS
306
+ terminator behind trusted proxies (WP-15)."""
307
+ return self.tls_enabled or (self.tls_terminated_upstream and bool(self.trusted_proxies))
308
+
309
+ @property
310
+ def is_loopback(self) -> bool:
311
+ """Whether the API binds a loopback host — i.e. is **not** exposed off-box, so the exposed-bind
312
+ TLS gate and the MFA-at-exposure advisory (``serve``) don't apply. Treats ``127.0.0.1``,
313
+ ``localhost`` and ``::1`` as loopback (a dual-stack box never spuriously counts as exposed)."""
314
+ return self.host in ("127.0.0.1", "localhost", "::1")
315
+
316
+ @field_validator("config_reload_roots", "ws_allowed_origins", "trusted_proxies", mode="before")
317
+ @classmethod
318
+ def _split_roots(cls, v: object) -> object:
319
+ # The env layer delivers list settings (MEFOR_API_CONFIG_RELOAD_ROOTS,
320
+ # MEFOR_API_WS_ALLOWED_ORIGINS, MEFOR_API_TRUSTED_PROXIES) as one string; split it on the
321
+ # platform path separator so these list-typed settings can be set via env (review low-12).
322
+ if isinstance(v, str):
323
+ return [p for p in v.split(os.pathsep) if p]
324
+ return v
325
+
326
+ @field_validator("tls_min_version")
327
+ @classmethod
328
+ def _check_tls_min_version(cls, v: str) -> str:
329
+ if v not in ("1.2", "1.3"):
330
+ raise ValueError(f"tls_min_version must be '1.2' or '1.3' (NIST 800-52r2), got {v!r}")
331
+ return v
332
+
333
+ @field_validator("tls_ciphers")
334
+ @classmethod
335
+ def _check_tls_ciphers(cls, v: str | None) -> str | None:
336
+ # Reject a cipher string that would admit a non-forward-secret key exchange (ASVS 11.6.2), so a
337
+ # misconfiguration can't widen the suite below the ECDHE policy. Fails loud at load, not bind.
338
+ return v if v is None else validate_tls_ciphers(v)
339
+
340
+ @model_validator(mode="after")
341
+ def _check_tls_cert_dependency(self) -> "ApiSettings":
342
+ # A key (or its passphrase / a client-CA) is meaningless without a server cert; require it so a
343
+ # half-configured TLS block fails loud at load, not at bind.
344
+ if (
345
+ self.tls_key_file or self.tls_key_password or self.tls_client_ca_file
346
+ ) and not self.tls_cert_file:
347
+ raise ValueError(
348
+ "tls_key_file / tls_key_password / tls_client_ca_file require [api].tls_cert_file"
349
+ )
350
+ # An upstream TLS terminator only satisfies the exposed-gate when the engine knows (and trusts)
351
+ # the proxy in front — otherwise it's an unverifiable claim that XFF could spoof.
352
+ if self.tls_terminated_upstream and not self.trusted_proxies:
353
+ raise ValueError("[api].tls_terminated_upstream requires [api].trusted_proxies")
354
+ return self
355
+
356
+
357
+ class InboundSettings(_Section):
358
+ """Inbound-connection defaults that are an operational, per-environment decision rather than
359
+ something authored in the message graph."""
360
+
361
+ # The network interface EVERY inbound MLLP/TCP listener binds to. Loopback by default; binding
362
+ # 0.0.0.0 exposes unauthenticated MLLP to the network, so it's a deliberate per-instance admin
363
+ # choice (DEV typically loopback, PROD a specific NIC or 0.0.0.0) — not a developer default.
364
+ # Connections never carry a host; they inherit this. See docs/CONNECTIONS.md.
365
+ bind_host: str = "127.0.0.1"
366
+
367
+ # Default ACK timing for every inbound (staged pipeline, ADR 0001): INGEST = ACK-on-receipt
368
+ # (the message is ACKed once durably committed to the ingress stage). A connection's own
369
+ # ack_after= overrides this. Step A supports only INGEST; 'delivered' (defer the ACK until
370
+ # delivery) is not yet implemented and is rejected at engine start.
371
+ ack_after: AckAfter = AckAfter.INGEST
372
+
373
+
374
+ class DeliverySettings(_Section):
375
+ """Global outbound-delivery defaults. An outbound connection that declares no ``retry=``/
376
+ ``ordering=`` of its own inherits these; an explicit per-connection value overrides them
377
+ (resolution order: per-connection override > ``[delivery]`` global default > built-in). The
378
+ retry fields mirror :class:`~messagefoundry.config.models.RetryPolicy`; a test guards the sync.
379
+ """
380
+
381
+ # Key names match docs/CONFIGURATION.md's [delivery] catalog (retry_-prefixed so the section can
382
+ # also grow non-retry keys like outbox_workers/dead_letter later). max_attempts unset (None) =
383
+ # retry forever (the conservative default — see RetryPolicy); set a finite value to dead-letter.
384
+ retry_max_attempts: int | None = None
385
+ retry_backoff_seconds: float = 5.0
386
+ retry_backoff_multiplier: float = 2.0
387
+ retry_max_backoff_seconds: float = 300.0
388
+ # Default queue ordering for every outbound (FIFO = strict in-order per connection).
389
+ ordering: OrderingMode = OrderingMode.FIFO
390
+ # What a delivery worker does on an internal/code error: continue (dead-letter + advance, default)
391
+ # or stop the connection and alert. Per-connection internal_error= overrides this.
392
+ internal_error: InternalErrorPolicy = InternalErrorPolicy.CONTINUE
393
+ # queue_buildup alert thresholds for every outbound. Mirror BuildupThreshold (a test guards the
394
+ # sync); buildup_max_depth unset = depth dimension off; per-connection buildup= overrides.
395
+ buildup_max_depth: int | None = None
396
+ buildup_max_oldest_seconds: float | None = 300.0
397
+
398
+ def retry_policy(self) -> RetryPolicy:
399
+ """The global default :class:`RetryPolicy` an outbound inherits when it sets none."""
400
+ return RetryPolicy(
401
+ max_attempts=self.retry_max_attempts,
402
+ backoff_seconds=self.retry_backoff_seconds,
403
+ backoff_multiplier=self.retry_backoff_multiplier,
404
+ max_backoff_seconds=self.retry_max_backoff_seconds,
405
+ )
406
+
407
+ def buildup_threshold(self) -> BuildupThreshold:
408
+ """The global default :class:`BuildupThreshold` an outbound inherits when it sets none."""
409
+ return BuildupThreshold(
410
+ max_depth=self.buildup_max_depth,
411
+ max_oldest_seconds=self.buildup_max_oldest_seconds,
412
+ )
413
+
414
+
415
+ class PipelineSettings(_Section):
416
+ """Staged-pipeline tunables (ADR 0013 Increment 2). ``max_correlation_depth`` bounds re-ingress
417
+ loops: a re-ingressed message at this correlation depth still routes, but the next hop (depth+1)
418
+ dead-letters its work-row and the origin is marked ``ERROR``. Coarse by design (it bounds total work,
419
+ not topology) — a chain that legitimately bounces A→B→A a few times needs headroom; the default 8 is
420
+ safe for typical request→response→route feeds. Floor of 1 (a value of 0 would dead-letter every
421
+ re-ingress)."""
422
+
423
+ max_correlation_depth: int = Field(default=8, ge=1)
424
+
425
+
426
+ class EnvironmentsSettings(_Section):
427
+ """Where the per-environment **values** (``env()`` lookups in the message graph) live.
428
+
429
+ The ACTIVE environment is the single cross-cutting selector ``[ai].environment`` (a free-form
430
+ name, ADR 0017); this section only locates the value files. Each environment has a ``<env>.toml``
431
+ flat table under ``dir`` for non-secret values (versioned), overlaid by ``MEFOR_VALUE_<KEY>`` env
432
+ vars for secrets. See docs/CONFIGURATION.md."""
433
+
434
+ dir: str = "environments" # directory of <env>.toml value files, relative to base_dir (below)
435
+ # Anchor that ``dir`` (and thus ``environments/<env>.toml``) resolves against. Empty (default) =
436
+ # the process working directory — the original behavior, so an existing deployment is unchanged.
437
+ # Set it to the config-repo root (a standalone config repo keeps environments/ at its root, a
438
+ # sibling of the --config dir) so env-value resolution no longer depends on where serve was
439
+ # launched — important under NSSM, whose working dir is rarely the repo. A relative value is taken
440
+ # against the working dir; an absolute value is used as-is (on Windows it must be drive-qualified,
441
+ # e.g. C:/repo — a leading-slash "/repo" is drive-relative and still inherits the launch drive).
442
+ # Overridable per run via ``serve --project-root``. See resolve_values_base_dir + docs/CONFIGURATION.md.
443
+ base_dir: str = ""
444
+
445
+
446
+ class LogFormat(str, Enum):
447
+ TEXT = "text" # human-readable (the default; stdout unchanged)
448
+ JSON = "json" # one JSON object per line — structured for a log shipper / SIEM
449
+
450
+
451
+ class SyslogProtocol(str, Enum):
452
+ UDP = "udp" # RFC 5426; fire-and-forget, never blocks the engine (the default)
453
+ TCP = (
454
+ "tcp" # RFC 6587; connection-oriented (down-at-startup skipped; runtime stall bounded by a
455
+ )
456
+ # socket timeout so a wedged collector can't block the event loop — synchronous send)
457
+
458
+
459
+ class LoggingSettings(_Section):
460
+ """``[logging]`` — log level, stdout rendering, and optional off-box forwarding (sec-offbox-log).
461
+
462
+ PHI redaction + control-char scrubbing are applied to **every** sink (stdout and the forwarder) by
463
+ ``logging_setup.configure_logging``, so structured output and off-box shipping never weaken the
464
+ "never log full PHI bodies" guarantee (docs/PHI.md §7)."""
465
+
466
+ level: str = "INFO"
467
+ # stdout rendering: "text" (default, unchanged) or "json" (one JSON object per line, friendlier to
468
+ # a log shipper tailing NSSM's captured stdout).
469
+ format: LogFormat = LogFormat.TEXT
470
+
471
+ # --- Off-box forwarding to a syslog/SIEM collector (ASVS 16.x) ----------
472
+ # Ship a copy of every log record to a remote syslog collector so log evidence survives a host
473
+ # compromise (the local audit_log is tamper-evident, but lives on the same host). Off by default.
474
+ # PHI redaction applies to the forwarded stream exactly as to stdout, but the syslog transport
475
+ # itself is plaintext — terminate it at a local TLS-forwarding agent or keep it on a trusted
476
+ # management network (see docs/SECURITY.md / docs/PHI.md). The forwarder never blocks the engine
477
+ # indefinitely: UDP is fire-and-forget; a TCP collector unreachable at startup is skipped (warns),
478
+ # and a runtime stall is bounded by a socket timeout (record dropped). Synchronous send — for a
479
+ # high-volume feed prefer UDP or a local agent.
480
+ forward_enabled: bool = False
481
+ forward_host: str | None = None
482
+ forward_port: int = 514
483
+ forward_protocol: SyslogProtocol = SyslogProtocol.UDP
484
+ # Wire format sent off-box, independent of the stdout `format`. JSON is the SIEM-friendly default and
485
+ # guarantees one record per line; "text" framing is best-effort (a multi-line traceback spans lines).
486
+ forward_format: LogFormat = LogFormat.JSON
487
+
488
+ @field_validator("level")
489
+ @classmethod
490
+ def _normalize_level(cls, value: str) -> str:
491
+ upper = value.upper()
492
+ if upper not in LOG_LEVELS:
493
+ raise ValueError(
494
+ f"invalid log level {value!r}; expected one of {', '.join(LOG_LEVELS)}"
495
+ )
496
+ return upper
497
+
498
+ @field_validator("forward_port")
499
+ @classmethod
500
+ def _check_forward_port(cls, value: int) -> int:
501
+ if not 1 <= value <= 65535:
502
+ raise ValueError("[logging].forward_port must be between 1 and 65535")
503
+ return value
504
+
505
+ @model_validator(mode="after")
506
+ def _forward_needs_host(self) -> "LoggingSettings":
507
+ if self.forward_enabled and not self.forward_host:
508
+ raise ValueError(
509
+ "[logging].forward_enabled requires [logging].forward_host (the syslog/SIEM collector)"
510
+ )
511
+ return self
512
+
513
+
514
+ class ReferenceSettings(_Section):
515
+ """``[reference]`` — managed, versioned, read-only lookup snapshots (ADR 0006 Tier 1).
516
+
517
+ Enforced by the engine's :class:`~messagefoundry.pipeline.reference_sync.ReferenceSyncRunner`.
518
+ Reference sets are declared in wiring modules with ``Reference(name, source=…)`` and materialized
519
+ OFF the message path; a transform reads them purely via ``reference("name").get(key)``. The runner
520
+ is a no-op when no sets are declared, so these defaults are safe for an existing deployment."""
521
+
522
+ # Base cadence (seconds) the sync loop ticks at; each set re-materializes when its own
523
+ # refresh_seconds is due. Must be > 0.
524
+ refresh_interval_seconds: float = 3600.0
525
+ # Sync every declared set once at startup, before inbound listeners begin serving, so a transform's
526
+ # reference(...) resolves on the very first message. Strongly recommended on.
527
+ sync_on_startup: bool = True
528
+ # Reserved freshness guard (seconds; 0 = off): alert/refuse when the active snapshot is older than
529
+ # this. Not enforced in Tier 1 — accepted so a forward-looking file still loads.
530
+ max_staleness_seconds: float = 0.0
531
+
532
+ @field_validator("refresh_interval_seconds")
533
+ @classmethod
534
+ def _positive_interval(cls, value: float) -> float:
535
+ if value <= 0:
536
+ raise ValueError("refresh_interval_seconds must be > 0")
537
+ return value
538
+
539
+ @field_validator("max_staleness_seconds")
540
+ @classmethod
541
+ def _non_negative_staleness(cls, value: float) -> float:
542
+ if value < 0:
543
+ raise ValueError("max_staleness_seconds must be >= 0 (0 = off)")
544
+ return value
545
+
546
+
547
+ class RetentionSettings(_Section):
548
+ """``[retention]`` — data-retention + SQLite maintenance (PHI.md §8, ASVS 14.2.x).
549
+
550
+ Enforced by the engine's :class:`~messagefoundry.pipeline.retention.RetentionRunner`. Every window
551
+ defaults to ``0``/``""`` = keep/off, so an existing deployment is unchanged until an operator opts
552
+ in. A purge **NULLs the PHI *body*** of a message/dead-letter while **keeping its metadata row**
553
+ (counts + disposition + audit stay intact — the Mirth Data-Pruner pattern); it never deletes a
554
+ ``messages`` row, and never touches a body still in flight (at-least-once is preserved).
555
+ """
556
+
557
+ # Past N days, null inbound bodies (raw/summary/error) of fully-resolved messages, keeping the
558
+ # metadata row. 0 = keep forever.
559
+ messages_days: int = 0
560
+ # Past N days, null the bodies of DEAD (dead-lettered) outbound rows — their own window because a
561
+ # dead row stays replayable until its body is purged. 0 = keep forever.
562
+ dead_letter_days: int = 0
563
+ # Past N days, DELETE transform-state entries (ADR 0005) last written before the cutoff — keeps the
564
+ # in-memory state cache + table bounded. A simple global age purge; per-namespace policy is a
565
+ # follow-up. 0 = keep forever (the default — state correlation data is opt-in to purge).
566
+ state_max_age_days: int = 0
567
+ # Audit-log retention. RESERVED / not enforced: the audit_log is a tamper-evident hash chain and
568
+ # HIPAA expects ~6-year retention, so audit is keep-forever by design here; archive-first pruning
569
+ # is a tracked follow-up. Accepted (not rejected) so a forward-looking file still loads.
570
+ audit_days: int = 0
571
+ # Warn (WARNING log + AlertSink storage_threshold) when the DB file (+ -wal/-shm) exceeds this
572
+ # many MB. 0 = off. Advisory only — never auto-deletes.
573
+ max_db_mb: int = 0
574
+ # How often the purge/maintenance loop runs a pass (seconds).
575
+ purge_interval_seconds: float = 3600.0
576
+ # PRAGMA wal_checkpoint(TRUNCATE) cadence in seconds (SQLite). 0 = off — rely on SQLite's
577
+ # auto-checkpoint. Evaluated once per purge pass, so a value below purge_interval_seconds is
578
+ # effectively rounded up to it.
579
+ wal_checkpoint_seconds: float = 0.0
580
+ # Daily local clock time "HH:MM" at which to run VACUUM (SQLite; reclaims space freed by purges).
581
+ # "" = off. A daily off-peak time, not a cron expression, to avoid a new dependency — VACUUM holds
582
+ # a write lock on the whole DB while it runs, so it is off by default and meant for a quiet window.
583
+ vacuum_at: str = ""
584
+
585
+ @field_validator(
586
+ "messages_days", "dead_letter_days", "audit_days", "max_db_mb", "state_max_age_days"
587
+ )
588
+ @classmethod
589
+ def _non_negative_days(cls, value: int) -> int:
590
+ if value < 0:
591
+ raise ValueError("retention windows/thresholds must be >= 0 (0 = keep/off)")
592
+ return value
593
+
594
+ @field_validator("purge_interval_seconds")
595
+ @classmethod
596
+ def _positive_interval(cls, value: float) -> float:
597
+ if value <= 0:
598
+ raise ValueError("purge_interval_seconds must be > 0")
599
+ return value
600
+
601
+ @field_validator("wal_checkpoint_seconds")
602
+ @classmethod
603
+ def _non_negative_wal(cls, value: float) -> float:
604
+ if value < 0:
605
+ raise ValueError("wal_checkpoint_seconds must be >= 0 (0 = off)")
606
+ return value
607
+
608
+ @field_validator("vacuum_at")
609
+ @classmethod
610
+ def _valid_clock_time(cls, value: str) -> str:
611
+ value = value.strip()
612
+ if value and cls._parse_clock(value) is None:
613
+ raise ValueError(f"vacuum_at must be empty or 'HH:MM' (24h), got {value!r}")
614
+ return value
615
+
616
+ @staticmethod
617
+ def _parse_clock(value: str) -> tuple[int, int] | None:
618
+ m = re.fullmatch(r"(\d{1,2}):(\d{2})", value.strip())
619
+ if not m:
620
+ return None
621
+ hour, minute = int(m.group(1)), int(m.group(2))
622
+ if 0 <= hour <= 23 and 0 <= minute <= 59:
623
+ return hour, minute
624
+ return None
625
+
626
+ def vacuum_time(self) -> tuple[int, int] | None:
627
+ """The configured daily VACUUM time as ``(hour, minute)`` local, or ``None`` when disabled."""
628
+ return self._parse_clock(self.vacuum_at) if self.vacuum_at else None
629
+
630
+
631
+ class AuthSettings(_Section):
632
+ """Authentication + RBAC knobs. Secrets (the AD bind password) come from env, never the file."""
633
+
634
+ # Authentication is required by default; this flag exists only for the embedding/test path.
635
+ enabled: bool = True
636
+ session_idle_timeout_minutes: int = 30
637
+ session_absolute_hours: int = 12
638
+ # Cap concurrent sessions per user (ASVS 7.1.2); a login beyond the cap revokes the user's oldest
639
+ # active session. 0 = unlimited. Default 5 (WP-10): generous for a few devices/console instances.
640
+ max_sessions_per_user: int = 5
641
+ # Step-up re-verification (ASVS 7.5.3): a highly sensitive operation requires the session to have
642
+ # re-verified its credential — at login or via POST /me/reauth — within this many seconds. The
643
+ # initial login counts as the first verification (sudo-timestamp model). Default 5 minutes.
644
+ step_up_max_age_seconds: int = 300
645
+
646
+ # Multi-factor authentication (WP-14, ADR 0002 §3; ASVS 6.3.3) — a native RFC 6238 TOTP second
647
+ # factor for LOCAL accounts. AD/Kerberos MFA is delegated to the directory (Entra Conditional
648
+ # Access / an MFA proxy), so a directory login is never prompted for an engine TOTP. When
649
+ # require_mfa is on, a user holding the Administrator role MUST enroll TOTP and satisfy it before
650
+ # any step-up (sensitive) operation; non-admins may opt in voluntarily. Default OFF preserves
651
+ # today's loopback behavior byte-for-byte (on the 127.0.0.1 bind 6.3.3 is deferred-by-design with
652
+ # the single-trusted-host compensating control). An off-loopback bind that serves local accounts
653
+ # SHOULD turn this on; ``serve`` now makes that posture explicit (sec-mfa-on) — on an exposed
654
+ # (non-loopback) PHI bind with this off it **refuses to start** on a production instance and
655
+ # **warns** on a non-production one, mirroring the keyless-store / open-egress startup gates (see
656
+ # __main__._serve), so MFA can't be silently skipped at exposure. Scope: it gates **step-up
657
+ # (sensitive) operations** for the Administrator role — it is NOT a gate on every authenticated PHI
658
+ # read (those stay behind RBAC + the PHI-read throttle).
659
+ require_mfa: bool = False
660
+ # How many single-use recovery codes are minted at enrollment (the lost-authenticator escape
661
+ # hatch). 0 disables recovery codes (an admin reset is then the only recovery path).
662
+ mfa_recovery_code_count: int = 10
663
+ # Admin-interface defense-in-depth contextual-risk signal (WP-L3-13, ADR 0002; ASVS 8.4.2). When
664
+ # on, a step-up (sensitive admin) request arriving from a client IP that differs from the one the
665
+ # session last verified from is treated as higher-risk: it emits an audit + out-of-band notice and
666
+ # FORCES a fresh step-up (a successful re-verify re-anchors the session to the new IP). It is
667
+ # advisory + step-up-forcing only — it NEVER changes an RBAC allow/deny and never blocks the
668
+ # non-admin request path. Default OFF preserves today's behavior byte-for-byte; and even on, a
669
+ # single-host loopback deployment never trips it (loopback addresses 127.0.0.1 and ::1 are treated
670
+ # as the same host, so a dual-stack box doesn't spuriously fire).
671
+ # An off-loopback bind serving admins SHOULD turn this on (operator/runbook responsibility).
672
+ admin_new_ip_step_up: bool = False
673
+
674
+ # Local-password policy — ASVS 5.0-aligned (WP-3): length-first, no mandatory composition.
675
+ password_min_length: int = 15
676
+ # Character-class requirements are OFF by default (ASVS forbids mandatory composition) but kept
677
+ # as opt-in knobs for deployments with a legacy standard that still mandates them.
678
+ password_require_uppercase: bool = False
679
+ password_require_lowercase: bool = False
680
+ password_require_digit: bool = False
681
+ password_require_symbol: bool = False
682
+ password_check_breached: bool = True # reject known common/breached passwords (offline corpus)
683
+ password_check_context: bool = True # reject passwords containing app/vendor/HL7 terms
684
+ password_check_username: bool = (
685
+ True # reject passwords containing the user's own username (6.2.11)
686
+ )
687
+ # Optional path to a larger offline breach corpus that augments the bundled top-10k list (6.2.12):
688
+ # a plaintext list OR an HIBP-style SHA-1-hash export (HASH[:count] lines, auto-detected). Fully
689
+ # offline — no live HIBP call. Use a curated subset, not the full ~40 GB HIBP set (loaded into memory).
690
+ password_breach_corpus_file: str | None = None
691
+ lockout_threshold: int = 5 # consecutive failed logins before the account locks
692
+ lockout_minutes: int = 15
693
+ # First-run bootstrap admin: auto-disabled once a second administrator exists, and (if still
694
+ # unclaimed — never password-changed) disabled this many hours after creation. 0 = no time expiry.
695
+ bootstrap_expiry_hours: int = 72
696
+
697
+ # Active Directory / LDAP. The bind password is a secret: MEFOR_AUTH_AD_BIND_PASSWORD.
698
+ ad_enabled: bool = False
699
+ ad_server: str | None = None # e.g. ldaps://dc1.example.com:636
700
+ ad_domain: str | None = None # e.g. example.com (UPN suffix)
701
+ ad_user_search_base: str | None = None
702
+ ad_group_search_base: str | None = None
703
+ ad_bind_dn: str | None = None # service-account DN used to look users up
704
+ ad_bind_password: str | None = None # secret — supply via env only
705
+ ad_use_nested_groups: bool = True # resolve nested groups via LDAP_MATCHING_RULE_IN_CHAIN
706
+ ad_tls_verify: bool = True
707
+ ad_tls_ca_cert_file: str | None = None # trust an internal CA without disabling verification
708
+ ad_allow_insecure_ldap: bool = False # explicit opt-in to a non-ldaps:// bind (trusted-net dev)
709
+
710
+ # Windows SSO (Kerberos/SPNEGO) — passwordless login from a domain-joined client.
711
+ # Experimental; off by default. Not a supported v0.1 feature — hardening targeted for 0.2.
712
+ kerberos_enabled: bool = False
713
+ kerberos_spn: str | None = None # e.g. HTTP/host.example.com
714
+
715
+ # Login rate limiting (AUTH-RATE) — in-process sliding window in front of the per-account
716
+ # lockout: bounds password-spray + argon2 CPU-burn. In-process only; an exposed/multi-host
717
+ # deployment must also front the API with a proxy/WAF limiter. None/0 disables a limit.
718
+ login_rate_limit_enabled: bool = True
719
+ login_rate_limit_per_ip: int = 10 # max attempts per client IP per window
720
+ login_rate_limit_global: int = 60 # max attempts across all clients per window
721
+ login_rate_limit_window_seconds: float = 60.0
722
+
723
+ # Anti-automation on the authenticated PHI-read endpoints (WP-8, ASVS 2.4.1): a per-actor sliding
724
+ # window over /messages, /messages/{id}, /dead-letters — bounds scripted PHI harvesting on top of
725
+ # pagination + access auditing. Generous by default (clears console/human use); in-process only,
726
+ # so an exposed deployment must also front a proxy/WAF limiter. 0 disables that dimension.
727
+ phi_read_rate_limit_enabled: bool = True
728
+ phi_read_rate_limit_per_actor: int = 120 # max PHI reads per user per window
729
+ phi_read_rate_limit_global: int = 0 # max PHI reads across all users per window (0 = off)
730
+ phi_read_rate_limit_window_seconds: float = 60.0
731
+
732
+ # Out-of-band user notification of security events (ASVS 6.3.5/6.3.7): email the affected user on
733
+ # lockout / first-success-after-failures / password/email/role/disable changes. Email requires the
734
+ # [alerts] SMTP transport to be configured (no SMTP → email is skipped); the audited
735
+ # /me/security-events feed records these regardless of this toggle.
736
+ notify_security_events: bool = True
737
+
738
+ @field_validator("mfa_recovery_code_count")
739
+ @classmethod
740
+ def _check_recovery_count(cls, value: int) -> int:
741
+ if not 0 <= value <= 50:
742
+ raise ValueError("mfa_recovery_code_count must be between 0 and 50 (0 = disabled)")
743
+ return value
744
+
745
+ @model_validator(mode="after")
746
+ def _require_ad_fields(self) -> "AuthSettings":
747
+ """AD/SSO need their connection essentials present when enabled."""
748
+ if self.ad_enabled and (self.ad_server is None or self.ad_user_search_base is None):
749
+ raise ValueError("ad_enabled requires: ad_server, ad_user_search_base")
750
+ if (
751
+ self.ad_enabled
752
+ and self.ad_server is not None
753
+ and not self.ad_server.lower().startswith("ldaps://")
754
+ and not self.ad_allow_insecure_ldap
755
+ ):
756
+ raise ValueError(
757
+ "ad_enabled requires an ldaps:// ad_server (credentials go over a SIMPLE bind); "
758
+ "set ad_allow_insecure_ldap=true only for a trusted-network dev override"
759
+ )
760
+ if self.ad_enabled and (self.ad_bind_dn is None or self.ad_bind_password is None):
761
+ raise ValueError(
762
+ "ad_enabled requires a service account: ad_bind_dn and ad_bind_password "
763
+ "(supply the password via MEFOR_AUTH_AD_BIND_PASSWORD)"
764
+ )
765
+ if self.kerberos_enabled and not self.ad_enabled:
766
+ raise ValueError("kerberos_enabled requires ad_enabled (SSO resolves roles via AD)")
767
+ return self
768
+
769
+
770
+ #: Characters permitted in a free-form environment NAME (it selects ``environments/<name>.toml``, so
771
+ #: it must be a safe single path segment).
772
+ _ENV_NAME_ALLOWED = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
773
+
774
+ #: Built-in environment names whose security posture (data_class, production) is derived when
775
+ #: ``[ai].data_class`` / ``[ai].production`` are left unset — back-compat with the original
776
+ #: dev/staging/prod tiers. A CUSTOM name must set posture explicitly (it is never inferred from a
777
+ #: free-form string), so a 'test'/'poc' instance can never default permissive (ADR 0017).
778
+ _KNOWN_ENV_POSTURE: dict[str, tuple[DataClass, bool]] = {
779
+ "dev": (DataClass.SYNTHETIC, False),
780
+ "staging": (DataClass.PHI, False),
781
+ "prod": (DataClass.PHI, True),
782
+ }
783
+
784
+
785
+ class AiSettings(_Section):
786
+ """Central AI-assistance policy plus the instance's active **environment name** and security
787
+ **posture**. The two AI axes (mode + data scope) are bounded by the production-posture ceiling
788
+ computed by :func:`~messagefoundry.config.ai_policy.resolve_effective_policy` (the API endpoint
789
+ and the ``ai-policy`` CLI both clamp these before serving them). See docs/AI.md.
790
+
791
+ ``environment`` is the **free-form** active-environment name (ADR 0017): it selects
792
+ ``environments/<name>.toml`` and is what ``current_environment()`` returns. It has **no default** —
793
+ ``serve`` requires it, so a missing env can never silently resolve another environment's
794
+ values/secrets. ``data_class`` / ``production`` are the explicit security posture, **decoupled from
795
+ the name**: for the built-in names dev/staging/prod they are derived when unset, but a custom name
796
+ must set them (see :meth:`require_posture`)."""
797
+
798
+ mode: AiMode = AiMode.BYO
799
+ data_scope: AiDataScope = AiDataScope.CODE_ONLY
800
+ # Free-form active-environment NAME (ADR 0017): selects environments/<name>.toml + what
801
+ # current_environment() returns. No default — serve requires it (a missing env must never silently
802
+ # resolve another env's values/secrets).
803
+ environment: str | None = None
804
+ # Explicit security POSTURE, decoupled from the name. Unset is derived from a built-in name
805
+ # (dev->synthetic/non-prod, staging->phi/non-prod, prod->phi/prod); a custom name must set them.
806
+ data_class: DataClass | None = None
807
+ production: bool | None = None
808
+
809
+ # --- forward-compat (accepted-but-UNUSED in this MVP; for the P1 engine broker) ----------
810
+ # These describe a managed provider connection. They parse so a forward-looking config loads,
811
+ # but nothing in this build reads them — managed modes are not yet implemented.
812
+ provider: str = "claude"
813
+ model: str = "claude-opus-4-8"
814
+ baa_attested: bool = False
815
+ endpoint: str | None = None
816
+
817
+ @field_validator("environment")
818
+ @classmethod
819
+ def _valid_environment_name(cls, v: str | None) -> str | None:
820
+ # The name becomes a filename segment (environments/<name>.toml), so keep it a simple token.
821
+ if v is not None and (not v or not set(v) <= _ENV_NAME_ALLOWED):
822
+ raise ValueError(
823
+ "[ai].environment must be a non-empty name of letters, digits, '.', '_' or '-' "
824
+ "(it selects environments/<name>.toml)"
825
+ )
826
+ return v
827
+
828
+ def derived_posture(self) -> tuple[DataClass | None, bool | None]:
829
+ """``(data_class, production)`` with built-in-name derivation applied where each is unset.
830
+
831
+ Either element may still be ``None`` when a *custom* environment name leaves it unset — callers
832
+ that need a definite posture use :meth:`require_posture` (fail-closed) or default the missing
833
+ ``production`` to ``True`` (strictest ceiling) for an advisory read."""
834
+ dc, prod = self.data_class, self.production
835
+ known = _KNOWN_ENV_POSTURE.get(self.environment or "")
836
+ if known is not None:
837
+ if dc is None:
838
+ dc = known[0]
839
+ if prod is None:
840
+ prod = known[1]
841
+ return dc, prod
842
+
843
+ def require_posture(self) -> tuple[DataClass, bool]:
844
+ """The fail-closed ``(data_class, production)`` posture; raises ``ValueError`` when a custom or
845
+ unset environment name has no explicit posture. Used at ``serve`` so a custom env never defaults
846
+ permissive (ADR 0017)."""
847
+ dc, prod = self.derived_posture()
848
+ if dc is None or prod is None:
849
+ raise ValueError(
850
+ f"environment {self.environment!r} has no built-in security posture (not one of "
851
+ "dev/staging/prod); set [ai].data_class (synthetic|phi) and [ai].production "
852
+ "(true|false) explicitly"
853
+ )
854
+ return dc, prod
855
+
856
+
857
+ class EgressSettings(_Section):
858
+ """``[egress]`` — fail-closed outbound destination allowlist (WP-11c; ASVS 13.2.4/13.2.5/14.2.3).
859
+
860
+ Bounds where the engine may **send** PHI, so a fat-fingered or hostile outbound destination can't
861
+ exfiltrate it. Each list is **opt-in**: empty = unrestricted (today's behavior); once a transport's
862
+ list is set, a destination of that transport not on it is **refused at config load/reload**
863
+ (fail-closed), checked against the resolved (``env()``-substituted) destination. The webhook/SMTP
864
+ *alert* sinks carry no PHI bodies and keep their own ``[alerts]`` host allowlists.
865
+
866
+ Set ``deny_by_default = true`` to flip the whole posture fail-closed: a transport with an **empty**
867
+ allowlist then refuses *every* destination of that type (so each permitted destination must be
868
+ listed). Default false keeps the per-list opt-in behavior.
869
+ """
870
+
871
+ # Allowed MLLP outbound destinations: each entry is "host" (any port) or "host:port".
872
+ allowed_mllp: list[str] = []
873
+ # Allowed raw-TCP outbound destinations: each entry is "host" (any port) or "host:port".
874
+ allowed_tcp: list[str] = []
875
+ # Allowed File outbound directories: a destination's directory must resolve at/under one of these.
876
+ allowed_file_dirs: list[str] = []
877
+ # Allowed REST/SOAP (HTTP) outbound hosts: each entry is "host" (any port) or "host:port".
878
+ allowed_http: list[str] = []
879
+ # Allowed DATABASE outbound servers: each entry is "host" (any port) or "host:port".
880
+ allowed_db: list[str] = []
881
+ # Allowed REMOTEFILE (SFTP/FTP/FTPS) hosts — gates the connector in BOTH directions (the source
882
+ # dials out to poll, the destination dials out to upload). Each entry is "host" or "host:port".
883
+ allowed_remote: list[str] = []
884
+
885
+ # Opt-in deny-by-default (Q5b): when true, a transport with an EMPTY allowlist refuses every
886
+ # destination of that type instead of allowing any. A global on-ramp to fail-closed egress without
887
+ # having to enumerate one list just to flip the posture; pairs with the prod/staging open-egress
888
+ # startup advisory. Default false = the per-list opt-in behavior above (empty = unrestricted).
889
+ deny_by_default: bool = False
890
+
891
+ @field_validator(
892
+ "allowed_mllp",
893
+ "allowed_tcp",
894
+ "allowed_file_dirs",
895
+ "allowed_http",
896
+ "allowed_db",
897
+ "allowed_remote",
898
+ mode="before",
899
+ )
900
+ @classmethod
901
+ def _split_list(cls, v: object) -> object:
902
+ # Allow setting via env (MEFOR_EGRESS_ALLOWED_MLLP=...) as one comma-separated string.
903
+ if isinstance(v, str):
904
+ return [item.strip() for item in v.split(",") if item.strip()]
905
+ return v
906
+
907
+
908
+ class ShadowSettings(_Section):
909
+ """``[shadow]`` — parallel-run / shadow-instance egress suppression (#15).
910
+
911
+ A *shadow* MessageFoundry instance processes real (teed) traffic to validate it against a legacy
912
+ engine, but must **not** deliver to live partners (the legacy engine is still the real sender).
913
+ Set ``simulate_all_egress = true`` to force **every** outbound into ``simulate`` mode regardless of
914
+ its per-connection ``simulate=`` flag — the deployment-wide safety switch so a shadow stand-up
915
+ can't accidentally leave one outbound live. Default false = each outbound's own ``simulate=`` flag
916
+ applies. (Per-outbound is the precise control; this is the blunt instance-wide override.)
917
+ """
918
+
919
+ simulate_all_egress: bool = False
920
+
921
+
922
+ class AlertSeverity(str, Enum):
923
+ """Severity a matching rule tags a fired alert with (ADR 0014) — carried in the payload so a
924
+ webhook target (PagerDuty/Slack/Teams) or the email subject can triage by it."""
925
+
926
+ INFO = "info"
927
+ WARNING = "warning"
928
+ CRITICAL = "critical"
929
+
930
+
931
+ #: The alert event types a rule may match (plus ``"any"``); mirror the AlertSink methods.
932
+ _ALERT_EVENT_TYPES = frozenset(
933
+ {"connection_stopped", "queue_buildup", "storage_threshold", "cert_expiry"}
934
+ )
935
+ #: The transport names a rule may route to; mirror ``AlertTransport.name``.
936
+ _ALERT_TRANSPORTS = frozenset({"webhook", "email"})
937
+
938
+
939
+ class AlertRule(BaseModel):
940
+ """One operator-authored alerting rule (ADR 0014). The **first** rule that matches an event decides
941
+ its severity, which transports fire, and the re-alert cooldown; an event matching no rule keeps the
942
+ default (notify every configured transport at ``warning`` with the global ``realert_seconds``).
943
+ Rules are pure data — there is no embedded code/expression."""
944
+
945
+ model_config = ConfigDict(extra="forbid")
946
+
947
+ # --- match (all conditions must hold) ---
948
+ event_type: str = (
949
+ "any" # "any" | connection_stopped | queue_buildup | storage_threshold | cert_expiry
950
+ )
951
+ connection: str = "*" # fnmatch glob over the connection name; "*" = all
952
+ min_depth: int | None = Field(None, ge=1) # queue_buildup: match only at/over this lane depth
953
+ min_oldest_seconds: float | None = Field(
954
+ None, ge=0
955
+ ) # queue_buildup: …or oldest-message age (s)
956
+ # --- outcome ---
957
+ severity: AlertSeverity = AlertSeverity.WARNING
958
+ transports: list[str] | None = (
959
+ None # None = every configured transport; [] = suppress entirely (event dropped, never sent)
960
+ )
961
+ cooldown_seconds: float | None = Field(
962
+ None, gt=0
963
+ ) # override realert_seconds for matching events
964
+
965
+ @field_validator("event_type")
966
+ @classmethod
967
+ def _check_event_type(cls, v: str) -> str:
968
+ if v != "any" and v not in _ALERT_EVENT_TYPES:
969
+ allowed = ", ".join(sorted({"any", *_ALERT_EVENT_TYPES}))
970
+ raise ValueError(f"event_type must be one of {allowed}; got {v!r}")
971
+ return v
972
+
973
+ @field_validator("transports")
974
+ @classmethod
975
+ def _check_transports(cls, v: list[str] | None) -> list[str] | None:
976
+ if v is not None:
977
+ bad = [t for t in v if t not in _ALERT_TRANSPORTS]
978
+ if bad:
979
+ allowed = ", ".join(sorted(_ALERT_TRANSPORTS))
980
+ raise ValueError(f"transports must be a subset of [{allowed}]; unknown: {bad}")
981
+ return v
982
+
983
+
984
+ class AlertsSettings(_Section):
985
+ """Where operational alerts (``connection_stopped`` / ``queue_buildup`` from the delivery
986
+ pipeline) are delivered. Both transports are **off by default** — with neither configured the
987
+ engine falls back to logging the events at ``WARNING`` (``LoggingAlertSink``).
988
+
989
+ A transport is *enabled* when its essentials are present: ``webhook_url`` for the webhook;
990
+ ``email_smtp_host`` + ``email_from`` + at least one ``email_to`` for email. The SMTP password is a
991
+ secret — supply it via ``MEFOR_ALERTS_EMAIL_PASSWORD``, never the file. Payloads carry only the
992
+ connection name + queue shape (no PHI)."""
993
+
994
+ # --- webhook (generic HTTP POST; fronts Slack/Teams/PagerDuty/custom) ----
995
+ webhook_url: str | None = None
996
+ webhook_timeout: float = 10.0 # seconds per POST
997
+ # Optional egress allowlist for the webhook host (ASVS 1.3.6, SSRF defense-in-depth). Empty =
998
+ # any host (the URL is operator-configured, not request-derived). When set, the webhook_url host
999
+ # must be listed or the transport refuses to send. Comma- or os.pathsep-separated via env.
1000
+ webhook_allowed_hosts: list[str] = []
1001
+
1002
+ # --- email / SMTP -------------------------------------------------------
1003
+ email_smtp_host: str | None = None
1004
+ email_smtp_port: int = 587
1005
+ email_from: str | None = None
1006
+ email_to: list[str] = []
1007
+ email_use_tls: bool = True # STARTTLS
1008
+ email_username: str | None = None
1009
+ email_password: str | None = None # secret — supply via MEFOR_ALERTS_EMAIL_PASSWORD
1010
+ email_timeout: float = 30.0 # seconds per send
1011
+ # Egress allowlist for the SMTP host (WP-11c, parity with webhook_allowed_hosts). Empty = any.
1012
+ smtp_allowed_hosts: list[str] = []
1013
+
1014
+ # Re-alert throttle: the same (event, connection) won't re-notify more often than this, so a
1015
+ # flapping lane can't spam the channel.
1016
+ realert_seconds: float = 300.0
1017
+
1018
+ # Operator alert rules (ADR 0014): refine severity / which transports fire / cooldown / suppression
1019
+ # per event + connection. Empty = today's behaviour (every event → every transport, global throttle).
1020
+ # Authored as ``[[alerts.rules]]`` tables in the config file. First match wins.
1021
+ rules: list[AlertRule] = []
1022
+
1023
+ @field_validator("email_to", "webhook_allowed_hosts", "smtp_allowed_hosts", mode="before")
1024
+ @classmethod
1025
+ def _split_recipients(cls, v: object) -> object:
1026
+ # The env layer delivers list-typed alerts settings (MEFOR_ALERTS_EMAIL_TO,
1027
+ # MEFOR_ALERTS_WEBHOOK_ALLOWED_HOSTS) as one string; split on commas so they can be set via
1028
+ # env (mirrors api.config_reload_roots).
1029
+ if isinstance(v, str):
1030
+ return [addr.strip() for addr in v.split(",") if addr.strip()]
1031
+ return v
1032
+
1033
+
1034
+ class ClusterSettings(_Section):
1035
+ """``[cluster]`` — horizontal scale-out coordination (Track B Steps 3-7).
1036
+
1037
+ The multi-node coordination seam (a ``nodes`` table + per-node heartbeat + leader election) without
1038
+ changing single-node behavior: with ``enabled = false`` (the default) the engine uses the no-op
1039
+ :class:`~messagefoundry.pipeline.cluster.NullCoordinator` and runs byte-identically to before.
1040
+ With ``enabled = true`` on Postgres, the scale-out feature set is COMPLETE: leader election (Step 4),
1041
+ leader-gated poll-source intake (Step 4b), per-lane FIFO ownership (Step 5), cross-node reference +
1042
+ config-reload + transform-state convergence (Steps 6/6b), and the read-only observability API
1043
+ (Step 7 — ``/cluster/status`` + ``/cluster/nodes``). Exactly one node runs the leader-only WRITE
1044
+ singletons (retention, the lease-reclaim sweep) and re-reads each reference source while followers
1045
+ read-through the shared snapshot; an operator config reload propagates cluster-wide via a version
1046
+ token; and operators can see membership + leadership over the API. Operators must keep node clocks
1047
+ synced (NTP — leases are wall-clock), run identical config dirs on every node, and apply config
1048
+ changes via a coordinated (not rolling) restart — see ``docs/CLUSTERING.md``. Leadership itself is a
1049
+ **self-fencing lease** (Workstream A2): the leader renews a ``leader_lease`` row every
1050
+ ``heartbeat_seconds`` to ``DB_now + leader_lease_ttl_seconds``, a standby acquires only once that
1051
+ lease has expired, and a leader that cannot renew within ``leader_fence_timeout_seconds`` self-fences
1052
+ before the lease can expire (the split-brain guard). The cross-section validator below requires
1053
+ ``[store].backend = postgres`` and ``[store].pool_size >= 2`` when this is enabled (a clustered node
1054
+ drives concurrent background work against the pool)."""
1055
+
1056
+ enabled: bool = False
1057
+ # Override the auto-generated node id (host:pid:hex). Pin it for a stable identity across restarts
1058
+ # or in tests; left unset, the factory reuses the store's lease owner-id so node-id == owner-id.
1059
+ node_id: str | None = None
1060
+ # How often a node refreshes its `last_seen` heartbeat. The same cadence drives leadership-lease
1061
+ # renewal (Track B Step 4 / Workstream A2) — no separate leader-check knob. Must be > 0.
1062
+ heartbeat_seconds: float = 10.0
1063
+ # A node is considered dead when its last_seen is older than this. Consulted by DbCoordinator's
1064
+ # cluster_members() (Step 7) as the freshness filter for the /cluster/nodes observability endpoint —
1065
+ # it discards a crashed ex-leader's stale is_leader flag and bounds the failover window in which a
1066
+ # just-beaten node still counts toward the derived leader. It is NOT what transfers leadership: the
1067
+ # self-fencing leadership lease is (a standby acquires only once the lease has expired). Must be > 0.
1068
+ node_timeout_seconds: float = 30.0
1069
+ # How often the LEADER runs the lease-reclaim sweep (reclaim_expired_leases) that recovers crashed
1070
+ # nodes' in-flight rows (Track B Step 4). Only the current leader acts; followers no-op. Must be > 0.
1071
+ reclaim_interval_seconds: float = 30.0
1072
+ # The leadership LEASE TTL (Workstream A2 active-passive self-fencing). The current leader renews the
1073
+ # lease every heartbeat_seconds, extending its expiry to DB_now + this; a standby may acquire leadership
1074
+ # ONLY once the lease has expired, so it always waits out the full TTL. Measured on the DB's own clock
1075
+ # (clock_timestamp()), so inter-node clock skew is irrelevant to leadership correctness. Must be > 0.
1076
+ leader_lease_ttl_seconds: float = 30.0
1077
+ # The SELF-FENCE timeout: a leader that has not renewed its lease within this many seconds (its own
1078
+ # monotonic clock, with NO DB I/O so a hung/partitioned DB can't block it) halts its leader work.
1079
+ # MUST be < leader_lease_ttl_seconds so the old leader stops BEFORE the lease can expire and a standby
1080
+ # acquire — the split-brain guard. MUST be > heartbeat_seconds so a single missed renew doesn't fence.
1081
+ leader_fence_timeout_seconds: float = 20.0
1082
+
1083
+ @field_validator(
1084
+ "heartbeat_seconds",
1085
+ "node_timeout_seconds",
1086
+ "reclaim_interval_seconds",
1087
+ "leader_lease_ttl_seconds",
1088
+ "leader_fence_timeout_seconds",
1089
+ )
1090
+ @classmethod
1091
+ def _positive(cls, value: float) -> float:
1092
+ if value <= 0:
1093
+ raise ValueError("must be > 0")
1094
+ return value
1095
+
1096
+ @model_validator(mode="after")
1097
+ def _timeout_exceeds_heartbeat(self) -> "ClusterSettings":
1098
+ """A node must beat at least once within its dead-timeout, or Step-4 election would mark a
1099
+ live node dead between beats. node_timeout_seconds is reserved for that election, but lock the
1100
+ invariant in now so a misconfiguration is caught at config load, not at election bring-up."""
1101
+ if self.node_timeout_seconds <= self.heartbeat_seconds:
1102
+ raise ValueError(
1103
+ "node_timeout_seconds must be > heartbeat_seconds "
1104
+ f"(got node_timeout_seconds={self.node_timeout_seconds}, "
1105
+ f"heartbeat_seconds={self.heartbeat_seconds}) — a node must beat at least once before "
1106
+ "it is considered dead"
1107
+ )
1108
+ return self
1109
+
1110
+ @model_validator(mode="after")
1111
+ def _fence_ordering(self) -> "ClusterSettings":
1112
+ """The split-brain guard's timing invariant (Workstream A2): heartbeat < fence < lease TTL. The
1113
+ leader must renew faster than it fences (so one missed beat doesn't demote it) and must fence
1114
+ before the lease can expire (so a partitioned old leader stops before a standby acquires).
1115
+ Caught at config load, not at failover."""
1116
+ if not (
1117
+ self.heartbeat_seconds
1118
+ < self.leader_fence_timeout_seconds
1119
+ < self.leader_lease_ttl_seconds
1120
+ ):
1121
+ raise ValueError(
1122
+ "cluster lease timing must satisfy heartbeat_seconds < leader_fence_timeout_seconds "
1123
+ "< leader_lease_ttl_seconds "
1124
+ f"(got heartbeat_seconds={self.heartbeat_seconds}, "
1125
+ f"leader_fence_timeout_seconds={self.leader_fence_timeout_seconds}, "
1126
+ f"leader_lease_ttl_seconds={self.leader_lease_ttl_seconds}) — the leader must renew "
1127
+ "faster than it fences, and fence before the lease can expire and a standby acquire it"
1128
+ )
1129
+ return self
1130
+
1131
+
1132
+ class CertMonitorSettings(_Section):
1133
+ """Periodic TLS-certificate expiry monitor (``[cert_monitor]``). The engine scans the certificate
1134
+ PEM files it actually serves with — the ``[api]`` TLS cert and every connection's ``tls_cert_file``
1135
+ (MLLP server/client identity) — and raises a ``cert_expiry`` alert when one is expired or within
1136
+ ``warn_days`` of expiry. Now that native off-loopback TLS is the supported posture, this catches a
1137
+ silently expiring cert (a hard PHI-feed outage at renewal time) ahead of time. Only the public
1138
+ certificate is read, never any private key. Set ``warn_days`` to 0 to disable the monitor."""
1139
+
1140
+ warn_days: int = 30 # alert this many days before expiry (0 = monitor off)
1141
+ check_interval_seconds: float = 43_200.0 # rescan cadence (default 12h)
1142
+
1143
+ @field_validator("warn_days")
1144
+ @classmethod
1145
+ def _check_warn_days(cls, v: int) -> int:
1146
+ if v < 0:
1147
+ raise ValueError("cert_monitor.warn_days must be >= 0 (0 disables the monitor)")
1148
+ return v
1149
+
1150
+ @field_validator("check_interval_seconds")
1151
+ @classmethod
1152
+ def _check_interval(cls, v: float) -> float:
1153
+ if v <= 0:
1154
+ raise ValueError("cert_monitor.check_interval_seconds must be > 0")
1155
+ return v
1156
+
1157
+
1158
+ #: The high-value operations dual-control can gate (registry keys). Confining ``[approvals].operations``
1159
+ #: to this set catches a typo'd op name at startup rather than silently never gating it.
1160
+ APPROVABLE_OPERATIONS: frozenset[str] = frozenset({"dead_letter_replay", "connection_purge"})
1161
+
1162
+
1163
+ class ApprovalsSettings(_Section):
1164
+ """Optional dual-control (maker-checker) approval for high-value actions (``[approvals]``, ASVS
1165
+ 2.3.5). **Off by default** so a single-operator deployment is never blocked. When ``enabled``, an
1166
+ action in ``operations`` is held as a pending request and must be released by a *distinct* second
1167
+ user holding ``approvals:approve`` — the requester can never approve their own. A request older than
1168
+ ``expiry_hours`` can no longer be approved."""
1169
+
1170
+ enabled: bool = False
1171
+ operations: list[str] = Field(default_factory=lambda: sorted(APPROVABLE_OPERATIONS))
1172
+ expiry_hours: float = (
1173
+ 72.0 # a pending request expires this many hours after it's made (0 = never)
1174
+ )
1175
+
1176
+ @field_validator("operations")
1177
+ @classmethod
1178
+ def _known_operations(cls, v: list[str]) -> list[str]:
1179
+ unknown = sorted(set(v) - APPROVABLE_OPERATIONS)
1180
+ if unknown:
1181
+ raise ValueError(
1182
+ f"[approvals].operations has unknown operation(s) {unknown}; "
1183
+ f"valid: {sorted(APPROVABLE_OPERATIONS)}"
1184
+ )
1185
+ return v
1186
+
1187
+ @field_validator("expiry_hours")
1188
+ @classmethod
1189
+ def _check_expiry(cls, v: float) -> float:
1190
+ if v < 0:
1191
+ raise ValueError("approvals.expiry_hours must be >= 0 (0 = never expires)")
1192
+ return v
1193
+
1194
+
1195
+ class ServiceSettings(BaseModel):
1196
+ model_config = ConfigDict(extra="ignore") # tolerate forward-looking/unknown sections
1197
+
1198
+ store: StoreSettings = Field(default_factory=StoreSettings)
1199
+ api: ApiSettings = Field(default_factory=ApiSettings)
1200
+ inbound: InboundSettings = Field(default_factory=InboundSettings)
1201
+ delivery: DeliverySettings = Field(default_factory=DeliverySettings)
1202
+ pipeline: PipelineSettings = Field(default_factory=PipelineSettings)
1203
+ environments: EnvironmentsSettings = Field(default_factory=EnvironmentsSettings)
1204
+ logging: LoggingSettings = Field(default_factory=LoggingSettings)
1205
+ reference: ReferenceSettings = Field(default_factory=ReferenceSettings)
1206
+ retention: RetentionSettings = Field(default_factory=RetentionSettings)
1207
+ auth: AuthSettings = Field(default_factory=AuthSettings)
1208
+ ai: AiSettings = Field(default_factory=AiSettings)
1209
+ egress: EgressSettings = Field(default_factory=EgressSettings)
1210
+ shadow: ShadowSettings = Field(default_factory=ShadowSettings)
1211
+ alerts: AlertsSettings = Field(default_factory=AlertsSettings)
1212
+ cert_monitor: CertMonitorSettings = Field(default_factory=CertMonitorSettings)
1213
+ cluster: ClusterSettings = Field(default_factory=ClusterSettings)
1214
+ approvals: ApprovalsSettings = Field(default_factory=ApprovalsSettings)
1215
+
1216
+ @model_validator(mode="after")
1217
+ def _cluster_requires_server_db(self) -> "ServiceSettings":
1218
+ """Cluster coordination needs a shared **server-DB** store to back the ``nodes`` + leadership-
1219
+ lease tables. SQLite is single-file/single-node, so it cannot. **Postgres** and **SQL Server**
1220
+ both can: each runs the active-passive leadership lease (one leader drains the graph; a standby
1221
+ takes over on failure). Postgres additionally backs the active-active per-lane row leases (0.2
1222
+ horizontal scale-out); SQL Server is **active-passive only** (no row leases — the leader-gate +
1223
+ self-fence keep a single active processor at a time). This spans two sections, so it lives here
1224
+ (not on :class:`ClusterSettings`, which can't see ``[store]``)."""
1225
+ if self.cluster.enabled:
1226
+ if self.store.backend not in (StoreBackend.POSTGRES, StoreBackend.SQLSERVER):
1227
+ raise ValueError(
1228
+ "[cluster].enabled requires [store].backend in {'postgres', 'sqlserver'} "
1229
+ f"(got {self.store.backend.value!r}); SQLite is single-node — cluster coordination "
1230
+ "needs a shared server-DB store (Postgres active-active/active-passive, or SQL "
1231
+ "Server active-passive)"
1232
+ )
1233
+ if self.store.pool_size < 2:
1234
+ # A clustered node runs concurrent background work against the pool — the maintenance
1235
+ # loop (heartbeat + lease renew + lane/config refresh), the leader-gated reclaim sweep,
1236
+ # and the per-stage workers — alongside request traffic. A pool of 1 would serialize all
1237
+ # of it behind a single connection, so require headroom.
1238
+ raise ValueError(
1239
+ "[cluster].enabled requires [store].pool_size >= 2 "
1240
+ f"(got {self.store.pool_size}); a clustered node drives concurrent background work "
1241
+ "(the membership/lease maintenance loop + the leader reclaim sweep + the per-stage "
1242
+ "workers) against the pool, so a pool of 1 would serialize everything — prefer "
1243
+ "pool_size >= 3 for clustered Postgres"
1244
+ )
1245
+ return self
1246
+
1247
+
1248
+ def _merge(dst: dict[str, dict[str, Any]], src: Mapping[str, Any]) -> None:
1249
+ """Shallow-merge per-section dicts from ``src`` into ``dst`` (later layers win)."""
1250
+ for section, values in src.items():
1251
+ if isinstance(values, dict):
1252
+ dst.setdefault(section, {}).update(values)
1253
+
1254
+
1255
+ def _env_overrides(environ: Mapping[str, str]) -> dict[str, dict[str, Any]]:
1256
+ """Parse ``MEFOR_<SECTION>_<KEY>`` vars into ``{section: {key: value}}`` (strings; pydantic coerces)."""
1257
+ out: dict[str, dict[str, Any]] = {}
1258
+ for name, value in environ.items():
1259
+ if not name.startswith(_ENV_PREFIX):
1260
+ continue
1261
+ section, _, key = name[len(_ENV_PREFIX) :].lower().partition("_")
1262
+ if section in _SECTIONS and key:
1263
+ out.setdefault(section, {})[key] = value
1264
+ return out
1265
+
1266
+
1267
+ def _warn_file_secrets(file_data: Mapping[str, Any], path: Path) -> None:
1268
+ """Warn when a secret is supplied via the config file instead of the environment."""
1269
+ for section, key in _FILE_SECRET_KEYS:
1270
+ sect = file_data.get(section)
1271
+ if isinstance(sect, dict) and sect.get(key) is not None:
1272
+ _log.warning(
1273
+ "secret [%s].%s is set in %s; move it to env (MEFOR_%s_%s) — the config file is "
1274
+ "not a safe place for secrets",
1275
+ section,
1276
+ key,
1277
+ path,
1278
+ section.upper(),
1279
+ key.upper(),
1280
+ )
1281
+
1282
+
1283
+ def load_settings(
1284
+ *,
1285
+ config_path: str | Path | None = None,
1286
+ cli: Mapping[str, Mapping[str, Any]] | None = None,
1287
+ environ: Mapping[str, str] | None = None,
1288
+ ) -> ServiceSettings:
1289
+ """Resolve settings with CLI > env > file > default precedence.
1290
+
1291
+ ``config_path`` reads that TOML file (error if it's missing); when ``None``, ``./messagefoundry.toml``
1292
+ is used **only if it exists**. ``cli`` is a nested ``{section: {key: value}}`` of explicitly-provided
1293
+ CLI overrides (omit a key to fall through). ``environ`` defaults to ``os.environ``.
1294
+ """
1295
+ environ = os.environ if environ is None else environ
1296
+ data: dict[str, dict[str, Any]] = {}
1297
+
1298
+ path = Path(config_path) if config_path is not None else Path(_DEFAULT_FILE)
1299
+ if config_path is not None and not path.exists():
1300
+ raise FileNotFoundError(f"service config not found: {path}")
1301
+ if path.exists():
1302
+ with path.open("rb") as fh:
1303
+ file_data = tomllib.load(fh)
1304
+ _warn_file_secrets(file_data, path)
1305
+ _merge(data, file_data)
1306
+
1307
+ _merge(data, _env_overrides(environ))
1308
+ if cli:
1309
+ _merge(data, cli)
1310
+
1311
+ return ServiceSettings.model_validate(data)