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,26 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Opaque session tokens: the client holds the secret; the store keeps only its SHA-256.
4
+
5
+ Opaque server-side tokens (not JWT) are chosen so logout, expiry, and role changes take effect
6
+ immediately — the ``sessions`` row is the source of truth and can be revoked. Only the hash is
7
+ persisted, so reading the store never exposes a usable token.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import secrets
14
+
15
+ #: Bytes of entropy per token (urlsafe-base64 encoded to ~43 chars).
16
+ _TOKEN_BYTES = 32
17
+
18
+
19
+ def mint_token() -> str:
20
+ """Return a fresh, unguessable token to hand to the client (store only its :func:`hash_token`)."""
21
+ return secrets.token_urlsafe(_TOKEN_BYTES)
22
+
23
+
24
+ def hash_token(token: str) -> str:
25
+ """Hash a token for storage/lookup. Plain SHA-256 is sufficient: tokens are high-entropy secrets."""
26
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
@@ -0,0 +1,174 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """RFC 6238 TOTP — the local-account second factor (WP-14, ADR 0002 §3).
4
+
5
+ Pure standard-library crypto (``hmac`` / ``hashlib`` / ``base64`` / ``secrets``) so MFA adds **no new
6
+ dependency**: a software authenticator app (Google/Microsoft Authenticator, Authy, 1Password) and the
7
+ engine independently compute the same short code from a shared base32 secret plus the current
8
+ 30-second time step. Used only for **local** users — AD/Kerberos MFA is delegated to the directory
9
+ (see :class:`~messagefoundry.auth.service.AuthService`). This module is side-effect-free and unit-
10
+ tested against the RFC 6238 vectors; it never touches the store, the event loop, or config.
11
+
12
+ Security notes:
13
+
14
+ - TOTP is a *shared-secret* factor: it satisfies OWASP ASVS 5.0 **6.3.3** at L2 but is phishable and
15
+ replayable inside its step window. The L3 preference for phishing-resistant factors (WebAuthn /
16
+ FIDO2) is the **WP-14b** follow-on, not implemented here.
17
+ - Verification uses a constant-time compare (:func:`hmac.compare_digest`) over a fixed candidate set
18
+ and a small clock-skew window. The secret is high-entropy (160-bit); the caller stores it encrypted
19
+ at rest (the store cipher) and never logs it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import hmac
27
+ import secrets
28
+ import time
29
+ from urllib.parse import quote, urlencode
30
+
31
+ __all__ = [
32
+ "DEFAULT_DIGITS",
33
+ "DEFAULT_PERIOD",
34
+ "DEFAULT_WINDOW",
35
+ "generate_secret",
36
+ "totp",
37
+ "verify_totp",
38
+ "verify_totp_step",
39
+ "otpauth_uri",
40
+ "generate_recovery_codes",
41
+ ]
42
+
43
+ #: Authenticator-app conventions (what Google/Microsoft Authenticator assume by default).
44
+ DEFAULT_DIGITS = 6
45
+ DEFAULT_PERIOD = 30 # seconds per time step
46
+ #: ± steps of clock skew tolerated at verify time (one step each side ≈ 30 s).
47
+ DEFAULT_WINDOW = 1
48
+
49
+ _SECRET_BYTES = 20 # 160 bits — RFC 4226 recommends ≥ 128 bits, 160 for HMAC-SHA1
50
+
51
+ # Recovery codes: human-legible groups from an unambiguous alphabet (no 0/O/1/I/L confusion).
52
+ _RECOVERY_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
53
+ _RECOVERY_GROUP_LEN = 5
54
+ _RECOVERY_GROUPS = 3 # e.g. "K7QF2-9DMNA-3XZP4" → ~74 bits of entropy
55
+
56
+
57
+ def generate_secret() -> str:
58
+ """Return a fresh base32-encoded TOTP secret (no padding) to share with the authenticator app."""
59
+ return base64.b32encode(secrets.token_bytes(_SECRET_BYTES)).decode("ascii").rstrip("=")
60
+
61
+
62
+ def _decode_secret(secret: str) -> bytes:
63
+ """Decode a base32 secret tolerantly: ignore spaces/case and restore any stripped padding."""
64
+ cleaned = secret.strip().replace(" ", "").upper()
65
+ padding = "=" * (-len(cleaned) % 8)
66
+ return base64.b32decode(cleaned + padding, casefold=True)
67
+
68
+
69
+ def _hotp(key: bytes, counter: int, digits: int) -> str:
70
+ """RFC 4226 HOTP: HMAC-SHA1 over the 8-byte counter, dynamically truncated to ``digits`` decimals."""
71
+ mac = hmac.new(key, counter.to_bytes(8, "big"), hashlib.sha1).digest()
72
+ offset = mac[-1] & 0x0F
73
+ truncated = int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF
74
+ return str(truncated % (10**digits)).zfill(digits)
75
+
76
+
77
+ def totp(
78
+ secret: str,
79
+ *,
80
+ now: float | None = None,
81
+ period: int = DEFAULT_PERIOD,
82
+ digits: int = DEFAULT_DIGITS,
83
+ ) -> str:
84
+ """The current RFC 6238 TOTP code for ``secret`` (base32) at ``now`` (defaults to the wall clock)."""
85
+ moment = time.time() if now is None else now
86
+ counter = int(moment // period)
87
+ return _hotp(_decode_secret(secret), counter, digits)
88
+
89
+
90
+ def verify_totp_step(
91
+ secret: str,
92
+ code: str,
93
+ *,
94
+ now: float | None = None,
95
+ period: int = DEFAULT_PERIOD,
96
+ digits: int = DEFAULT_DIGITS,
97
+ window: int = DEFAULT_WINDOW,
98
+ ) -> int | None:
99
+ """Return the time-step counter ``code`` matches within ±``window`` steps of ``now``, or ``None``.
100
+
101
+ Unlike :func:`verify_totp` this returns *which* step matched, so the caller can enforce single-use
102
+ by rejecting a step that was already consumed — RFC 6238 codes are otherwise replayable inside
103
+ their ~30 s step window (ASVS 6.5.1). Constant-time compared over a fixed set of candidate steps
104
+ (no early break) so a match near the window edge can't be distinguished by timing; a non-numeric
105
+ or wrong-length ``code`` returns ``None``.
106
+ """
107
+ candidate = code.strip()
108
+ if len(candidate) != digits or not candidate.isdigit():
109
+ return None
110
+ moment = time.time() if now is None else now
111
+ key = _decode_secret(secret)
112
+ counter = int(moment // period)
113
+ matched: int | None = None
114
+ for step in range(counter - window, counter + window + 1):
115
+ if step < 0:
116
+ continue
117
+ if hmac.compare_digest(_hotp(key, step, digits), candidate):
118
+ matched = step
119
+ return matched
120
+
121
+
122
+ def verify_totp(
123
+ secret: str,
124
+ code: str,
125
+ *,
126
+ now: float | None = None,
127
+ period: int = DEFAULT_PERIOD,
128
+ digits: int = DEFAULT_DIGITS,
129
+ window: int = DEFAULT_WINDOW,
130
+ ) -> bool:
131
+ """True iff ``code`` matches the TOTP for ``secret`` within ±``window`` steps of ``now``.
132
+
133
+ A thin bool wrapper over :func:`verify_totp_step` (which also reports *which* step matched, for
134
+ single-use enforcement). Constant-time; rejects a non-numeric or wrong-length ``code`` outright.
135
+ """
136
+ return (
137
+ verify_totp_step(secret, code, now=now, period=period, digits=digits, window=window)
138
+ is not None
139
+ )
140
+
141
+
142
+ def otpauth_uri(
143
+ secret: str,
144
+ account: str,
145
+ *,
146
+ issuer: str = "MessageFoundry",
147
+ period: int = DEFAULT_PERIOD,
148
+ digits: int = DEFAULT_DIGITS,
149
+ ) -> str:
150
+ """Build the ``otpauth://totp/…`` URI an authenticator app scans (the UI renders it as a QR code)."""
151
+ # The "issuer:account" colon is the conventional literal label separator (keep it; encode the rest).
152
+ label = quote(f"{issuer}:{account}", safe=":")
153
+ params = urlencode(
154
+ {
155
+ "secret": secret,
156
+ "issuer": issuer,
157
+ "algorithm": "SHA1",
158
+ "digits": digits,
159
+ "period": period,
160
+ }
161
+ )
162
+ return f"otpauth://totp/{label}?{params}"
163
+
164
+
165
+ def generate_recovery_codes(count: int) -> list[str]:
166
+ """Return ``count`` fresh, single-use recovery codes in plaintext (the caller hashes before storing)."""
167
+ codes: list[str] = []
168
+ for _ in range(count):
169
+ groups = [
170
+ "".join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(_RECOVERY_GROUP_LEN))
171
+ for _ in range(_RECOVERY_GROUPS)
172
+ ]
173
+ codes.append("-".join(groups))
174
+ return codes
@@ -0,0 +1,174 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """The ``messagefoundry check`` commit/CI gate — one callable for the git hook and the IDE.
4
+
5
+ ``run_checks`` runs the project's checks against a config directory and reports a clear pass/fail,
6
+ reusing the in-process ``validate``/``dry_run`` paths (no re-shelling for the MessageFoundry-native
7
+ checks). Two checks are **required** (they can block a commit):
8
+
9
+ * ``validate`` — every config module loads and every ``inbound → router`` reference resolves.
10
+ * ``dryrun`` — *only when* a fixtures dir with ``*.hl7`` is given (searched recursively): each message
11
+ routes through its inbound's Router/Handler(s) without erroring. A fixture under a
12
+ ``<messages>/<inbound_name>/`` subdir is dry-run **only** against that feed (#11); a fixture not under
13
+ such a subdir runs against **every** inbound. Absent fixtures → skipped (never blocks).
14
+
15
+ ``ruff`` and ``mypy`` are **advisory**: run only when installed (``shutil.which``) and never block —
16
+ a non-developer author shouldn't be stopped by a lint nit. Exit-code policy lives in the CLI
17
+ (``__main__._check``): 0 iff no required check failed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import shutil
23
+ import subprocess
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ __all__ = ["CheckResult", "CheckReport", "run_checks"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CheckResult:
33
+ """The outcome of one check."""
34
+
35
+ name: str
36
+ ok: bool
37
+ required: bool
38
+ skipped: bool = False
39
+ detail: str = ""
40
+
41
+ @property
42
+ def blocking(self) -> bool:
43
+ """A required check that ran and failed — the only thing that fails the gate."""
44
+ return self.required and not self.ok and not self.skipped
45
+
46
+ def to_json(self) -> dict[str, Any]:
47
+ return {
48
+ "name": self.name,
49
+ "ok": self.ok,
50
+ "required": self.required,
51
+ "skipped": self.skipped,
52
+ "detail": self.detail,
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class CheckReport:
58
+ """All check outcomes for one run."""
59
+
60
+ results: list[CheckResult]
61
+
62
+ @property
63
+ def ok(self) -> bool:
64
+ """True unless a required check ran and failed."""
65
+ return not any(r.blocking for r in self.results)
66
+
67
+ def to_json(self) -> dict[str, Any]:
68
+ return {"ok": self.ok, "checks": [r.to_json() for r in self.results]}
69
+
70
+
71
+ def run_checks(
72
+ config_dir: str | Path,
73
+ *,
74
+ messages_dir: str | Path | None = None,
75
+ run_lint: bool = True,
76
+ ) -> CheckReport:
77
+ """Run the gate against ``config_dir``; ``messages_dir`` enables the dry-run check when it has
78
+ fixtures. Set ``run_lint=False`` to skip the advisory ruff/mypy pass."""
79
+ results = [_check_validate(config_dir), _check_dryrun(config_dir, messages_dir)]
80
+ if run_lint:
81
+ results.append(_run_tool("ruff", ["ruff", "check", str(config_dir)]))
82
+ results.append(_run_tool("mypy", ["mypy", str(config_dir)]))
83
+ return CheckReport(results)
84
+
85
+
86
+ def _check_validate(config_dir: str | Path) -> CheckResult:
87
+ from messagefoundry.config.wiring import validate_config
88
+
89
+ errors = [d for d in validate_config(config_dir) if d.severity == "error"]
90
+ if errors:
91
+ detail = f"{len(errors)} problem(s): " + "; ".join(
92
+ f"{d.file or '-'}: {d.message}" for d in errors[:5]
93
+ )
94
+ return CheckResult("validate", ok=False, required=True, detail=detail)
95
+ return CheckResult("validate", ok=True, required=True, detail="no problems")
96
+
97
+
98
+ def _check_dryrun(config_dir: str | Path, messages_dir: str | Path | None) -> CheckResult:
99
+ from messagefoundry.config.wiring import WiringError, load_config
100
+ from messagefoundry.pipeline.dryrun import dry_run, read_message_sets
101
+ from messagefoundry.store import MessageStatus
102
+
103
+ if messages_dir is None:
104
+ return CheckResult(
105
+ "dryrun", ok=True, required=False, skipped=True, detail="no fixtures dir"
106
+ )
107
+ mpath = Path(messages_dir)
108
+ if not mpath.exists():
109
+ # An explicitly-given path that doesn't exist is a mistake (renamed/typo'd fixtures dir),
110
+ # not "no fixtures" — fail the gate rather than silently skip the required check (low-20).
111
+ return CheckResult(
112
+ "dryrun", ok=False, required=True, detail=f"messages path not found: {mpath}"
113
+ )
114
+ if mpath.is_dir() and not any(mpath.glob("**/*.hl7")):
115
+ # A real dir with no fixtures (searched recursively, since per-feed fixtures live in
116
+ # <messages>/<inbound>/ subdirs) is the documented "absent fixtures -> skipped" case. A single
117
+ # file (any extension) falls through and is dry-run like the `dryrun` CLI accepts (low-20).
118
+ return CheckResult(
119
+ "dryrun", ok=True, required=False, skipped=True, detail=f"no *.hl7 fixtures in {mpath}"
120
+ )
121
+ try:
122
+ reg = load_config(config_dir)
123
+ except WiringError as exc:
124
+ # validate already reports (and blocks on) this — don't double-fail here.
125
+ return CheckResult(
126
+ "dryrun", ok=True, required=False, skipped=True, detail=f"config did not load: {exc}"
127
+ )
128
+ if not reg.inbound:
129
+ return CheckResult(
130
+ "dryrun", ok=True, required=False, skipped=True, detail="no inbound connections"
131
+ )
132
+
133
+ # Per-feed mapping (#11): a fixture under <messages>/<inbound_name>/ is dry-run only against that
134
+ # feed; an unmapped fixture (top-level, or under a non-feed subdir) cross-products every inbound.
135
+ inbound_names = list(reg.inbound)
136
+ message_sets = read_message_sets(mpath, inbound_names)
137
+ errors: list[str] = []
138
+ total = 0
139
+ pinned = 0
140
+ for label, _path, raw, target in message_sets:
141
+ targets = [target] if target is not None else inbound_names
142
+ if target is not None:
143
+ pinned += 1
144
+ for ic_name in targets:
145
+ total += 1
146
+ result = dry_run(reg, raw, inbound=ic_name)
147
+ if result.error or result.disposition is MessageStatus.ERROR:
148
+ errors.append(f"{label} @ {ic_name}: {result.error or result.disposition.value}")
149
+ if errors:
150
+ detail = f"{len(errors)}/{total} run(s) errored: " + "; ".join(errors[:5])
151
+ return CheckResult("dryrun", ok=False, required=True, detail=detail)
152
+ pin_note = f", {pinned} feed-pinned" if pinned else ""
153
+ detail = f"{total} run(s) clean across {len(message_sets)} message(s){pin_note}"
154
+ return CheckResult("dryrun", ok=True, required=True, detail=detail)
155
+
156
+
157
+ def _run_tool(name: str, cmd: list[str]) -> CheckResult:
158
+ """Advisory: run ``cmd`` only if its executable resolves; absent → skipped, never blocking."""
159
+ if shutil.which(cmd[0]) is None:
160
+ return CheckResult(name, ok=True, required=False, skipped=True, detail="not installed")
161
+ try:
162
+ # nosec: cmd[0] is a fixed tool name (ruff/mypy), no shell; args are repo paths (low-27).
163
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=120) # nosec B603 B607
164
+ except subprocess.TimeoutExpired:
165
+ # A wedged advisory tool must not block a commit forever — degrade to a skip (low-21).
166
+ return CheckResult(name, ok=True, required=False, skipped=True, detail="timed out (120s)")
167
+ except OSError as exc:
168
+ return CheckResult(
169
+ name, ok=True, required=False, skipped=True, detail=f"could not run: {exc}"
170
+ )
171
+ if proc.returncode == 0:
172
+ return CheckResult(name, ok=True, required=False, detail="passed")
173
+ detail = (proc.stdout or proc.stderr).strip().replace("\n", " ")[:300] or "failed"
174
+ return CheckResult(name, ok=False, required=False, detail=detail)
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Configuration: connector models + the code-first wiring layer.
4
+
5
+ Connectors are described by :class:`Source`/:class:`Destination` (type + free-form
6
+ ``settings``). The message graph is authored code-first in
7
+ :mod:`messagefoundry.config.wiring` — declare ``inbound``/``outbound`` Connections and
8
+ decorate ``@router``/``@handler`` scripts; a directory of such modules loads via
9
+ ``load_config`` into a :class:`~messagefoundry.config.wiring.Registry`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from messagefoundry.config.models import (
15
+ AckMode,
16
+ ConnectorType,
17
+ Destination,
18
+ RetryPolicy,
19
+ Source,
20
+ Validation,
21
+ )
22
+
23
+ __all__ = [
24
+ "Source",
25
+ "Destination",
26
+ "Validation",
27
+ "RetryPolicy",
28
+ "ConnectorType",
29
+ "AckMode",
30
+ ]
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """The active **environment** name, readable inside a transform (per-face logic).
4
+
5
+ A migrated feed sometimes branches on which deployment it is running as — e.g. stamp MSH-11 (Processing
6
+ Id) ``P`` in production vs ``T`` in test (Corepoint's ``If #Environment[ActiveFace]="Test"``). The
7
+ engine's single active-environment selector is ``[ai].environment`` / ``serve --env`` (a free-form
8
+ name, ADR 0017); this module makes that name readable synchronously inside a Router or Handler via
9
+ :func:`current_environment`.
10
+
11
+ It is shaped like :mod:`messagefoundry.config.state` / :mod:`messagefoundry.config.reference`: a
12
+ ContextVar the runner publishes around each router/transform run, read synchronously by the accessor.
13
+ Unlike ``env()`` — which is a *deferred reference* resolved only when a **connection** spec is built
14
+ (using it inside a handler is an always-truthy object, a bug) — :func:`current_environment` returns the
15
+ **string** name, usable in handler control flow.
16
+
17
+ **Re-run-safe.** The active environment is fixed for the life of the engine process (a crash-re-run
18
+ restarts in the same environment; a config reload swaps the graph, not the environment), so reading it
19
+ is deterministic — pure, and compatible with the staged-pipeline re-run invariant (ADR 0001 /
20
+ CLAUDE.md §2). Distinct from ``env()`` *values*, which may differ per environment but are resolved at
21
+ connection-build time, not in a transform.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Iterator
27
+ from contextlib import contextmanager
28
+ from contextvars import ContextVar
29
+ from typing import Any
30
+
31
+ __all__ = [
32
+ "current_environment",
33
+ "set_active",
34
+ "reset",
35
+ "activated",
36
+ ]
37
+
38
+ # Active environment name as a ContextVar (mirrors state._active): the runner publishes it around each
39
+ # router/transform run so a call-time current_environment() inside a Handler resolves, and a clean
40
+ # reset restores the prior value (no leak across runs / tests). None = "no active environment" (outside
41
+ # a run / a dry-run with no live engine).
42
+ _active: ContextVar[str | None] = ContextVar("mefor_active_environment", default=None)
43
+
44
+
45
+ def set_active(name: str | None) -> Any:
46
+ """Publish ``name`` as the active environment and return a reset token (pass it to :func:`reset`)."""
47
+ return _active.set(name)
48
+
49
+
50
+ def reset(token: Any) -> None:
51
+ """Restore the active environment to what it was before the matching :func:`set_active`."""
52
+ _active.reset(token)
53
+
54
+
55
+ @contextmanager
56
+ def activated(name: str | None) -> Iterator[None]:
57
+ """Make ``name`` the active environment for the duration of the ``with`` block, then restore.
58
+
59
+ The runner brackets each router/transform run with it, so :func:`current_environment` resolves at
60
+ call time and the prior value is always restored — no leak."""
61
+ token = _active.set(name)
62
+ try:
63
+ yield
64
+ finally:
65
+ _active.reset(token)
66
+
67
+
68
+ def current_environment() -> str | None:
69
+ """The active environment name (a free-form string, e.g. ``"prod"``/``"test"``), or ``None`` outside a run.
70
+
71
+ Read it inside a Router/Handler for per-face logic::
72
+
73
+ # Corepoint: If ActiveFace="Test" Then MSH-11.1 = "T"
74
+ if current_environment() in ("staging", "dev"):
75
+ msg.set("MSH-11.1", "T")
76
+
77
+ Returns ``None`` in a dry-run / outside a run (no live engine) — a transform should default
78
+ sensibly (treat None as the production / leave-as-is case). The value is a deployment constant, so
79
+ the read is pure + re-run-safe (unlike a per-message external call)."""
80
+ return _active.get()
@@ -0,0 +1,140 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """AI-assistance **policy model** — the authoritative two-axis policy and its clamping algorithm.
4
+
5
+ A central operator governs how much AI coding assistance is permitted across a spectrum from
6
+ **OFF** to **PHI-safe**, expressed as two independent axes bounded by a posture ceiling:
7
+
8
+ * **mode** (:class:`AiMode`) — *whether and how* assistance runs: ``off`` (none), ``byo``
9
+ (the user's own provider; this extension version's only working path), or the engine-brokered
10
+ ``managed_claude`` / ``managed_claude_baa`` (P1/P2 — not built here, but a policy may already
11
+ declare them so the IDE refuses rather than silently downgrading).
12
+ * **data_scope** (:class:`AiDataScope`) — *how sensitive* the context attached to a request may be,
13
+ ordered least→most sensitive: ``code_only`` < ``synthetic`` < ``deidentified`` < ``phi``.
14
+
15
+ The instance's **production** posture flag imposes a ceiling on ``data_scope`` so the same config
16
+ behaves conservatively on a non-production instance and only reaches ``phi`` on a production instance
17
+ under a BAA mode. ``mode`` itself is never clamped — a central ``off`` is honored everywhere. Posture
18
+ is **decoupled from the environment *name*** (ADR 0017): an instance is ``production`` (and/or
19
+ PHI-carrying, see :class:`DataClass`) regardless of whether it is literally named ``prod`` — so an
20
+ org can name instances ``poc``/``test``/… while choosing posture explicitly.
21
+
22
+ This module is **pure** (no I/O) and imports nothing from :mod:`messagefoundry.config.settings`
23
+ (the dependency is one-way: settings imports these enums, not the reverse, to avoid a cycle). It is
24
+ consumed by the ``GET /ai/policy`` API endpoint and the ``messagefoundry ai-policy`` CLI, which both
25
+ serialize :class:`EffectivePolicy` to the shared snake_case wire shape.
26
+
27
+ Note: the ``deidentified`` scope depends on a de-identification framework that **does not exist in
28
+ this repo** (roadmap only); :func:`resolve_effective_policy` therefore always clamps it down rather
29
+ than pretending it is reachable.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass
35
+ from enum import Enum
36
+
37
+
38
+ class AiMode(str, Enum):
39
+ """Whether and how AI assistance runs. The value is the wire/storage string."""
40
+
41
+ OFF = "off" # no assistance at all
42
+ BYO = "byo" # bring-your-own provider (the only working path in this MVP)
43
+ MANAGED_CLAUDE = "managed_claude" # engine-brokered Claude (P1; not built here)
44
+ MANAGED_CLAUDE_BAA = "managed_claude_baa" # brokered Claude under a BAA (P2; unlocks phi scope)
45
+
46
+
47
+ class AiDataScope(str, Enum):
48
+ """How sensitive the context attached to an AI request may be. The value is the wire string."""
49
+
50
+ CODE_ONLY = "code_only" # graph names + editor code only (PHI-safe by construction)
51
+ SYNTHETIC = "synthetic" # plus synthetic/sample HL7
52
+ DEIDENTIFIED = "deidentified" # de-identified PHI (needs the unbuilt de-id framework)
53
+ PHI = "phi" # real message bodies (only over a BAA + zero-retention provider)
54
+
55
+
56
+ class DataClass(str, Enum):
57
+ """Whether an instance handles real PHI, **independent of its (free-form) environment name**.
58
+
59
+ Drives the at-rest-encryption + open-egress startup advisories (a synthetic instance stays quiet;
60
+ a ``phi`` instance is warned). The AI data-scope ceiling keys off the separate ``production`` flag,
61
+ not this. Decoupling the data class from the environment name (ADR 0017) lets an org name instances
62
+ freely (``poc``/``test``/…) while choosing posture explicitly. The value is the wire string."""
63
+
64
+ SYNTHETIC = "synthetic" # synthetic/sample data only — relaxed at-rest/egress posture
65
+ PHI = "phi" # carries real PHI — encryption + egress advisories apply
66
+
67
+
68
+ #: Scope ordering, least→most sensitive. Used to take the *lower* of (requested, ceiling).
69
+ _SCOPE_ORDER: dict[AiDataScope, int] = {
70
+ AiDataScope.CODE_ONLY: 0,
71
+ AiDataScope.SYNTHETIC: 1,
72
+ AiDataScope.DEIDENTIFIED: 2,
73
+ AiDataScope.PHI: 3,
74
+ }
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class EffectivePolicy:
79
+ """The clamped, enforceable policy returned by :func:`resolve_effective_policy`.
80
+
81
+ ``reason`` is a human-readable, ``"; "``-joined note of every clamp applied (``None`` when the
82
+ requested policy passed through unchanged) — surfaced in the API/CLI so an operator can see *why*
83
+ the effective scope differs from what was configured. The environment *name* and the posture
84
+ (``data_class``/``production``) are carried by the caller's wire model, not here.
85
+ """
86
+
87
+ mode: AiMode
88
+ data_scope: AiDataScope
89
+ reason: str | None
90
+
91
+
92
+ def resolve_effective_policy(
93
+ *, mode: AiMode, data_scope: AiDataScope, production: bool
94
+ ) -> EffectivePolicy:
95
+ """Clamp a requested (mode, data_scope) to the enforceable effective policy for this instance.
96
+
97
+ The algorithm (in order): apply the production-posture data-scope ceiling; defensively block
98
+ ``phi`` unless the mode is BAA-managed; block ``deidentified`` (the de-id framework is unbuilt);
99
+ and normalize scope to ``code_only`` when the mode is ``off``. ``mode`` is never clamped — a
100
+ central ``off``/managed choice is honored regardless of posture. ``production`` is the instance's
101
+ posture flag (decoupled from the environment *name*, ADR 0017), not whether it is literally named
102
+ ``prod``.
103
+ """
104
+ reasons: list[str] = []
105
+
106
+ # 1. Posture data-scope ceiling. A non-production instance never exceeds synthetic; a production
107
+ # instance reaches phi only under a BAA-managed mode, otherwise it floors at code_only.
108
+ if production:
109
+ ceiling = AiDataScope.PHI if mode is AiMode.MANAGED_CLAUDE_BAA else AiDataScope.CODE_ONLY
110
+ else:
111
+ ceiling = AiDataScope.SYNTHETIC
112
+
113
+ eff = data_scope
114
+ if _SCOPE_ORDER[ceiling] < _SCOPE_ORDER[eff]:
115
+ eff = ceiling
116
+ tier = "production" if production else "non-production"
117
+ reasons.append(f"data scope capped to {eff.value} by a {tier} instance")
118
+
119
+ # 2. phi hard rule (defensive): phi requires a BAA-managed mode. The ceiling already enforces
120
+ # this on a production instance; this guards any path that reached phi outside it.
121
+ if eff is AiDataScope.PHI and mode is not AiMode.MANAGED_CLAUDE_BAA:
122
+ eff = AiDataScope.CODE_ONLY
123
+ reasons.append("phi scope requires managed_claude_baa mode; fell back to code_only")
124
+
125
+ # 3. deidentified hard rule: the de-id framework is roadmap only, so this scope is never live.
126
+ if eff is AiDataScope.DEIDENTIFIED:
127
+ eff = AiDataScope.CODE_ONLY
128
+ reasons.append(
129
+ "deidentified scope requires the (unbuilt) de-id framework; fell back to code_only"
130
+ )
131
+
132
+ # 4. off normalization: when AI is off the scope is irrelevant — pin it to the safe floor.
133
+ if mode is AiMode.OFF:
134
+ eff = AiDataScope.CODE_ONLY
135
+
136
+ return EffectivePolicy(
137
+ mode=mode,
138
+ data_scope=eff,
139
+ reason="; ".join(reasons) if reasons else None,
140
+ )