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,1155 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Command-line entrypoint for the MessageFoundry engine + IDE tooling.
4
+
5
+ messagefoundry serve --config ./samples/config --db ./messagefoundry.db # run engine + API
6
+ messagefoundry validate --config ./samples/config --json # report problems
7
+ messagefoundry graph --config ./samples/config --json # the wired graph
8
+ messagefoundry dryrun --config ./samples/config --messages ./msgs --json # run, don't send
9
+ messagefoundry check --config ./samples/config --messages ./msgs # commit/CI gate
10
+ messagefoundry connection upsert --config ./samples/config --data '{...}' # edit connections.toml
11
+ messagefoundry generate --type ADT --count 5 --out ./out/adt # synthetic HL7
12
+ messagefoundry hl7schema --json # HL7 field schema
13
+ messagefoundry init ./my-config-repo # scaffold a config repo
14
+
15
+ The introspection subcommands (validate/graph/dryrun/check/hl7schema) print to stdout for the VS
16
+ Code extension / git hooks; they touch no network and start no server. Heavy imports are deferred
17
+ per-command so a quick `validate`/`hl7schema` call doesn't pay for FastAPI/uvicorn.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import logging
25
+ import sys
26
+ from typing import Any
27
+
28
+ from messagefoundry import __version__
29
+ from messagefoundry.logging_setup import LOG_LEVELS, SyslogForward, configure_logging
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ parser = argparse.ArgumentParser(prog="messagefoundry", description=__doc__)
34
+ parser.add_argument("--version", action="version", version=f"messagefoundry {__version__}")
35
+ sub = parser.add_subparsers(dest="command", required=True)
36
+
37
+ serve = sub.add_parser("serve", help="run the engine + localhost API")
38
+ serve.add_argument("--config", default="samples/config", help="config modules directory (*.py)")
39
+ serve.add_argument(
40
+ "--service-config",
41
+ default=None,
42
+ help="service settings TOML (default: ./messagefoundry.toml if present)",
43
+ )
44
+ # These override the corresponding settings; defaults live in ServiceSettings, not argparse, so
45
+ # precedence (CLI > env > file > default) is honored — an unset flag falls through.
46
+ serve.add_argument("--db", default=None, help="message store path (overrides [store].path)")
47
+ serve.add_argument("--host", default=None, help="API bind host (overrides [api].host)")
48
+ serve.add_argument(
49
+ "--port", type=int, default=None, help="API bind port (overrides [api].port)"
50
+ )
51
+ serve.add_argument(
52
+ "--log-level",
53
+ default=None,
54
+ choices=LOG_LEVELS,
55
+ help="logging verbosity (overrides [logging].level)",
56
+ )
57
+ serve.add_argument(
58
+ "--env",
59
+ default=None,
60
+ help="active environment NAME (overrides [ai].environment; selects environments/<env>.toml "
61
+ "values). Built-in names dev/staging/prod carry a default posture; a custom name also needs "
62
+ "[ai].data_class + [ai].production set.",
63
+ )
64
+ serve.add_argument(
65
+ "--project-root",
66
+ default=None,
67
+ help="anchor for the per-environment value dir (overrides [environments].base_dir): the "
68
+ "config-repo root that environments/<env>.toml resolves against. Default = the working "
69
+ "directory (unchanged). Set this when serve runs from elsewhere than the repo root (e.g. "
70
+ "under NSSM) so env() values aren't silently empty.",
71
+ )
72
+ serve.add_argument(
73
+ "--allow-insecure-bind",
74
+ action="store_true",
75
+ help="permit a non-loopback [api].host WITHOUT TLS (bearer tokens and PHI would cross the "
76
+ "network in cleartext); a dev override for a trusted, firewalled network. Prefer configuring "
77
+ "[api].tls_cert_file (+ tls_key_file) for in-process TLS, which is allowed off-loopback "
78
+ "without this flag. Does not relax the no-auth refuse.",
79
+ )
80
+
81
+ validate = sub.add_parser("validate", help="check a config dir and report all problems")
82
+ validate.add_argument("--config", default="samples/config", help="config modules directory")
83
+ validate.add_argument("--json", action="store_true", help="emit JSON")
84
+
85
+ graph = sub.add_parser("graph", help="print the wired Connection/Router/Handler graph")
86
+ graph.add_argument("--config", default="samples/config", help="config modules directory")
87
+ graph.add_argument("--json", action="store_true", help="emit JSON")
88
+
89
+ dryrun = sub.add_parser("dryrun", help="run messages through the config without sending")
90
+ dryrun.add_argument("--config", default="samples/config", help="config modules directory")
91
+ dryrun.add_argument(
92
+ "--messages", required=True, nargs="+", help="HL7 file(s) or directories of *.hl7"
93
+ )
94
+ dryrun.add_argument("--inbound", default=None, help="inbound connection to simulate")
95
+ dryrun.add_argument("--json", action="store_true", help="emit JSON")
96
+ dryrun.add_argument(
97
+ "--show-phi",
98
+ action="store_true",
99
+ help="include full message bodies (raw + payloads) — PHI; redacted by default",
100
+ )
101
+
102
+ check = sub.add_parser(
103
+ "check", help="run validate + dryrun (+ advisory ruff/mypy) as a commit/CI gate"
104
+ )
105
+ check.add_argument("--config", default="samples/config", help="config modules directory")
106
+ check.add_argument(
107
+ "--messages", default=None, help="HL7 fixtures dir (dryrun gates when it has *.hl7)"
108
+ )
109
+ check.add_argument("--no-lint", action="store_true", help="skip the advisory ruff/mypy checks")
110
+ check.add_argument("--json", action="store_true", help="emit JSON")
111
+
112
+ connection = sub.add_parser(
113
+ "connection",
114
+ help="manage connections.toml — list / upsert / remove (ADR 0007; the VS Code editor shells this)",
115
+ )
116
+ connection.add_argument("action", choices=["list", "upsert", "remove"])
117
+ connection.add_argument("--config", default="samples/config", help="config modules directory")
118
+ connection.add_argument(
119
+ "--service-config",
120
+ default=None,
121
+ help="service settings TOML for [egress]/active-env validation (default: "
122
+ "./messagefoundry.toml if present)",
123
+ )
124
+ connection.add_argument("--name", default=None, help="connection name (for remove)")
125
+ connection.add_argument(
126
+ "--data", default=None, help="connection JSON for upsert (default: read from stdin)"
127
+ )
128
+ connection.add_argument("--json", action="store_true", help="emit JSON")
129
+
130
+ generate = sub.add_parser(
131
+ "generate", help="generate conformant synthetic HL7 messages (no real PHI)"
132
+ )
133
+ generate.add_argument("--type", default=None, help="message type, e.g. ADT, ORU (see --list)")
134
+ generate.add_argument(
135
+ "--triggers", default="", help="comma-separated subset (default: all for the type)"
136
+ )
137
+ generate.add_argument("--count", type=int, default=50, help="messages per trigger (default 50)")
138
+ generate.add_argument(
139
+ "--out", default=None, help="output root (default: samples/messages/<type>)"
140
+ )
141
+ generate.add_argument("--seed", default=None, help="RNG seed for reproducible output")
142
+ generate.add_argument("--list", action="store_true", help="list registered message types")
143
+ generate.add_argument("--json", action="store_true", help="emit JSON")
144
+
145
+ schema = sub.add_parser("hl7schema", help="print HL7 v2.5.1 segment/field schema")
146
+ schema.add_argument("--json", action="store_true", help="emit JSON")
147
+
148
+ init = sub.add_parser(
149
+ "init",
150
+ help="scaffold a new config repo (starter feed + environments + CI + a pinned engine)",
151
+ )
152
+ init.add_argument("dir", nargs="?", default=".", help="target directory (default: current dir)")
153
+ init.add_argument(
154
+ "--force",
155
+ action="store_true",
156
+ help="scaffold into a non-empty directory (existing files are left untouched)",
157
+ )
158
+ init.add_argument("--json", action="store_true", help="emit JSON")
159
+
160
+ sub.add_parser(
161
+ "gen-key", help="generate a base64 key for MEFOR_STORE_ENCRYPTION_KEY (PHI-at-rest)"
162
+ )
163
+
164
+ protect_key = sub.add_parser(
165
+ "protect-key",
166
+ help="DPAPI-protect the store key to a file for [store].encryption_key_file (Windows-only)",
167
+ )
168
+ protect_key.add_argument("--out", required=True, help="path to write the protected key file")
169
+ protect_key.add_argument(
170
+ "--generate",
171
+ action="store_true",
172
+ help="mint a fresh key and protect it (printed once to stderr so you can back it up offline)",
173
+ )
174
+ protect_key.add_argument(
175
+ "--user",
176
+ action="store_true",
177
+ help="protect under the current USER only (default: machine scope, so the low-privilege "
178
+ "service account can read the key at startup)",
179
+ )
180
+
181
+ audit_verify = sub.add_parser(
182
+ "audit-verify", help="verify the audit-log hash chain (tamper-evidence)"
183
+ )
184
+ audit_verify.add_argument(
185
+ "--service-config",
186
+ default=None,
187
+ help="service settings TOML (default: ./messagefoundry.toml if present)",
188
+ )
189
+ audit_verify.add_argument("--db", default=None, help="store path (overrides [store].path)")
190
+
191
+ rotate_key = sub.add_parser(
192
+ "rotate-key",
193
+ help="re-encrypt the store under the active MEFOR_STORE_ENCRYPTION_KEY (run with the engine "
194
+ "stopped; keep the prior key in MEFOR_STORE_ENCRYPTION_KEYS_RETIRED)",
195
+ )
196
+ rotate_key.add_argument(
197
+ "--service-config",
198
+ default=None,
199
+ help="service settings TOML (default: ./messagefoundry.toml if present)",
200
+ )
201
+ rotate_key.add_argument("--db", default=None, help="store path (overrides [store].path)")
202
+
203
+ ai_policy = sub.add_parser(
204
+ "ai-policy", help="print the effective AI-assistance policy (for the IDE gate)"
205
+ )
206
+ ai_policy.add_argument(
207
+ "--service-config",
208
+ default=None,
209
+ help="service settings TOML (default: ./messagefoundry.toml if present)",
210
+ )
211
+ ai_policy.add_argument("--json", action="store_true", help="emit JSON only (parsed by the IDE)")
212
+
213
+ args = parser.parse_args(argv)
214
+ return _DISPATCH[args.command](args)
215
+
216
+
217
+ def _serve(args: argparse.Namespace) -> int:
218
+ import uvicorn
219
+ from pydantic import ValidationError
220
+
221
+ from messagefoundry.api import create_managed_app
222
+ from messagefoundry.config.settings import StoreBackend, load_settings
223
+
224
+ # Only pass flags the user actually supplied so they override env/file but an unset flag doesn't.
225
+ cli: dict[str, dict[str, object]] = {}
226
+ if args.db is not None:
227
+ cli.setdefault("store", {})["path"] = args.db
228
+ if args.host is not None:
229
+ cli.setdefault("api", {})["host"] = args.host
230
+ if args.port is not None:
231
+ cli.setdefault("api", {})["port"] = args.port
232
+ if args.log_level is not None:
233
+ cli.setdefault("logging", {})["level"] = args.log_level
234
+ if args.env is not None:
235
+ cli.setdefault("ai", {})["environment"] = args.env # the single active-environment selector
236
+ if args.project_root is not None:
237
+ # Anchor for environments/<env>.toml resolution (overrides [environments].base_dir).
238
+ cli.setdefault("environments", {})["base_dir"] = args.project_root
239
+
240
+ try:
241
+ settings = load_settings(config_path=args.service_config, cli=cli)
242
+ except (FileNotFoundError, ValueError, ValidationError) as exc:
243
+ print(f"error: {exc}", file=sys.stderr)
244
+ return 2
245
+
246
+ # Fail closed: with auth disabled the API answers as a full-privilege system identity, so a
247
+ # non-loopback bind would publish admin access to the network. Loopback is the only no-auth posture.
248
+ if not settings.auth.enabled and not settings.api.is_loopback:
249
+ print(
250
+ "error: refusing to serve with [auth] enabled=false on non-loopback host "
251
+ f"{settings.api.host!r}; enable auth or bind 127.0.0.1",
252
+ file=sys.stderr,
253
+ )
254
+ return 2
255
+
256
+ if settings.store.backend is StoreBackend.SQLSERVER:
257
+ import importlib.util
258
+
259
+ if importlib.util.find_spec("aioodbc") is None:
260
+ print(
261
+ "error: the SQL Server backend needs the 'sqlserver' extra: "
262
+ "pip install 'messagefoundry[sqlserver]' (plus the Microsoft ODBC Driver 18)",
263
+ file=sys.stderr,
264
+ )
265
+ return 2
266
+
267
+ # Active environment is REQUIRED (ADR 0017): no silent default, so a missing env can never resolve
268
+ # another environment's values/secrets. Its security POSTURE (data_class / production) is derived
269
+ # for the built-in names dev/staging/prod and must be explicit for a custom name.
270
+ from messagefoundry.config.ai_policy import DataClass
271
+
272
+ if settings.ai.environment is None:
273
+ print(
274
+ "error: no active environment set — pass --env <name> or set [ai].environment. It selects "
275
+ "environments/<name>.toml and, with [ai].data_class/[ai].production, the instance's PHI "
276
+ "posture.",
277
+ file=sys.stderr,
278
+ )
279
+ return 2
280
+ try:
281
+ data_class, production = settings.ai.require_posture()
282
+ except ValueError as exc:
283
+ print(f"error: {exc}", file=sys.stderr)
284
+ return 2
285
+ env_name = settings.ai.environment
286
+
287
+ # PHI-at-rest posture (WP-5/WP-11d): refuse (require_encryption) or warn (PHI-carrying instance)
288
+ # when no key is configured. A DPAPI-protected key file (Windows) counts as a configured key; if
289
+ # it's set but unreadable here, open_store fails closed at startup with the DPAPI error.
290
+ if not (settings.store.encryption_key or settings.store.encryption_key_file):
291
+ if settings.store.require_encryption:
292
+ print(
293
+ "error: [store].require_encryption is set but no MEFOR_STORE_ENCRYPTION_KEY (or "
294
+ "[store].encryption_key_file) is configured; refusing to start (PHI would be stored "
295
+ "unencrypted at rest)",
296
+ file=sys.stderr,
297
+ )
298
+ return 2
299
+ # Fail closed on a PRODUCTION PHI instance: a live production store must never run keyless
300
+ # (the prod analogue of require_encryption — the deployment doesn't have to set the flag to
301
+ # get the protection). staging/dev keep the softer posture below.
302
+ if production and data_class is DataClass.PHI:
303
+ print(
304
+ f"error: no MEFOR_STORE_ENCRYPTION_KEY (or [store].encryption_key_file) set on a "
305
+ f"production PHI instance ({env_name!r}); refusing to start — PHI bodies and the "
306
+ "error/last_error/detail columns would be stored UNENCRYPTED at rest. Generate a key "
307
+ "with `messagefoundry gen-key` (or protect one to a file with `messagefoundry "
308
+ "protect-key`) and configure it before starting a production store.",
309
+ file=sys.stderr,
310
+ )
311
+ return 2
312
+ # Warn on a non-production PHI-carrying instance (e.g. staging). A synthetic instance stays
313
+ # quiet to avoid alarm fatigue (CLAUDE.md §9 / docs/PHI.md).
314
+ if data_class is DataClass.PHI:
315
+ print(
316
+ f"warning: no MEFOR_STORE_ENCRYPTION_KEY set in a PHI-carrying environment "
317
+ f"({env_name!r}) — PHI bodies and the error/last_error/detail columns are stored "
318
+ "UNENCRYPTED at rest (only volume encryption protects them). Generate a key with "
319
+ "`messagefoundry gen-key` (or protect one to a file with `messagefoundry "
320
+ "protect-key`), or set [store].require_encryption.",
321
+ file=sys.stderr,
322
+ )
323
+
324
+ # Open-egress posture (Q5b): on a PHI-carrying instance, outbound egress that is fully
325
+ # unrestricted — no [egress] allowlist AND deny_by_default off — lets a transform send PHI to any
326
+ # destination. On a PRODUCTION instance this fails closed (refuse to start, the prod analogue of
327
+ # the keyless-store refusal above); on a non-production PHI instance (e.g. staging) it is an
328
+ # advisory warning. A synthetic instance stays quiet. Lock it down with [egress].deny_by_default
329
+ # or per-transport [egress].allowed_* lists.
330
+ if data_class is DataClass.PHI:
331
+ eg = settings.egress
332
+ egress_open = not eg.deny_by_default and not (
333
+ eg.allowed_mllp
334
+ or eg.allowed_tcp
335
+ or eg.allowed_http
336
+ or eg.allowed_db
337
+ or eg.allowed_remote
338
+ or eg.allowed_file_dirs
339
+ )
340
+ if egress_open:
341
+ if production:
342
+ print(
343
+ f"error: outbound egress is UNRESTRICTED on a production PHI instance "
344
+ f"({env_name!r}); refusing to start — a transform could send PHI to any "
345
+ "destination. Set [egress].deny_by_default=true, or declare the permitted "
346
+ "destinations with per-transport [egress].allowed_* allowlists.",
347
+ file=sys.stderr,
348
+ )
349
+ return 2
350
+ print(
351
+ f"warning: outbound egress is UNRESTRICTED in a PHI-carrying environment "
352
+ f"({env_name!r}) — a transform may send to any destination. Set "
353
+ "[egress].deny_by_default or per-transport [egress].allowed_* allowlists to fail "
354
+ "closed.",
355
+ file=sys.stderr,
356
+ )
357
+
358
+ # Gate #1: DEBUG logging can surface PHI (full message bodies / raw field values) into the general
359
+ # log. Refuse it fail-closed on a production instance — real PHI flows there. A non-production
360
+ # instance may use DEBUG for diagnostics.
361
+ if production and settings.logging.level.upper() == "DEBUG":
362
+ print(
363
+ "error: DEBUG logging is refused on a production instance ([ai].production=true) — it can "
364
+ "surface PHI (full message bodies / raw field values) into logs. Use INFO or higher in "
365
+ "production (set [ai].production=false on a non-production instance for verbose "
366
+ "diagnostics).",
367
+ file=sys.stderr,
368
+ )
369
+ return 2
370
+
371
+ # Off-box log forwarding (sec-offbox-log): ship a copy of every record to a syslog/SIEM collector
372
+ # so evidence survives a host compromise. PHI redaction + control-char scrubbing apply to the
373
+ # forwarded stream exactly as to stdout (configure_logging installs the same filters on both).
374
+ log_forward = (
375
+ SyslogForward(
376
+ host=settings.logging.forward_host,
377
+ port=settings.logging.forward_port,
378
+ protocol=settings.logging.forward_protocol.value,
379
+ fmt=settings.logging.forward_format.value,
380
+ )
381
+ if settings.logging.forward_enabled and settings.logging.forward_host
382
+ else None
383
+ )
384
+ forwarder_live = configure_logging(
385
+ settings.logging.level, fmt=settings.logging.format.value, forward=log_forward
386
+ )
387
+ if forwarder_live and log_forward is not None:
388
+ # Only announce forwarding when configure_logging actually installed the handler — a TCP
389
+ # collector that is down at startup is skipped (it warns), so this must not contradict it.
390
+ logging.getLogger(__name__).info(
391
+ "off-box log forwarding enabled → %s:%d (%s, %s)",
392
+ log_forward.host,
393
+ log_forward.port,
394
+ log_forward.protocol,
395
+ log_forward.fmt,
396
+ )
397
+ # Anchor for the per-environment value dir: [environments].base_dir (or --project-root) when set,
398
+ # else the working directory (unchanged default). Resolved once here so the startup log shows the
399
+ # exact file env() values come from — the standalone-repo / NSSM footgun is a silently-wrong path.
400
+ from pathlib import Path
401
+
402
+ from messagefoundry.config.environments import resolve_values_base_dir
403
+
404
+ env_base = resolve_values_base_dir(settings.environments.base_dir, cwd=Path.cwd())
405
+ # Announce the active environment + posture so an operator can see which env() values resolve and
406
+ # the PHI posture in effect (the env is required — there is no silent default).
407
+ logging.getLogger(__name__).info(
408
+ "active environment: %s (data_class=%s, production=%s; env() values from %s + MEFOR_VALUE_*)",
409
+ env_name,
410
+ data_class.value,
411
+ production,
412
+ env_base / settings.environments.dir / f"{env_name}.toml",
413
+ )
414
+ # A non-loopback API bind puts bearer tokens + PHI on the wire. The exposed-gate (ADR 0002 §0):
415
+ # TLS configured → the first-class secure path (allow); no TLS but --allow-insecure-bind → a loud
416
+ # dev override (warn); otherwise → refuse fail-closed. The auth-disabled case is refused above
417
+ # regardless of this flag — serving full-privilege admin to the network is never one "I accept the
418
+ # risk" away.
419
+ if not settings.api.is_loopback:
420
+ if settings.api.tls_enabled:
421
+ # WP-13a: TLS terminates in-process, so tokens + PHI are encrypted on the wire and HSTS
422
+ # engages — no dev escape needed.
423
+ logging.getLogger(__name__).info(
424
+ "API on non-loopback host %r with in-process TLS (https/wss).", settings.api.host
425
+ )
426
+ elif settings.api.tls_terminated_upstream:
427
+ # WP-15: a reverse proxy terminates TLS in front; trust forwarded headers only from the
428
+ # declared proxies (the validator guarantees trusted_proxies is set here).
429
+ logging.getLogger(__name__).info(
430
+ "API on non-loopback host %r behind a TLS-terminating proxy; trusting forwarded "
431
+ "headers from %s.",
432
+ settings.api.host,
433
+ settings.api.trusted_proxies,
434
+ )
435
+ elif args.allow_insecure_bind:
436
+ print(
437
+ f"warning: API bound to non-loopback host {settings.api.host!r} with "
438
+ "--allow-insecure-bind and NO TLS; bearer tokens and PHI cross the network in "
439
+ "cleartext — configure [api].tls_cert_file (+ tls_key_file) for real remote access.",
440
+ file=sys.stderr,
441
+ )
442
+ else:
443
+ print(
444
+ "error: refusing to serve the API on non-loopback host "
445
+ f"{settings.api.host!r} without TLS; bearer tokens and PHI would cross the network in "
446
+ "cleartext. Configure [api].tls_cert_file for in-process TLS, set "
447
+ "[api].tls_terminated_upstream (+ trusted_proxies) if a proxy terminates TLS, or pass "
448
+ "--allow-insecure-bind to accept the cleartext risk on a trusted, firewalled network.",
449
+ file=sys.stderr,
450
+ )
451
+ return 2
452
+
453
+ # MFA-at-exposure posture (sec-mfa-on; WP-14, ASVS 6.3.3): an off-loopback bind serving local
454
+ # accounts puts admin authentication on the network, where a single password factor is far weaker.
455
+ # [auth].require_mfa adds the native TOTP second factor for the Administrator role; with it off the
456
+ # admin interface is single-factor over the wire. Mirror the keyless-store / open-egress posture:
457
+ # refuse on a production PHI instance (the prod fail-closed analogue), warn on a non-production PHI
458
+ # instance, stay quiet on a synthetic instance. Reached only for an otherwise-permitted exposed
459
+ # bind (the TLS gate above ran first); the loopback default never trips it. AD/Kerberos MFA is
460
+ # delegated to the directory, so require_mfa only gates LOCAL Administrator accounts (the bootstrap
461
+ # admin is one) — it is safe to enable even on an AD-only deployment.
462
+ if not settings.api.is_loopback and settings.auth.enabled and not settings.auth.require_mfa:
463
+ if data_class is DataClass.PHI:
464
+ if production:
465
+ print(
466
+ f"error: API bound to non-loopback host {settings.api.host!r} on a production PHI "
467
+ f"instance ({env_name!r}) with [auth].require_mfa off; refusing to start — the "
468
+ "Administrator role would authenticate with a single factor over the network. "
469
+ "Enable native TOTP MFA with [auth].require_mfa=true (WP-14) before exposing the "
470
+ "API (safe even on an AD-only deployment — it gates only local Administrator "
471
+ "accounts).",
472
+ file=sys.stderr,
473
+ )
474
+ return 2
475
+ print(
476
+ f"warning: API bound to non-loopback host {settings.api.host!r} in a PHI-carrying "
477
+ f"environment ({env_name!r}) with [auth].require_mfa off — the Administrator role is "
478
+ "single-factor over the network. Enable [auth].require_mfa=true (WP-14 native TOTP) "
479
+ "before exposure.",
480
+ file=sys.stderr,
481
+ )
482
+
483
+ # This instance's environment values (env() lookups in the graph): environments/<env>.toml +
484
+ # MEFOR_VALUE_* env, anchored at env_base (above). The active environment is the single selector
485
+ # [ai].environment. Passed as a provider (re-read on each reload, not just startup) so a promote
486
+ # picks up edited values without a service restart (review M-23) — the anchor is fixed per process.
487
+ import os
488
+
489
+ from messagefoundry.config.environments import load_environment_values
490
+
491
+ def env_values() -> dict[str, Any]:
492
+ return load_environment_values(
493
+ base_dir=env_base,
494
+ dir_name=settings.environments.dir,
495
+ environment=env_name,
496
+ environ=os.environ,
497
+ )
498
+
499
+ app = create_managed_app(
500
+ store_settings=settings.store,
501
+ config_dir=args.config,
502
+ config_reload_roots=settings.api.config_reload_roots,
503
+ inbound_bind_host=settings.inbound.bind_host,
504
+ allow_insecure_bind=args.allow_insecure_bind,
505
+ delivery_defaults=settings.delivery.retry_policy(),
506
+ ordering_default=settings.delivery.ordering,
507
+ internal_error_default=settings.delivery.internal_error,
508
+ buildup_default=settings.delivery.buildup_threshold(),
509
+ ack_after_default=settings.inbound.ack_after,
510
+ max_correlation_depth=settings.pipeline.max_correlation_depth,
511
+ env_values_provider=env_values,
512
+ auth_settings=settings.auth,
513
+ ai_settings=settings.ai,
514
+ alerts_settings=settings.alerts,
515
+ retention_settings=settings.retention,
516
+ cert_monitor_settings=settings.cert_monitor,
517
+ api_tls_cert_file=settings.api.tls_cert_file,
518
+ reference_settings=settings.reference,
519
+ egress_settings=settings.egress,
520
+ shadow_settings=settings.shadow,
521
+ cluster_settings=settings.cluster,
522
+ approvals_settings=settings.approvals,
523
+ expose_docs=settings.api.expose_docs,
524
+ ws_allowed_origins=settings.api.ws_allowed_origins,
525
+ )
526
+ # log_config=None: uvicorn's loggers propagate to the handler configure_logging installed,
527
+ # so everything shares one format/stream (and one log file under NSSM).
528
+ # WP-15: trust X-Forwarded-For/-Proto ONLY from the declared reverse proxies, so the audit /
529
+ # rate-limit source IP is the real client (not the proxy). Empty list = trust nothing (the secure
530
+ # default — the direct TCP peer is used), overriding uvicorn's loopback default.
531
+ run_kwargs: dict[str, Any] = {
532
+ "log_config": None,
533
+ "forwarded_allow_ips": settings.api.trusted_proxies,
534
+ # WP-L3-07 (ASVS 13.4.6): drop the `Server: uvicorn` banner so a response doesn't advertise the
535
+ # server implementation/version to an unauthenticated caller.
536
+ "server_header": False,
537
+ }
538
+ if settings.api.tls_enabled:
539
+ # WP-13a: terminate TLS in-process. Build the context now so a bad cert/key/passphrase fails
540
+ # fast (before uvicorn opens the socket); pass it via uvicorn's ssl_context_factory so the
541
+ # tls_min_version floor is enforced exactly.
542
+ from messagefoundry.api.tls import build_api_ssl_context
543
+
544
+ ctx = build_api_ssl_context(settings.api)
545
+ run_kwargs["ssl_context_factory"] = lambda config, default_factory: ctx
546
+ from messagefoundry.last_resort import install_excepthook
547
+ from messagefoundry.redaction import safe_exc
548
+
549
+ install_excepthook() # last-resort main-thread hook: an uncaught exception logs PHI-redacted (16.5.4)
550
+ try:
551
+ uvicorn.run(app, host=settings.api.host, port=settings.api.port, **run_kwargs)
552
+ except Exception as exc: # last-resort: log an abnormal server exit PHI-redacted, then re-raise
553
+ logging.getLogger(__name__).critical("server exited abnormally: %s", safe_exc(exc))
554
+ raise
555
+ return 0
556
+
557
+
558
+ def _validate(args: argparse.Namespace) -> int:
559
+ from messagefoundry.config.wiring import validate_config
560
+
561
+ diags = validate_config(args.config)
562
+ if args.json:
563
+ print(
564
+ json.dumps(
565
+ [{"message": d.message, "file": d.file, "severity": d.severity} for d in diags]
566
+ )
567
+ )
568
+ elif not diags:
569
+ print("OK: no problems found")
570
+ else:
571
+ for d in diags:
572
+ print(f"{d.severity}: {d.file or '-'}: {d.message}")
573
+ return 1 if diags else 0
574
+
575
+
576
+ def _graph(args: argparse.Namespace) -> int:
577
+ from messagefoundry.config.wiring import WiringError, display_settings, load_config
578
+
579
+ try:
580
+ reg = load_config(args.config)
581
+ except WiringError as exc:
582
+ return _emit_error(str(exc), as_json=args.json)
583
+ data = {
584
+ "inbound": [
585
+ {
586
+ "name": name,
587
+ "type": c.spec.type.value,
588
+ "settings": display_settings(c.spec.settings),
589
+ "router": c.router,
590
+ "ack_mode": c.ack_mode.value,
591
+ "strict": c.validation.strict,
592
+ "file": c.source_file,
593
+ "line": c.source_line,
594
+ }
595
+ for name, c in reg.inbound.items()
596
+ ],
597
+ "outbound": [
598
+ {
599
+ "name": name,
600
+ "type": c.spec.type.value,
601
+ "settings": display_settings(c.spec.settings),
602
+ "file": c.source_file,
603
+ "line": c.source_line,
604
+ }
605
+ for name, c in reg.outbound.items()
606
+ ],
607
+ # router→handler and handler→outbound edges are decided in code, not declared, so they're
608
+ # extracted best-effort: a handler/outbound name that appears as a string literal in the
609
+ # function counts as a reference. Accurate for names written literally; misses computed names.
610
+ "routers": [
611
+ {"name": n, **_fn_location(fn), "handlers": _referenced(fn, reg.handlers)}
612
+ for n, fn in sorted(reg.routers.items())
613
+ ],
614
+ "handlers": [
615
+ {"name": n, **_fn_location(fn), "sends": _referenced(fn, reg.outbound)}
616
+ for n, fn in sorted(reg.handlers.items())
617
+ ],
618
+ }
619
+ _print_json(data, compact=args.json)
620
+ return 0
621
+
622
+
623
+ def _fn_location(fn: object) -> dict[str, Any]:
624
+ """File + line where a Router/Handler function is defined (for IDE go-to-definition)."""
625
+ code = getattr(fn, "__code__", None)
626
+ if code is None:
627
+ return {"file": None, "line": None}
628
+ return {"file": code.co_filename, "line": code.co_firstlineno}
629
+
630
+
631
+ def _referenced(fn: object, names: dict[str, Any]) -> list[str]:
632
+ """Best-effort: which of ``names`` appear as string literals in ``fn`` (router/handler wiring)."""
633
+ consts = _string_consts(fn)
634
+ return sorted(name for name in names if name in consts)
635
+
636
+
637
+ def _string_consts(fn: object) -> set[str]:
638
+ """All string constants in a function, recursing into nested code objects (comprehensions, etc.)."""
639
+ import types
640
+
641
+ code = getattr(fn, "__code__", None)
642
+ if code is None:
643
+ return set()
644
+ found: set[str] = set()
645
+ stack = [code]
646
+ while stack:
647
+ current = stack.pop()
648
+ for const in current.co_consts:
649
+ if isinstance(const, str):
650
+ found.add(const)
651
+ elif isinstance(const, types.CodeType):
652
+ stack.append(const)
653
+ return found
654
+
655
+
656
+ def _redact_body(body: str) -> str:
657
+ """Replace a PHI-bearing message body with a length placeholder.
658
+
659
+ ``dryrun`` is a dev tool whose output is routinely piped to files/CI logs, so it must not emit
660
+ full bodies (raw + would-send payloads) by default; ``--show-phi`` opts in. See docs/PHI.md §7.
661
+ """
662
+ return f"<redacted {len(body)} chars; pass --show-phi>" if body else body
663
+
664
+
665
+ def _dryrun(args: argparse.Namespace) -> int:
666
+ from messagefoundry.config.wiring import WiringError, load_config
667
+ from messagefoundry.pipeline.dryrun import dry_run, read_messages
668
+
669
+ try:
670
+ reg = load_config(args.config)
671
+ except WiringError as exc:
672
+ return _emit_error(str(exc), as_json=args.json)
673
+ try:
674
+ messages = read_messages(args.messages)
675
+ except (FileNotFoundError, ValueError) as exc:
676
+ return _emit_error(str(exc), as_json=args.json)
677
+
678
+ show_phi: bool = args.show_phi
679
+ if not show_phi:
680
+ print(
681
+ "note: message bodies redacted; pass --show-phi to include raw/payloads (PHI)",
682
+ file=sys.stderr,
683
+ )
684
+
685
+ out: list[dict[str, Any]] = []
686
+ try:
687
+ for source, path, raw in messages:
688
+ result = dry_run(reg, raw, inbound=args.inbound)
689
+ out.append(
690
+ {
691
+ "source": source,
692
+ "path": path,
693
+ "inbound": result.inbound,
694
+ "disposition": result.disposition.value,
695
+ "message_type": result.message_type,
696
+ "control_id": result.control_id,
697
+ # The summary is PHI (MRN + patient name from PID-3/5), so gate it like raw/
698
+ # payloads — dryrun stdout is routinely piped to files/CI logs (review H-12).
699
+ # (The `error` text can also quote field values; that's tracked separately as
700
+ # low-8, gated holistically with the API's error exposure.)
701
+ "summary": result.summary if show_phi else None,
702
+ "handlers": result.handlers,
703
+ "deliveries": [
704
+ {"to": d.to, "payload": d.payload if show_phi else _redact_body(d.payload)}
705
+ for d in result.deliveries
706
+ ],
707
+ # Declared state writes (ADR 0005). The value can be PHI (e.g. an MRN→anon
708
+ # mapping), so gate it behind --show-phi exactly like a delivery payload.
709
+ "state_ops": [
710
+ {
711
+ "namespace": s.namespace,
712
+ "key": s.key if show_phi else _redact_body(str(s.key)),
713
+ "value": s.value if show_phi else _redact_body(str(s.value)),
714
+ }
715
+ for s in result.state_ops
716
+ ],
717
+ "error": result.error,
718
+ "raw": result.raw if show_phi else _redact_body(result.raw),
719
+ }
720
+ )
721
+ except (ValueError, KeyError) as exc: # e.g. ambiguous/unknown --inbound
722
+ return _emit_error(str(exc), as_json=args.json)
723
+ _print_json(out, compact=args.json)
724
+ return 0
725
+
726
+
727
+ def _hl7schema(args: argparse.Namespace) -> int:
728
+ from messagefoundry.hl7schema import hl7_schema
729
+
730
+ _print_json(hl7_schema(), compact=args.json)
731
+ return 0
732
+
733
+
734
+ def _init(args: argparse.Namespace) -> int:
735
+ """Scaffold a new config repo into ``args.dir`` (starter feed + environments + CI + a pinned engine)."""
736
+ from pathlib import Path
737
+
738
+ from messagefoundry.scaffold import scaffold
739
+
740
+ target = Path(args.dir)
741
+ try:
742
+ written = scaffold(target, force=args.force)
743
+ except (FileExistsError, NotADirectoryError, OSError) as exc:
744
+ return _emit_error(str(exc), as_json=args.json)
745
+
746
+ rels = [str(p.relative_to(target)) for p in written]
747
+ if args.json:
748
+ _print_json({"target": str(target), "written": rels}, compact=True)
749
+ return 0
750
+ if not written:
751
+ print(f"Nothing written — {target} already has every scaffold file.")
752
+ return 0
753
+ print(f"Scaffolded a config repo in {target} ({len(written)} files):")
754
+ for rel in rels:
755
+ print(f" {rel}")
756
+ print("\nNext steps:")
757
+ print(" pip install -r requirements.txt # the pinned engine (a read-only dependency)")
758
+ print(" messagefoundry check --config config --messages messages/sets")
759
+ print(" messagefoundry serve --config config --env dev")
760
+ return 0
761
+
762
+
763
+ def _gen_key(_args: argparse.Namespace) -> int:
764
+ from messagefoundry.store.crypto import generate_key
765
+
766
+ # Print only the key (so it can be piped); set it as MEFOR_STORE_ENCRYPTION_KEY, never the file.
767
+ print(generate_key())
768
+ return 0
769
+
770
+
771
+ def _protect_key(args: argparse.Namespace) -> int:
772
+ """DPAPI-protect the store encryption key to a file (WP-11d, ASVS 13.3.1; Windows-only).
773
+
774
+ Source: ``--generate`` mints a fresh key (also printed once to stderr so it can be backed up
775
+ offline — the machine-bound file is unrecoverable if the host is lost); otherwise the key is read
776
+ from ``MEFOR_STORE_ENCRYPTION_KEY``. The file is written with an owner-only DACL on top of DPAPI.
777
+ """
778
+ import base64
779
+ import os
780
+ from pathlib import Path
781
+
782
+ from messagefoundry.secrets_dpapi import DpapiError, DpapiUnavailable, protect_key_to_file
783
+ from messagefoundry.store.crypto import generate_key
784
+ from messagefoundry.store.store import _secure_file
785
+
786
+ if args.generate:
787
+ key_b64 = generate_key()
788
+ print(
789
+ "Generated a new store key. BACK IT UP OFFLINE — the protected file is bound to this "
790
+ f"machine and cannot be recovered if the host is lost:\n {key_b64}",
791
+ file=sys.stderr,
792
+ )
793
+ else:
794
+ key_b64 = os.environ.get("MEFOR_STORE_ENCRYPTION_KEY", "").strip()
795
+ if not key_b64:
796
+ print(
797
+ "error: no key to protect — set MEFOR_STORE_ENCRYPTION_KEY, or pass --generate to "
798
+ "mint a fresh one",
799
+ file=sys.stderr,
800
+ )
801
+ return 2
802
+
803
+ try:
804
+ raw = base64.b64decode(key_b64, validate=True)
805
+ except (ValueError, base64.binascii.Error): # type: ignore[attr-defined]
806
+ raw = b""
807
+ if len(raw) != 32:
808
+ print(
809
+ "error: the key must be base64 of 32 bytes (use `gen-key` or --generate)",
810
+ file=sys.stderr,
811
+ )
812
+ return 2
813
+
814
+ out = Path(args.out)
815
+ try:
816
+ protect_key_to_file(key_b64, out, machine_scope=not args.user)
817
+ except DpapiUnavailable as exc:
818
+ print(
819
+ f"error: {exc}. protect-key is Windows-only; on other platforms keep the key in "
820
+ "MEFOR_STORE_ENCRYPTION_KEY.",
821
+ file=sys.stderr,
822
+ )
823
+ return 2
824
+ except DpapiError as exc:
825
+ print(f"error: {exc}", file=sys.stderr)
826
+ return 2
827
+ _secure_file(out) # owner-only DACL — defence in depth atop the DPAPI binding
828
+ print(
829
+ f"Wrote DPAPI-protected key to {out}.\nNext: set [store].encryption_key_file = {str(out)!r} "
830
+ "and unset MEFOR_STORE_ENCRYPTION_KEY."
831
+ )
832
+ return 0
833
+
834
+
835
+ def _audit_verify(args: argparse.Namespace) -> int:
836
+ import asyncio
837
+ from pathlib import Path
838
+
839
+ from pydantic import ValidationError
840
+
841
+ from messagefoundry.config.settings import StoreBackend, load_settings
842
+ from messagefoundry.store.base import open_store
843
+
844
+ cli: dict[str, dict[str, object]] = {}
845
+ if args.db is not None:
846
+ cli.setdefault("store", {})["path"] = args.db
847
+ try:
848
+ settings = load_settings(config_path=args.service_config, cli=cli)
849
+ except (FileNotFoundError, ValueError, ValidationError) as exc:
850
+ print(f"error: {exc}", file=sys.stderr)
851
+ return 2
852
+
853
+ # A SQLite store would otherwise be CREATED on open: a compliance job pointed at a typo'd path
854
+ # would silently get a fresh empty DB and report "OK: verified 0 audit row(s)" forever (M-31).
855
+ if settings.store.backend == StoreBackend.SQLITE and not Path(settings.store.path).exists():
856
+ print(
857
+ f"error: no audit database at {settings.store.path} — refusing to create one and report "
858
+ f"a false 'verified 0 rows' (check --db / [store].path)",
859
+ file=sys.stderr,
860
+ )
861
+ return 2
862
+
863
+ async def run() -> tuple[bool, str | None]:
864
+ store = await open_store(settings.store)
865
+ try:
866
+ return await store.verify_audit_chain()
867
+ finally:
868
+ await store.close()
869
+
870
+ ok, message = asyncio.run(run())
871
+ print(("OK: " if ok else "FAIL: ") + (message or ""))
872
+ if ok and message and "verified 0 " in message:
873
+ # An empty log on a real DB is legitimate but worth flagging — it's indistinguishable at a
874
+ # glance from pointing at the wrong database (M-31).
875
+ print(
876
+ "warning: the audit log is empty — confirm this is the intended database.",
877
+ file=sys.stderr,
878
+ )
879
+ return 0 if ok else 1
880
+
881
+
882
+ def _rotate_key(args: argparse.Namespace) -> int:
883
+ """Re-encrypt every cipher-covered value under the active key (WP-5 key rotation, ASVS 11.2.2).
884
+
885
+ Run **offline** (engine stopped): set ``MEFOR_STORE_ENCRYPTION_KEY`` to the NEW active key and keep
886
+ the prior key(s) in ``MEFOR_STORE_ENCRYPTION_KEYS_RETIRED`` so existing rows can be decrypted, then
887
+ rotate. After it finishes, the retired key can be removed.
888
+ """
889
+ import asyncio
890
+ from pathlib import Path
891
+
892
+ from pydantic import ValidationError
893
+
894
+ from messagefoundry.config.settings import StoreBackend, load_settings
895
+ from messagefoundry.secrets_dpapi import DpapiError, DpapiUnavailable
896
+ from messagefoundry.store.base import open_store, resolve_active_key
897
+ from messagefoundry.store.crypto import CipherError
898
+ from messagefoundry.store.keyprovider import KeyProviderError
899
+
900
+ cli: dict[str, dict[str, object]] = {}
901
+ if args.db is not None:
902
+ cli.setdefault("store", {})["path"] = args.db
903
+ try:
904
+ settings = load_settings(config_path=args.service_config, cli=cli)
905
+ except (FileNotFoundError, ValueError, ValidationError) as exc:
906
+ print(f"error: {exc}", file=sys.stderr)
907
+ return 2
908
+
909
+ try:
910
+ active_key = resolve_active_key(settings.store)
911
+ except (DpapiError, DpapiUnavailable, KeyProviderError) as exc:
912
+ # KeyProviderError: a non-default [store].key_provider that is unknown or not-yet-built (an
913
+ # external HSM/KMS/Vault provider) — fail closed with a clean exit-2, not a traceback (ADR 0019).
914
+ print(f"error: cannot load the active key for rotation: {exc}", file=sys.stderr)
915
+ return 2
916
+ if not active_key:
917
+ print(
918
+ "error: rotate-key needs an active key — set MEFOR_STORE_ENCRYPTION_KEY (or "
919
+ "[store].encryption_key_file) to the new active key, with any prior key in "
920
+ "MEFOR_STORE_ENCRYPTION_KEYS_RETIRED; none is configured",
921
+ file=sys.stderr,
922
+ )
923
+ return 2
924
+ if settings.store.backend == StoreBackend.SQLITE and not Path(settings.store.path).exists():
925
+ print(
926
+ f"error: no store at {settings.store.path} (check --db / [store].path)", file=sys.stderr
927
+ )
928
+ return 2
929
+
930
+ async def run() -> int:
931
+ store = await open_store(settings.store)
932
+ try:
933
+ return await store.reencrypt_to_active()
934
+ finally:
935
+ await store.close()
936
+
937
+ try:
938
+ count = asyncio.run(run())
939
+ except CipherError as exc:
940
+ # A value couldn't be decrypted by any supplied key — the prior key is missing. Nothing was
941
+ # corrupted (a batch is all-or-nothing); supply the key and re-run.
942
+ print(f"error: rotation aborted — {exc}", file=sys.stderr)
943
+ return 1
944
+ except NotImplementedError as exc:
945
+ print(f"error: {exc}", file=sys.stderr)
946
+ return 2
947
+ print(f"OK: re-encrypted {count} value(s) under the active key")
948
+ return 0
949
+
950
+
951
+ def _ai_policy(args: argparse.Namespace) -> int:
952
+ """Print the effective AI-assistance policy resolved from local service settings.
953
+
954
+ Offline mirror of ``GET /ai/policy`` for the IDE's fallback path: it reads the same [ai] config
955
+ and runs the same clamp, but ``assist_permitted`` is always ``null`` because RBAC can't be
956
+ evaluated without the engine. Prints config only — never message data (PHI-safe)."""
957
+ from pydantic import ValidationError
958
+
959
+ from messagefoundry.config.ai_policy import resolve_effective_policy
960
+ from messagefoundry.config.settings import load_settings
961
+
962
+ try:
963
+ settings = load_settings(config_path=args.service_config)
964
+ except (FileNotFoundError, ValueError, ValidationError) as exc:
965
+ # Surface via stdout so the IDE's runJson bridge sees it (mirrors the wire-error shape).
966
+ print(json.dumps({"error": str(exc)}))
967
+ return 2
968
+
969
+ ai = settings.ai
970
+ data_class, prod = ai.derived_posture()
971
+ production = True if prod is None else prod # unresolved posture -> strictest ceiling
972
+ eff = resolve_effective_policy(mode=ai.mode, data_scope=ai.data_scope, production=production)
973
+ payload = {
974
+ "mode": eff.mode.value,
975
+ "data_scope": eff.data_scope.value,
976
+ "environment": ai.environment,
977
+ "data_class": data_class.value if data_class is not None else None,
978
+ "production": production,
979
+ "assist_permitted": None, # RBAC is not evaluable offline
980
+ "reason": eff.reason,
981
+ }
982
+ _print_json(payload, compact=args.json)
983
+ return 0
984
+
985
+
986
+ def _generate(args: argparse.Namespace) -> int:
987
+ from messagefoundry.generators import _core
988
+ from messagefoundry.generators import all_types # noqa: F401 (registers every built-in type)
989
+
990
+ if args.list:
991
+ listing = {code: _core.triggers_for(code) for code in _core.message_codes()}
992
+ if args.json:
993
+ _print_json(listing, compact=True)
994
+ else:
995
+ for code, trigs in listing.items():
996
+ print(f"{code}: {len(trigs)} trigger(s) ({', '.join(trigs)})")
997
+ return 0
998
+
999
+ if not args.type:
1000
+ print("error: --type is required (or use --list to see types)", file=sys.stderr)
1001
+ return 2
1002
+
1003
+ code = args.type.upper()
1004
+ triggers = [t.strip().upper() for t in args.triggers.split(",") if t.strip()] or None
1005
+ out = args.out or f"samples/messages/{code.lower()}"
1006
+ seed = args.seed or _core.DEFAULT_SEED
1007
+ try:
1008
+ result = _core.write_corpus(code, triggers=triggers, count=args.count, out=out, seed=seed)
1009
+ except KeyError as exc:
1010
+ print(f"error: {exc.args[0] if exc.args else exc}", file=sys.stderr)
1011
+ return 2
1012
+ except _core.GenerationError as exc:
1013
+ print(f"error: {exc}", file=sys.stderr)
1014
+ return 1
1015
+
1016
+ if args.json:
1017
+ _print_json(
1018
+ {
1019
+ "type": result.code,
1020
+ "out": result.out_dir,
1021
+ "total": result.total,
1022
+ "by_trigger": result.by_trigger,
1023
+ },
1024
+ compact=True,
1025
+ )
1026
+ else:
1027
+ for trig, n in result.by_trigger.items():
1028
+ print(f"{code}^{trig}: {n}")
1029
+ print(f"Generated {result.total} message(s) into {result.out_dir}/")
1030
+ return 0
1031
+
1032
+
1033
+ def _check(args: argparse.Namespace) -> int:
1034
+ """Commit/CI gate: exit 0 iff every *required* check passed (advisory failures only print)."""
1035
+ from messagefoundry.checks import run_checks
1036
+
1037
+ report = run_checks(args.config, messages_dir=args.messages, run_lint=not args.no_lint)
1038
+ if args.json:
1039
+ _print_json(report.to_json(), compact=True)
1040
+ else:
1041
+ for r in report.results:
1042
+ status = "skip" if r.skipped else ("ok" if r.ok else "FAIL")
1043
+ tag = "" if r.required else " (advisory)"
1044
+ line = f"{status:>4} {r.name}{tag}"
1045
+ print(f"{line}: {r.detail}" if r.detail else line)
1046
+ print("PASS" if report.ok else "FAIL: a required check failed")
1047
+ return 0 if report.ok else 1
1048
+
1049
+
1050
+ def _connection(args: argparse.Namespace) -> int:
1051
+ """Manage the data-authored ``connections.toml`` (ADR 0007): ``list`` to populate the VS Code
1052
+ editor, ``upsert``/``remove`` to save (a developer can also hand-edit the file). ``upsert``/
1053
+ ``remove`` validate the whole config dir (structure + connector/egress build-check) BEFORE
1054
+ persisting and roll back on failure. Offline: touches no network, starts no server."""
1055
+ import os
1056
+ from pathlib import Path
1057
+
1058
+ from pydantic import ValidationError
1059
+
1060
+ from messagefoundry.config import connections_edit
1061
+ from messagefoundry.config.environments import (
1062
+ load_environment_values,
1063
+ resolve_values_base_dir,
1064
+ )
1065
+ from messagefoundry.config.settings import load_settings
1066
+ from messagefoundry.config.wiring import WiringError, load_config
1067
+ from messagefoundry.pipeline.wiring_runner import build_check_registry
1068
+
1069
+ if args.action == "list":
1070
+ try:
1071
+ entries = connections_edit.list_connections(args.config)
1072
+ except (OSError, WiringError) as exc:
1073
+ return _emit_error(str(exc), as_json=args.json)
1074
+ _print_json(entries, compact=args.json)
1075
+ return 0
1076
+
1077
+ # upsert / remove: validate the candidate dir against this instance's [egress] allowlist + active
1078
+ # environment before persisting, so a GUI edit pointing at a non-allowlisted host fails at edit
1079
+ # time exactly as it would at reload.
1080
+ try:
1081
+ settings = load_settings(config_path=args.service_config)
1082
+ except (FileNotFoundError, ValueError, ValidationError) as exc:
1083
+ return _emit_error(str(exc), as_json=args.json)
1084
+ env_name = settings.ai.environment
1085
+ # Anchor environments/<env>.toml the same way serve does (honor [environments].base_dir), so a
1086
+ # GUI/CLI edit validates against the same env() values the running instance will resolve.
1087
+ env_values = (
1088
+ load_environment_values(
1089
+ base_dir=resolve_values_base_dir(settings.environments.base_dir, cwd=Path.cwd()),
1090
+ dir_name=settings.environments.dir,
1091
+ environment=env_name,
1092
+ environ=os.environ,
1093
+ )
1094
+ if env_name is not None
1095
+ else {}
1096
+ )
1097
+
1098
+ def validate(config_dir: Path) -> None:
1099
+ registry = load_config(config_dir)
1100
+ build_check_registry(
1101
+ registry,
1102
+ inbound_bind_host=settings.inbound.bind_host,
1103
+ env_values=env_values,
1104
+ egress=settings.egress,
1105
+ )
1106
+
1107
+ try:
1108
+ if args.action == "upsert":
1109
+ raw = args.data if args.data is not None else sys.stdin.read()
1110
+ obj = json.loads(raw)
1111
+ result = connections_edit.upsert_connection(args.config, obj, validate=validate)
1112
+ else: # remove
1113
+ if not args.name:
1114
+ return _emit_error("--name is required for `connection remove`", as_json=args.json)
1115
+ result = connections_edit.remove_connection(args.config, args.name, validate=validate)
1116
+ except json.JSONDecodeError as exc:
1117
+ return _emit_error(f"invalid connection JSON: {exc}", as_json=args.json)
1118
+ except (WiringError, OSError) as exc:
1119
+ return _emit_error(str(exc), as_json=args.json)
1120
+ _print_json(result, compact=args.json)
1121
+ return 0
1122
+
1123
+
1124
+ def _print_json(data: object, *, compact: bool) -> None:
1125
+ print(json.dumps(data) if compact else json.dumps(data, indent=2))
1126
+
1127
+
1128
+ def _emit_error(message: str, *, as_json: bool) -> int:
1129
+ if as_json:
1130
+ print(json.dumps({"error": message}))
1131
+ else:
1132
+ print(f"error: {message}")
1133
+ return 1
1134
+
1135
+
1136
+ _DISPATCH = {
1137
+ "serve": _serve,
1138
+ "init": _init,
1139
+ "validate": _validate,
1140
+ "graph": _graph,
1141
+ "dryrun": _dryrun,
1142
+ "check": _check,
1143
+ "connection": _connection,
1144
+ "generate": _generate,
1145
+ "hl7schema": _hl7schema,
1146
+ "gen-key": _gen_key,
1147
+ "protect-key": _protect_key,
1148
+ "audit-verify": _audit_verify,
1149
+ "rotate-key": _rotate_key,
1150
+ "ai-policy": _ai_policy,
1151
+ }
1152
+
1153
+
1154
+ if __name__ == "__main__":
1155
+ raise SystemExit(main())