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,240 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Connector configuration models — the transport-level building blocks.
4
+
5
+ A :class:`Source`/:class:`Destination` is a transport endpoint (type + free-form
6
+ ``settings`` validated by the connector plugin) plus delivery behaviour. The code-first
7
+ wiring layer (:mod:`messagefoundry.config.wiring`) builds these from a connection's
8
+ ``ConnectionSpec`` to resolve connectors via the registry; routing/filtering/transforming
9
+ is done in code-first Router/Handler scripts, not here.
10
+
11
+ These models are intentionally transport-agnostic: adding a new transport never requires
12
+ touching this file.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping
18
+ from enum import Enum
19
+ from typing import Any
20
+
21
+ from pydantic import BaseModel, ConfigDict, Field
22
+
23
+
24
+ class ConnectorType(str, Enum):
25
+ """Built-in transport connectors. Plugins may register additional values."""
26
+
27
+ MLLP = "mllp"
28
+ TCP = "tcp" # raw TCP with configurable delimiter framing (X12 over TCP, ADR 0003)
29
+ FILE = "file"
30
+ REST = "rest" # generic HTTP destination (ADR 0003)
31
+ DATABASE = "database" # SQL destination — SQL Server first (ADR 0003)
32
+ SOAP = "soap" # SOAP / web-service destination over HTTP (ADR 0003)
33
+ REMOTEFILE = "remotefile" # remote-file transport — SFTP / FTP / FTPS (source + destination)
34
+ TIMER = "timer" # clock-driven source — emits a configured body on a schedule (source only, ADR 0011)
35
+ X12 = "x12" # raw-TCP X12 EDI — ISA/IEA-framed (no transport sentinel), source + destination (ADR 0012)
36
+ LOOPBACK = "loopback" # inert inbound — messages arrive only via ingress_handoff (re-ingress, ADR 0013)
37
+ # DATABASE also has an inbound poll source (DatabasePoll, ADR 0003 §3 + 0004); REMOTEFILE is both
38
+ # source and destination. TIMER is source-only (it generates, never delivers). REST/SOAP sources
39
+ # (HTTP listeners) and TCP/FHIR are future.
40
+
41
+
42
+ class ContentType(str, Enum):
43
+ """The payload format of an inbound connection (ADR 0004 — payload-agnostic ingress).
44
+
45
+ ``HL7V2`` (the default, so every existing config is unchanged) gets the full HL7 peek / optional
46
+ strict-validate / HL7-ACK ingress path and is routed to Routers/Handlers as a mutable
47
+ :class:`~messagefoundry.parsing.message.Message`. Any other value **skips** HL7 parsing/validation/
48
+ ACK: the decoded body is committed verbatim and routed as a
49
+ :class:`~messagefoundry.parsing.message.RawMessage` (``.raw`` / ``.text`` / ``.json()``)."""
50
+
51
+ HL7V2 = "hl7v2"
52
+ JSON = "json"
53
+ XML = "xml"
54
+ TEXT = "text"
55
+ X12 = "x12" # ASC X12 EDI, relayed opaquely (no structured parse) — routes as RawMessage
56
+
57
+
58
+ class AckMode(str, Enum):
59
+ """HL7 acknowledgement mode for MLLP/TCP sources."""
60
+
61
+ ORIGINAL = "original" # MSA generated from the inbound message
62
+ ENHANCED = "enhanced" # application + commit acks (MSH-15/16)
63
+ NONE = "none"
64
+
65
+
66
+ class AckAfter(str, Enum):
67
+ """**When** an inbound connection sends its ACK, in the staged pipeline (ADR 0001).
68
+
69
+ ``INGEST`` (default): ACK-on-receipt — the ACK is sent as soon as the raw message is durably
70
+ committed to the ingress stage, before routing/transform/delivery run (decoupling intake from a
71
+ slow downstream). ``DELIVERED``: defer the ACK until outbound delivery succeeds (end-to-end
72
+ confirmation). ``DELIVERED`` is **not yet implemented** in Step A — wiring it raises
73
+ ``WiringError`` (it needs the listener to hold/replay the ACK from the delivery worker); the enum
74
+ + threading exist so the follow-up is a small change. Distinct from
75
+ :class:`AckMode` (which selects the ACK *code* family, not its timing)."""
76
+
77
+ INGEST = "ingest"
78
+ DELIVERED = "delivered"
79
+
80
+
81
+ class OrderingMode(str, Enum):
82
+ """How an outbound connection's queue is drained.
83
+
84
+ ``FIFO`` (default): strict in-order per outbound connection — the worker delivers the oldest
85
+ enqueued message and **blocks the head on failure** (a stuck message holds the lane until it
86
+ succeeds, dead-letters, or is purged) so HL7 dependencies (ADT→ORM→ORU) are never reordered.
87
+ ``UNORDERED``: the legacy throughput mode — claim a batch and rotate past a failing message
88
+ (a failure backs off and later messages proceed), trading order for parallelism within the
89
+ connection.
90
+ """
91
+
92
+ FIFO = "fifo"
93
+ UNORDERED = "unordered"
94
+
95
+
96
+ class InternalErrorPolicy(str, Enum):
97
+ """What an outbound delivery worker does when an **internal/code error** (a non-``DeliveryError``
98
+ exception escaping a connector's ``send`` — our bug, not the partner's) hits a message.
99
+
100
+ ``CONTINUE`` (default): error-and-continue — dead-letter the offending row (replayable) and move
101
+ on, so a code bug can't wedge the whole lane. ``STOP``: halt the connection's delivery worker and
102
+ raise a ``connection_stopped`` alert, leaving the message queued for replay — for operators who
103
+ would rather a lane freeze (and page someone) than auto-dead-letter on an unexpected error.
104
+ Partner rejections (``NegativeAckError``) and transport failures are unaffected — this governs
105
+ only the engine-internal-error case.
106
+ """
107
+
108
+ CONTINUE = "continue"
109
+ STOP = "stop"
110
+
111
+
112
+ class Source(BaseModel):
113
+ """An inbound connector endpoint."""
114
+
115
+ type: ConnectorType
116
+ settings: dict[str, Any] = Field(default_factory=dict)
117
+ ack_mode: AckMode = AckMode.ORIGINAL
118
+
119
+
120
+ class RetryPolicy(BaseModel):
121
+ """Outbound delivery retry/backoff. ``max_attempts`` is the number of delivery attempts before a
122
+ failure dead-letters; ``None`` (the default) means **retry forever** — the conservative posture
123
+ for transient failures (transport errors, ``AE`` NAKs), so nothing is silently lost. Under FIFO a
124
+ forever-retrying head blocks its lane until it succeeds or an operator purges it (a permanent
125
+ ``AR`` reject is the exception — it fails fast, see
126
+ :class:`~messagefoundry.transports.base.NegativeAckError`). Set a finite ``max_attempts`` to opt
127
+ back into retry-then-dead-letter."""
128
+
129
+ max_attempts: int | None = None
130
+ backoff_seconds: float = 5.0
131
+ backoff_multiplier: float = 2.0
132
+ max_backoff_seconds: float = 300.0
133
+
134
+
135
+ class BuildupThreshold(BaseModel):
136
+ """When to raise a ``queue_buildup`` alert for an outbound lane (its backlog is not draining — a
137
+ retry-forever head blocking the lane is the classic cause).
138
+
139
+ A lane crosses the threshold when its **pending depth** reaches ``max_depth`` *or* its **oldest
140
+ pending message's age** reaches ``max_oldest_seconds``. ``None`` disables that dimension; both
141
+ ``None`` disables buildup alerting for the connection. The age dimension defaults on (a head stuck
142
+ over five minutes is a problem in any environment); depth is opt-in (a healthy ceiling depends on
143
+ the connection's throughput, so there's no safe universal default)."""
144
+
145
+ max_depth: int | None = None
146
+ max_oldest_seconds: float | None = 300.0
147
+
148
+
149
+ class SignatureAlgorithm(str, Enum):
150
+ """JWS algorithm for opt-in per-connection outbound message signing (ASVS 4.1.5, ADR 0018).
151
+
152
+ All three are produced with the **core** ``cryptography`` dependency (no new package). ``RS256``
153
+ (RSASSA-PKCS1-v1_5) is **deterministic** — the same key + payload always yields the same signature;
154
+ ``PS256`` (RSASSA-PSS) and ``ES256`` (ECDSA P-256) are **randomized** (a fresh signature per call,
155
+ like the WS-Security nonce, ADR 0015) — all SHA-256."""
156
+
157
+ RS256 = "RS256" # RSASSA-PKCS1-v1_5 + SHA-256 (RSA key; deterministic)
158
+ PS256 = "PS256" # RSASSA-PSS + SHA-256 (RSA key; randomized)
159
+ ES256 = "ES256" # ECDSA P-256 (secp256r1) + SHA-256 (EC key; randomized)
160
+
161
+
162
+ class OutboundSigning(BaseModel):
163
+ """Opt-in per-connection message signing for a **REST/SOAP outbound** (ASVS 4.1.5, ADR 0018).
164
+
165
+ OFF unless configured. When set, the connector mints a **detached JWS** (RFC 7515 Appendix F) over
166
+ the exact outbound payload bytes in ``send()`` — past the queue boundary, like the WS-Security
167
+ timestamp/nonce (ADR 0015), so a retry re-mints it and routers/transforms stay pure — and carries it
168
+ in the ``header_name`` HTTP header. The receiver verifies it against the matching **public** key,
169
+ out-of-band per partner contract (the signing code's :func:`verify` counterpart does this). RSA
170
+ (``RS256``/``PS256``) or ECDSA (``ES256``) via the core ``cryptography`` library — no new dependency.
171
+
172
+ ``private_key`` is the signing key as **inline PEM** *(use* :func:`~messagefoundry.config.wiring.env`
173
+ *for the secret)* **or a path to a PEM file** (the file, like a TLS key, is protected by OS perms).
174
+ Put every secret — an inline key, or ``private_key_password`` for an encrypted key — in ``env()`` so
175
+ it is never stored in config. The key never leaves the box; only the public-verifiable signature does.
176
+
177
+ Authored code-first with :func:`~messagefoundry.transports.signing.with_signing` over a ``Rest()`` /
178
+ ``Soap()`` spec, or assembled from flat ``sign_*`` connector settings via :meth:`from_settings`."""
179
+
180
+ model_config = ConfigDict(extra="forbid")
181
+
182
+ enabled: bool = True
183
+ algorithm: SignatureAlgorithm = SignatureAlgorithm.RS256
184
+ private_key: str # inline PEM (use env() for the secret) or a path to a PEM private-key file
185
+ private_key_password: str | None = None # passphrase for an encrypted key (secret — use env())
186
+ key_id: str | None = None # JWS 'kid' so the receiver can select the verifying key
187
+ header_name: str = "X-JWS-Signature" # the HTTP header that carries the detached JWS
188
+
189
+ @classmethod
190
+ def from_settings(cls, settings: Mapping[str, Any]) -> OutboundSigning | None:
191
+ """Build from flat ``sign_*`` connector settings, or ``None`` when signing isn't configured.
192
+
193
+ Signing is OFF (``None``) unless ``sign_private_key`` is present, so every existing outbound is
194
+ unchanged. The flat keys (vs a nested table) keep each value a top-level setting that ``env()``
195
+ resolution and ``connections.toml`` decoding already handle. Recognized keys: ``sign_enabled``,
196
+ ``sign_algorithm``, ``sign_private_key``, ``sign_private_key_password``, ``sign_key_id``,
197
+ ``sign_header``. An unknown/typo'd field raises (``extra='forbid'``)."""
198
+ key = settings.get("sign_private_key")
199
+ if not key:
200
+ return None
201
+ data: dict[str, Any] = {"private_key": key}
202
+ if "sign_enabled" in settings:
203
+ data["enabled"] = settings["sign_enabled"]
204
+ if settings.get("sign_algorithm"):
205
+ data["algorithm"] = settings["sign_algorithm"]
206
+ if settings.get("sign_private_key_password"):
207
+ data["private_key_password"] = settings["sign_private_key_password"]
208
+ if settings.get("sign_key_id"):
209
+ data["key_id"] = settings["sign_key_id"]
210
+ if settings.get("sign_header"):
211
+ data["header_name"] = settings["sign_header"]
212
+ return cls.model_validate(data)
213
+
214
+
215
+ class Destination(BaseModel):
216
+ """An outbound connector endpoint. Each outbound connection queues independently
217
+ so a slow/failed one never blocks the others."""
218
+
219
+ name: str
220
+ type: ConnectorType
221
+ settings: dict[str, Any] = Field(default_factory=dict)
222
+ retry: RetryPolicy = Field(default_factory=RetryPolicy)
223
+ # Shadow / parallel-run mode (#15): when True the delivery worker runs the full pipeline + count-
224
+ # and-log but SUPPRESSES the real egress (no bytes/SQL leave the box) and finalizes the message
225
+ # PROCESSED, so a shadow instance can process real traffic without double-delivering to live
226
+ # partners. A deployment-wide [shadow].simulate_all_egress forces this on for every outbound.
227
+ simulate: bool = False
228
+ # ASVS 4.1.5 (ADR 0018): opt-in per-connection detached-JWS signing for REST/SOAP outbound. None
229
+ # (the default) = OFF — every existing outbound is byte-identical. Assembled from the env-resolved
230
+ # sign_* settings by the runner's _dest_config; the connector mints the signature in send().
231
+ sign: OutboundSigning | None = None
232
+
233
+
234
+ class Validation(BaseModel):
235
+ """Parse/validate behaviour. Tolerant by default — non-conformant messages
236
+ still route; ``strict`` runs full hl7apy profile validation and NACKs on failure."""
237
+
238
+ hl7_version: str | None = None # e.g. "2.5.1"; None = infer from MSH-12
239
+ strict: bool = False
240
+ profile: str | None = None # path to a conformance profile, optional
@@ -0,0 +1,158 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Synchronous **read side** for reference sets (external-data enrichment, ADR 0006 Tier 1).
4
+
5
+ A **reference set** is a managed, versioned, read-only lookup snapshot the engine *materializes* from
6
+ an external source **off the message path** (a provider directory, a DB-backed translation table — the
7
+ Corepoint Data Point / DB Association pattern). A Handler/Router reads it **purely** at call time via
8
+ ``reference("name").get(key)`` — a twin of :func:`~messagefoundry.config.wiring.code_set`, but the data
9
+ comes from a periodic sync (the :class:`~messagefoundry.pipeline.reference_sync.ReferenceSyncRunner`),
10
+ not a file in the bundle.
11
+
12
+ Because the snapshot lives in the **store** (encrypted at rest, it may carry PHI), this is shaped like
13
+ :mod:`messagefoundry.config.state`, not :mod:`messagefoundry.config.code_sets`: the store owns the
14
+ read-through cache and exposes it as a :data:`ReferenceView`; the runner publishes
15
+ ``store.reference_view()`` as the active view around each router/transform run; :func:`reference`
16
+ resolves against it synchronously. This config-layer module owns only the active-view holder + the
17
+ accessor + the publish helpers, and does **not** import the store (one-way dependency, CLAUDE.md §4).
18
+
19
+ **Re-run-safe (ADR 0006 / ADR 0001).** A read carries no side effect, so re-run-identity reduces to
20
+ "does the snapshot change between a run and a crash-re-run?" The sync writes each new snapshot with a
21
+ build-new-then-atomic-flip and the read view swaps wholesale only after a sync commits — so the only
22
+ non-determinism is a flip landing in the narrow window between a run and its re-run, the **same**
23
+ already-accepted caveat as a code-set hot-reload (see :mod:`messagefoundry.config.code_sets`).
24
+
25
+ **Call-time only.** Unlike ``code_set()`` (file-loaded at config load, capturable at a module's top
26
+ level), a reference snapshot exists only once the store is open and synced — so call :func:`reference`
27
+ **inside a Handler/Router at run time**, like :func:`~messagefoundry.config.state.state_get`, not at
28
+ module import.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Iterator, Mapping
34
+ from contextlib import contextmanager
35
+ from contextvars import ContextVar
36
+ from typing import Any
37
+
38
+ __all__ = [
39
+ "ReferenceSet",
40
+ "ReferenceView",
41
+ "ReferenceError",
42
+ "reference",
43
+ "set_active",
44
+ "reset",
45
+ "activated",
46
+ ]
47
+
48
+ #: The engine-published read view: a read-only mapping ``{refset_name: {key: value}}`` of the **active**
49
+ #: snapshots. The store builds it (decrypting at load), so the config layer needs no store import.
50
+ ReferenceView = Mapping[str, Mapping[str, Any]]
51
+
52
+
53
+ class ReferenceError(ValueError):
54
+ """A reference set was read but isn't available (no active view, or no such synced set).
55
+
56
+ A subclass of :class:`ValueError`. Raised at **handler run time** (a reference is read inside a
57
+ transform), so it surfaces as that message's ``ERROR`` disposition / dead-letter — fail loud, never
58
+ a silent empty table — not as a load-time ``WiringError``."""
59
+
60
+
61
+ class ReferenceSet(Mapping[str, Any]):
62
+ """A frozen, read-only reference snapshot: ``name`` + an immutable ``key → value`` mapping.
63
+
64
+ Behaves like a read-only ``dict`` (``rs[key]``, ``rs.get(key, default)``, ``key in rs``,
65
+ ``len(rs)``, iteration) but rejects mutation — one snapshot is shared across every transform, so a
66
+ Handler must never edit it. ``rs[missing]`` raises a :class:`KeyError` naming the set;
67
+ ``rs.get(missing, default)`` returns the default (sparse external data). Wraps the snapshot mapping
68
+ **by reference** (no copy) — the view is already read-only and the sync swaps it wholesale, so a
69
+ large table isn't copied on every lookup."""
70
+
71
+ __slots__ = ("_name", "_data")
72
+
73
+ def __init__(self, name: str, data: Mapping[str, Any]) -> None:
74
+ self._name = name
75
+ self._data = data
76
+
77
+ @property
78
+ def name(self) -> str:
79
+ return self._name
80
+
81
+ def __getitem__(self, key: str) -> Any:
82
+ try:
83
+ return self._data[key]
84
+ except KeyError:
85
+ raise KeyError(f"key {key!r} not in reference set {self._name!r}") from None
86
+
87
+ def __contains__(self, key: object) -> bool:
88
+ return key in self._data
89
+
90
+ def __iter__(self) -> Iterator[str]:
91
+ return iter(self._data)
92
+
93
+ def __len__(self) -> int:
94
+ return len(self._data)
95
+
96
+ def __repr__(self) -> str:
97
+ return f"ReferenceSet(name={self._name!r}, entries={len(self._data)})"
98
+
99
+
100
+ # Active view as a ContextVar (mirrors state._active): the runner re-publishes the live snapshot view
101
+ # around each router/transform run so a call-time reference(...) inside a Handler resolves, and a clean
102
+ # reset restores the prior view (no leak across runs / overlapping reloads). Defaults to None = "no
103
+ # active view" so reference() can distinguish "not running" from "set not synced".
104
+ _active: ContextVar[ReferenceView | None] = ContextVar("mefor_active_reference", default=None)
105
+
106
+
107
+ def set_active(view: ReferenceView | None) -> Any:
108
+ """Publish ``view`` as the active reference view and return a reset token (pass it to :func:`reset`).
109
+
110
+ For callers that can't bracket the active span with a ``with`` (e.g. an async worker publishing
111
+ around a single transform call). Prefer :func:`activated` where a ``with`` block fits."""
112
+ return _active.set(view)
113
+
114
+
115
+ def reset(token: Any) -> None:
116
+ """Restore the active reference view to what it was before the matching :func:`set_active`."""
117
+ _active.reset(token)
118
+
119
+
120
+ @contextmanager
121
+ def activated(view: ReferenceView | None) -> Iterator[None]:
122
+ """Make ``view`` the active reference view for the duration of the ``with`` block, then restore.
123
+
124
+ The runner brackets each router/transform run with it (and dry-run mirrors that), so
125
+ :func:`reference` resolves at call time and the prior view is always restored — no leak."""
126
+ token = _active.set(view)
127
+ try:
128
+ yield
129
+ finally:
130
+ _active.reset(token)
131
+
132
+
133
+ def reference(name: str) -> ReferenceSet:
134
+ """Return the active reference set ``name`` (a frozen, read-only :class:`ReferenceSet`).
135
+
136
+ Call it inside a Handler/Router at run time::
137
+
138
+ npi = reference("provider_npi").get(msg["PV1-7.1"])
139
+
140
+ Resolves against the engine-published snapshot view the runner brackets around the run. A missing
141
+ **key** is not an error (use ``.get(key, default)`` — external data is sparse). A missing **set**
142
+ (no active view, or the named set hasn't synced) raises :class:`ReferenceError` (fail loud) — it
143
+ surfaces as that message's ``ERROR`` disposition, like any transform error."""
144
+ view = _active.get()
145
+ if view is None:
146
+ raise ReferenceError(
147
+ f"reference({name!r}) called with no active reference view — reference sets resolve only "
148
+ "inside a Handler/Router while the graph is running (call it at run time, not at import)"
149
+ )
150
+ try:
151
+ data = view[name]
152
+ except KeyError:
153
+ available = ", ".join(sorted(view)) or "(none synced)"
154
+ raise ReferenceError(
155
+ f"no such reference set {name!r} — declare it with Reference({name!r}, source=…) and let "
156
+ f"it sync; available: {available}"
157
+ ) from None
158
+ return ReferenceSet(name, data)
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Synchronous **read side** for captured request/response replies (ADR 0013).
4
+
5
+ A capturing outbound's reply is persisted (immutably, per message) by the delivery worker; a
6
+ Router/Handler reads a *prior committed* reply back synchronously via :func:`response_get`. Like
7
+ :mod:`messagefoundry.config.state` / :mod:`messagefoundry.config.reference`, the engine publishes a
8
+ read-only **view** as a :class:`~contextvars.ContextVar` for the duration of each run, and the
9
+ accessor resolves against it without awaiting the store (handlers are pure synchronous functions).
10
+
11
+ **Increment 1 scope.** A message has no captured reply *during its own transform* (capture happens
12
+ later, at delivery), so the engine currently publishes **no** view for a normal run — ``response_get``
13
+ returns its default, and the provider is a registered no-op that establishes the seam, the accessor,
14
+ and the registration order (between ``state`` and ``environment``). Increment 2 (re-ingress
15
+ orchestration) is where a re-ingressed answer's run publishes a per-message view bound to its
16
+ correlation lineage — at which point this accessor resolves a real reply. The view it reads is
17
+ **immutable committed** state, so it is re-run-stable by construction (ADR 0009).
18
+
19
+ **Layering (information hiding, CLAUDE.md §4).** This config-layer module owns only the active-view
20
+ holder + the accessor + the publish helpers; it does **not** import the store. The runner bridges the
21
+ two by publishing a store-derived view here around each run, so the config layer stays store-free.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Iterator, Mapping
27
+ from contextlib import contextmanager
28
+ from contextvars import ContextVar
29
+ from typing import Any
30
+
31
+ __all__ = [
32
+ "ResponseView",
33
+ "response_get",
34
+ "set_active",
35
+ "reset",
36
+ "activated",
37
+ ]
38
+
39
+ #: The engine-published read view: a read-only mapping ``{destination_name: latest_reply}`` for the
40
+ #: message in scope (Increment 2 populates it). The value shape is whatever the runner publishes; the
41
+ #: config layer only reads it, so it needs no store import. ``None`` means "no active view".
42
+ ResponseView = Mapping[str, Any]
43
+
44
+ # Active view as a ContextVar (mirrors state._active). Defaults to None = "no active view" so
45
+ # response_get distinguishes "not running / nothing captured" from a real reply.
46
+ _active: ContextVar[ResponseView | None] = ContextVar("mefor_active_response", default=None)
47
+
48
+
49
+ def set_active(view: ResponseView | None) -> Any:
50
+ """Publish ``view`` as the active response view and return a reset token (pass it to :func:`reset`)."""
51
+ return _active.set(view)
52
+
53
+
54
+ def reset(token: Any) -> None:
55
+ """Restore the active response view to what it was before the matching :func:`set_active`."""
56
+ _active.reset(token)
57
+
58
+
59
+ @contextmanager
60
+ def activated(view: ResponseView | None) -> Iterator[None]:
61
+ """Make ``view`` the active response view for the duration of the ``with`` block, then restore.
62
+
63
+ The run-context registry brackets each transform run with it (via the ``response`` provider), so
64
+ :func:`response_get` resolves at call time and the prior view is always restored — no leak."""
65
+ token = _active.set(view)
66
+ try:
67
+ yield
68
+ finally:
69
+ _active.reset(token)
70
+
71
+
72
+ def response_get(destination_name: str, default: Any = None) -> Any:
73
+ """Read the latest captured reply for ``destination_name`` from the active response view (ADR
74
+ 0013); ``default`` on a miss or with no active view.
75
+
76
+ Synchronous (no ``await``) so it is callable inside a Handler. In Increment 1 there is no active
77
+ view during a normal run, so this returns ``default``; Increment 2 publishes a per-message view a
78
+ re-ingressed answer's handler can read. The view is immutable committed state, so a re-run reads
79
+ the identical value (the re-run-stability the staged pipeline requires)."""
80
+ view = _active.get()
81
+ if view is None:
82
+ return default
83
+ return view.get(destination_name, default)
@@ -0,0 +1,153 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Run-scoped context providers — the one seam that activates per-run engine state for a Router/Handler.
4
+
5
+ Routers and Handlers read engine-published state at call time through **synchronous accessors**
6
+ (``code_set()``, ``reference()``, ``state_get()``, ``current_environment()``, …). Each resolves against
7
+ a :class:`~contextvars.ContextVar` the engine *activates* for the duration of one router/transform run.
8
+ Before this module, every call site — the router worker, the transform worker, and the dry-run path —
9
+ repeated its own ``with (activated(...), activated(...), …)`` tuple, so adding a new run-scoped accessor
10
+ meant **editing all three tuples**, which collided with every other feature doing the same.
11
+
12
+ This module replaces those tuples with a single registry of **providers**. A provider is a callable
13
+ ``(RunContext) -> AbstractContextManager`` tagged with the phase(s) it applies to. The engine and the
14
+ dry-run path both call :func:`run_contexts` to enter every provider for a phase; a new run-scoped
15
+ accessor just calls :func:`register_run_context` once at import — it never edits a call site again.
16
+
17
+ **Layering (CLAUDE.md §4).** This lives in ``config/`` (not ``pipeline/``) so config-layer accessors —
18
+ e.g. ``config.db_lookup`` — can register a provider without importing ``pipeline/`` (which would invert
19
+ the one-way dependency). It depends only on the config activation helpers it pre-registers.
20
+
21
+ **Order = nesting.** Providers are entered in **registration order** via one
22
+ :class:`contextlib.ExitStack`, so registration order *is* the context-manager nesting order. The four
23
+ built-ins register at import here, before any feature module; a feature that must nest *inside* another
24
+ (e.g. an ingest-time provider inside ``db_lookup``'s executor scope) imports after it.
25
+
26
+ **Re-run stability (CLAUDE.md §2).** At-least-once re-runs a router/transform and relies on identical
27
+ output, so every provider's published state must be re-run-stable. The built-ins are (code sets /
28
+ reference snapshots / committed state / the deployment environment name); a provider exposing live,
29
+ non-deterministic data (``config.db_lookup``) is the documented exception and must refuse to run where
30
+ determinism is assumed. See docs/adr/0009-run-scoped-context-providers.md.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections.abc import Callable, Iterator
36
+ from contextlib import AbstractContextManager, ExitStack, contextmanager
37
+ from dataclasses import dataclass
38
+ from typing import Any
39
+
40
+ from messagefoundry.config.active_environment import activated as _environment_activated
41
+ from messagefoundry.config.code_sets import activated as _code_sets_activated
42
+ from messagefoundry.config.reference import activated as _reference_activated
43
+ from messagefoundry.config.response import activated as _response_activated
44
+ from messagefoundry.config.state import activated as _state_activated
45
+
46
+ __all__ = [
47
+ "RunContext",
48
+ "RunContextProvider",
49
+ "ROUTER",
50
+ "TRANSFORM",
51
+ "register_run_context",
52
+ "run_contexts",
53
+ "registered_providers",
54
+ ]
55
+
56
+ # The two points a router/transform runs. Routers see code sets / references / environment; transforms
57
+ # additionally see committed transform state (state is transform-only — see the built-in phases below).
58
+ ROUTER = "router"
59
+ TRANSFORM = "transform"
60
+ _PHASES = frozenset({ROUTER, TRANSFORM})
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class RunContext:
65
+ """The per-run views a provider may read. The engine builds one per run from its live store/registry;
66
+ the dry-run path builds one from its simulated views. A provider reads only the fields it needs (a
67
+ phase that omits a provider never reads that provider's field — e.g. ``state_view`` in the router
68
+ phase), so a caller may leave unused fields at their default."""
69
+
70
+ code_sets: Any = None
71
+ reference_view: Any = None
72
+ state_view: Any = None
73
+ response_view: Any = (
74
+ None # ADR 0013: per-message captured-reply view (transform phase; Increment 2 feeds it)
75
+ )
76
+ active_environment: str | None = None
77
+ ingest_time: float | None = (
78
+ None # the message's re-run-stable enqueue time (ingest-time provider)
79
+ )
80
+
81
+
82
+ # A provider turns a run context into a context manager that activates one accessor's view for the run.
83
+ RunContextProvider = Callable[[RunContext], AbstractContextManager[Any]]
84
+
85
+ # (name, provider, phases) in REGISTRATION ORDER == ExitStack nesting order. Module-global, populated at
86
+ # IMPORT time: engine modules import once per process and user config modules never register here, so a
87
+ # config reload never re-appends. Keyed by `name` for idempotency + clear diagnostics.
88
+ _providers: list[tuple[str, RunContextProvider, frozenset[str]]] = []
89
+
90
+
91
+ def register_run_context(
92
+ name: str, provider: RunContextProvider, *, phases: frozenset[str] | set[str]
93
+ ) -> None:
94
+ """Register a run-scoped context ``provider`` under ``name``, applied in the given ``phases``.
95
+
96
+ Call **once at module import** (an engine module's top level), not per config load. Idempotent on
97
+ ``name``: a second registration with the same name replaces the first **in place** (preserving
98
+ order), so a re-imported engine module can't double-register. ``phases`` ⊆ {``"router"``,
99
+ ``"transform"``}. Registration order is the runtime nesting order (see module docstring)."""
100
+ ph = frozenset(phases)
101
+ unknown = ph - _PHASES
102
+ if unknown:
103
+ raise ValueError(f"register_run_context({name!r}): unknown phase(s) {sorted(unknown)}")
104
+ if not ph:
105
+ raise ValueError(f"register_run_context({name!r}): phases must not be empty")
106
+ for i, (existing, _, _) in enumerate(_providers):
107
+ if existing == name:
108
+ _providers[i] = (name, provider, ph)
109
+ return
110
+ _providers.append((name, provider, ph))
111
+
112
+
113
+ @contextmanager
114
+ def run_contexts(context: RunContext, *, phase: str) -> Iterator[None]:
115
+ """Activate every provider registered for ``phase`` (in registration order) for the ``with`` body.
116
+
117
+ Replaces the hand-written ``with (activated(...), …)`` tuples the router worker, transform worker,
118
+ and dry-run path each used. Enters via one :class:`~contextlib.ExitStack`, so providers nest in
119
+ registration order and unwind cleanly (each accessor's prior view restored) on exit — including on
120
+ an exception raised inside the body."""
121
+ with ExitStack() as stack:
122
+ for _name, provider, phases in _providers:
123
+ if phase in phases:
124
+ stack.enter_context(provider(context))
125
+ yield
126
+
127
+
128
+ def registered_providers() -> list[str]:
129
+ """The registered provider names in registration (nesting) order — for diagnostics / tests."""
130
+ return [name for name, _, _ in _providers]
131
+
132
+
133
+ # --- built-in providers (pre-registered so the seam is byte-identical to the old `with` tuples) -------
134
+ # Registration order below IS the nesting the workers used: code_sets (outermost) → reference → state →
135
+ # environment (innermost). `state` is transform-only; the other three apply in both phases. The dry-run
136
+ # path runs router+transform in one block under phase="transform" and supplies active_environment=None,
137
+ # so environment activates None — exactly the value current_environment() had when dry-run left it unset.
138
+ register_run_context(
139
+ "code_sets", lambda c: _code_sets_activated(c.code_sets), phases={ROUTER, TRANSFORM}
140
+ )
141
+ register_run_context(
142
+ "reference", lambda c: _reference_activated(c.reference_view), phases={ROUTER, TRANSFORM}
143
+ )
144
+ register_run_context("state", lambda c: _state_activated(c.state_view), phases={TRANSFORM})
145
+ # ADR 0013: the captured-reply view (transform phase only — a Handler reconciling an answer is a
146
+ # transform concern). Registered AFTER state and BEFORE environment, so the nesting order is
147
+ # code_sets → reference → state → response → environment (asserted by a registration-order test).
148
+ register_run_context("response", lambda c: _response_activated(c.response_view), phases={TRANSFORM})
149
+ register_run_context(
150
+ "environment",
151
+ lambda c: _environment_activated(c.active_environment),
152
+ phases={ROUTER, TRANSFORM},
153
+ )