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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- 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
|
+
)
|