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,758 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Backend-agnostic store interface + construction seam.
4
+
5
+ The engine and API depend on the store **protocols**, not on a concrete backend, so adding a new
6
+ backend (SQL Server, Postgres, …) only means implementing these methods and registering it in
7
+ :func:`open_store`. Today the sole backend is the SQLite :class:`~messagefoundry.store.store.MessageStore`.
8
+
9
+ The contract is **segregated by concern** so each consumer depends only on the slice it uses
10
+ (interface segregation — see docs/ARCHITECTURE.md §"Architectural standard"):
11
+
12
+ * :class:`QueueStore` — the message inbox/outbox lifecycle + reads + store health. The engine,
13
+ the :class:`~messagefoundry.pipeline.wiring_runner.RegistryRunner`, and the message routes use this.
14
+ * :class:`AuditStore` — the audit log + PHI-view trail.
15
+ * :class:`AuthStore` — users, roles, sessions, AD-group maps. Only :class:`AuthService` uses this,
16
+ and it can no longer reach the queue/message methods.
17
+ * :class:`Store` — the composite a backend implements and :func:`open_store` returns.
18
+
19
+ Read methods return :class:`Row` — a minimal protocol (key access + ``keys()``) satisfied by both
20
+ ``aiosqlite.Row`` and a plain ``dict``, so a non-SQLite backend can return its own row mapping without
21
+ the callers caring.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Iterable, Mapping, Sequence
27
+ from pathlib import Path
28
+ from typing import Any, Protocol, runtime_checkable
29
+
30
+ from messagefoundry.config.models import RetryPolicy
31
+ from messagefoundry.config.settings import SqliteSync, StoreBackend, StoreSettings
32
+ from messagefoundry.store.crypto import make_cipher
33
+ from messagefoundry.store.keyprovider import resolve_key_provider
34
+ from messagefoundry.store.store import (
35
+ CapturedResponse,
36
+ ConnectionMetrics,
37
+ DbStatus,
38
+ MessageStatus,
39
+ MessageStore,
40
+ OutboxItem,
41
+ SessionRecord,
42
+ Stage,
43
+ UserRecord,
44
+ )
45
+
46
+ __all__ = [
47
+ "AdminStore",
48
+ "AuditStore",
49
+ "AuthStore",
50
+ "QueueStore",
51
+ "Row",
52
+ "Store",
53
+ "StoreLifecycle",
54
+ "open_store",
55
+ "sqlite_settings",
56
+ ]
57
+
58
+
59
+ class Row(Protocol):
60
+ """A read result: key access + ``keys()`` (satisfied by ``aiosqlite.Row`` and ``dict``)."""
61
+
62
+ def __getitem__(self, key: str) -> Any: ...
63
+ def keys(self) -> Iterable[str]: ...
64
+
65
+
66
+ class StoreLifecycle(Protocol):
67
+ """Open-store handle basics shared by every backend."""
68
+
69
+ path: str
70
+
71
+ async def close(self) -> None: ...
72
+
73
+
74
+ class QueueStore(StoreLifecycle, Protocol):
75
+ """The durable message inbox/outbox queue — the contract the engine + message routes use.
76
+
77
+ Covers the transactional write path, the per-destination delivery worker, recovery/replay, the
78
+ read helpers the API/console render, and store-health/metrics. Deliberately excludes auth and the
79
+ audit log so a queue consumer cannot reach them.
80
+ """
81
+
82
+ #: Whether this backend implements the staged ingress pipeline (``enqueue_ingress``/``handoff``).
83
+ #: ``False`` backends (e.g. SQL Server, gated on BACKLOG #1) are rejected at engine start rather
84
+ #: than trapping the first received message in a ``NotImplementedError``.
85
+ supports_ingest_stage: bool
86
+
87
+ #: Whether this backend can capture request/response replies (ADR 0013: the ``response`` table +
88
+ #: :meth:`complete_with_response`). ``True`` on SQLite/Postgres/SQL Server; a backend returning
89
+ #: ``False`` makes the runner reject a capturing outbound at start (fail-closed) rather than drop
90
+ #: captures.
91
+ supports_response_capture: bool
92
+
93
+ # --- write path ----------------------------------------------------------
94
+ async def enqueue_message(
95
+ self,
96
+ *,
97
+ channel_id: str,
98
+ raw: str,
99
+ deliveries: Sequence[tuple[str, str]],
100
+ control_id: str | None = None,
101
+ message_type: str | None = None,
102
+ source_type: str | None = None,
103
+ summary: str | None = None,
104
+ metadata: str | None = None,
105
+ now: float | None = None,
106
+ ) -> str: ...
107
+
108
+ async def record_received(
109
+ self,
110
+ *,
111
+ channel_id: str,
112
+ raw: str,
113
+ status: MessageStatus,
114
+ error: str | None = None,
115
+ control_id: str | None = None,
116
+ message_type: str | None = None,
117
+ source_type: str | None = None,
118
+ summary: str | None = None,
119
+ metadata: str | None = None,
120
+ now: float | None = None,
121
+ ) -> str: ...
122
+
123
+ async def enqueue_ingress(
124
+ self,
125
+ *,
126
+ channel_id: str,
127
+ raw: str,
128
+ control_id: str | None = None,
129
+ message_type: str | None = None,
130
+ source_type: str | None = None,
131
+ summary: str | None = None,
132
+ metadata: str | None = None,
133
+ now: float | None = None,
134
+ ) -> str:
135
+ """Durably persist a freshly-received raw message to the ingress stage (status ``RECEIVED`` +
136
+ one ``stage='ingress'`` queue row) in one transaction — the staged pipeline's ACK-on-receipt
137
+ boundary. The inbound may be ACKed once this returns. Returns the message id."""
138
+ ...
139
+
140
+ async def handoff(
141
+ self,
142
+ *,
143
+ ingress_id: str,
144
+ message_id: str,
145
+ channel_id: str,
146
+ deliveries: Sequence[tuple[str, str]],
147
+ disposition: MessageStatus,
148
+ now: float | None = None,
149
+ ) -> bool:
150
+ """Advance a message from ingress to outbound in one transaction (claim→produce→complete):
151
+ consume the in-flight ingress row, insert one outbound row per delivery, set the post-router
152
+ ``disposition`` (``ROUTED``/``FILTERED``/``UNROUTED``). Idempotent against worker restart —
153
+ returns ``False`` (a no-op) if the ingress row was already consumed by a prior run. The Step-A
154
+ combined router+transform primitive; the split pipeline uses :meth:`route_handoff` +
155
+ :meth:`transform_handoff` instead."""
156
+ ...
157
+
158
+ async def route_handoff(
159
+ self,
160
+ *,
161
+ ingress_id: str,
162
+ message_id: str,
163
+ channel_id: str,
164
+ handlers: Sequence[tuple[str, str]],
165
+ disposition: MessageStatus,
166
+ now: float | None = None,
167
+ ) -> bool:
168
+ """Advance a message from the ingress stage to the **routed** stage in one transaction (the
169
+ router half of the split pipeline, ADR 0001 Step B): consume the in-flight ingress row, insert
170
+ one ``stage='routed'`` row per selected handler (each ``(handler_name, raw_payload)``), set the
171
+ intermediate ``disposition`` (``ROUTED`` with handlers, ``UNROUTED`` with none). Idempotent
172
+ against worker restart — ``False`` if the ingress row was already consumed."""
173
+ ...
174
+
175
+ async def transform_handoff(
176
+ self,
177
+ *,
178
+ routed_id: str,
179
+ message_id: str,
180
+ channel_id: str,
181
+ deliveries: Sequence[tuple[str, str]],
182
+ state_ops: Sequence[tuple[str, str, Any]] = (),
183
+ now: float | None = None,
184
+ ) -> bool:
185
+ """Advance one handler assignment from the **routed** stage to outbound in one transaction (the
186
+ transform half of the split pipeline, ADR 0001 Step B): consume the in-flight routed row,
187
+ insert one outbound row per delivery, **apply each declared state write** (``state_ops``:
188
+ ``(namespace, key, value)`` upserts, ADR 0005), and let the finalizer recompute the terminal
189
+ disposition (this method never writes ``messages.status`` directly). The state writes commit
190
+ atomically with the outbound rows, so a crash before commit leaves no state and a re-run applies
191
+ them exactly-once (preserving the pure-re-run invariant). Idempotent against worker restart —
192
+ ``False`` if the routed row was already consumed."""
193
+ ...
194
+
195
+ def state_view(self) -> Mapping[tuple[str, str], Any]:
196
+ """A read-only view of the engine-maintained transform-state read-through cache (ADR 0005):
197
+ ``{(namespace, key): decoded_value}``. The runner publishes it around each router/transform run
198
+ so a Handler's synchronous ``state_get(...)`` resolves. Reflects writes as they commit."""
199
+ ...
200
+
201
+ # --- reference sets (ADR 0006 Tier 1) ------------------------------------
202
+ def reference_view(self) -> Mapping[str, Mapping[str, Any]]:
203
+ """A read-only view of the active reference snapshots (ADR 0006): ``{name: {key: value}}``. The
204
+ runner publishes it around each router/transform run so ``reference("name").get(key)`` resolves.
205
+ Swaps in a new snapshot only after a sync commits."""
206
+ ...
207
+
208
+ async def write_reference_snapshot(
209
+ self, *, name: str, version: str, rows: Mapping[str, Any]
210
+ ) -> None:
211
+ """Materialize a new reference snapshot for ``name`` and atomically make it active (ADR 0006):
212
+ one transaction replaces the set's rows and flips the active version; the read cache swaps only
213
+ after commit, so a failed sync leaves the last-good snapshot live."""
214
+ ...
215
+
216
+ async def converge_reference_cache(self) -> list[str]:
217
+ """Refresh this node's in-process reference read cache from the shared store (Track B Step 6).
218
+
219
+ The follower read-through: re-loads any set whose authoritative active version (in the shared
220
+ store) is newer than the version currently reflected in this handle's cache, **without**
221
+ re-reading the external source. Returns the names of the sets actually refreshed (``[]`` when
222
+ nothing changed). Multi-node Postgres implements it for real; single-node backends (SQLite,
223
+ SQL Server) return ``[]`` (a single node is the sole writer, so its cache is always current)."""
224
+ ...
225
+
226
+ async def converge_state_cache(self) -> list[str]:
227
+ """Refresh this node's in-process transform-STATE read cache from the shared store (Track B
228
+ Step 6b).
229
+
230
+ The follower read-through for ADR 0005 state: re-reads any namespace whose per-namespace version
231
+ (in the shared store) is newer than the version currently reflected in this handle's cache, so a
232
+ sibling node's state write reaches every node. Returns the namespace names actually refreshed
233
+ (``[]`` when nothing changed). Multi-node Postgres implements it for real; single-node backends
234
+ (SQLite, SQL Server) return ``[]`` (a single node is the sole writer, so its cache is always
235
+ current)."""
236
+ ...
237
+
238
+ def enable_state_convergence(self) -> None:
239
+ """Turn on per-namespace state-version bumping for cross-node convergence (Track B Step 6b). The
240
+ engine calls this only in a cluster (``coordinator.is_clustered()``) BEFORE workers start, so a
241
+ sibling's :meth:`converge_state_cache` sees every write. Single-node never calls it → no version
242
+ writes → byte-identical. A no-op on backends without cross-node convergence (SQLite, SQL Server)."""
243
+ ...
244
+
245
+ # --- delivery worker path ------------------------------------------------
246
+ async def claim_ready(
247
+ self,
248
+ limit: int = 10,
249
+ now: float | None = None,
250
+ *,
251
+ stage: str = Stage.OUTBOUND.value,
252
+ channel_id: str | None = None,
253
+ destination_name: str | None = None,
254
+ ) -> list[OutboxItem]: ...
255
+
256
+ async def claim_next_fifo(
257
+ self,
258
+ name: str,
259
+ now: float | None = None,
260
+ *,
261
+ stage: str = Stage.OUTBOUND.value,
262
+ owner: str | None = None,
263
+ ) -> OutboxItem | None:
264
+ """Claim the single oldest *due* pending row for one lane at ``stage`` (strict FIFO; the head
265
+ blocks the lane while it backs off). The lane key is stage-aware: ``destination_name`` for
266
+ outbound, ``channel_id`` for ingress. ``None`` when nothing is pending or the head isn't due.
267
+
268
+ ``owner`` is this node's cluster identity (Track B Step 5 lane ownership): ``None`` single-node
269
+ (the byte-identical path; SQLite/SQL Server always ignore it), or the coordinator's node_id
270
+ when clustered, gating the claim by an atomic per-lane lease so a FIFO lane is processed by
271
+ exactly one node at a time and strict per-lane FIFO holds across nodes."""
272
+ ...
273
+
274
+ async def mark_done(self, outbox_id: str, now: float | None = None) -> None: ...
275
+
276
+ async def complete_with_response(
277
+ self,
278
+ outbox_id: str,
279
+ *,
280
+ body: str,
281
+ outcome: str,
282
+ detail: str | None = None,
283
+ reingress_to: str | None = None,
284
+ now: float | None = None,
285
+ ) -> None:
286
+ """Mark one outbound row delivered **and** persist the partner's captured reply (ADR 0013) in
287
+ one atomic transaction — :meth:`mark_done` plus an immutable ``response`` row keyed
288
+ ``(message_id, destination_name, response_seq)``. The delivery worker calls **exactly one** of
289
+ this or :meth:`mark_done` per successful delivery (the capture XOR). The ``response`` table is
290
+ invisible to disposition (the finalizer scans ``queue`` only), so a captured delivery finalizes
291
+ ``PROCESSED`` exactly as a one-way one does.
292
+
293
+ When ``reingress_to`` is set (Increment 2), the same transaction *also* inserts a drainable
294
+ ``Stage.RESPONSE`` work-row on the named loopback inbound's lane (a token referencing the
295
+ artifact) so the reply is re-ingressed; ``None`` is byte-identical to Increment 1 (no work-row)."""
296
+ ...
297
+
298
+ async def correlate_response(self, message_id: str) -> list[CapturedResponse]:
299
+ """Every captured reply for ``message_id`` (ADR 0013), ordered by destination then
300
+ ``response_seq`` (latest seq per destination = the authoritative reply). The PHI read surface
301
+ behind the audited, body-gated ``GET /messages/{id}/responses`` route."""
302
+ ...
303
+
304
+ async def ingress_handoff(
305
+ self,
306
+ *,
307
+ response_row_id: str,
308
+ loopback_channel_id: str,
309
+ correlation_depth_cap: int,
310
+ control_id: str | None,
311
+ message_type: str | None,
312
+ summary: str | None,
313
+ peek_failed: bool = False,
314
+ now: float | None = None,
315
+ ) -> bool:
316
+ """Consume one INFLIGHT ``Stage.RESPONSE`` work-row and produce the re-ingressed message+ingress
317
+ row in one transaction (ADR 0013 Increment 2) — the re-ingress edge. A guarded ``DELETE`` of the
318
+ work-row is the exactly-once commit, so a committed run is an idempotent no-op (``False``). The
319
+ re-ingress worker peeks the loopback body and passes the derived metadata in. Returns ``True`` if
320
+ this call performed the handoff."""
321
+ ...
322
+
323
+ async def response_body_for_work_row(self, response_row_id: str) -> str | None:
324
+ """The decrypted artifact body a ``Stage.RESPONSE`` work-row references (ADR 0013 Increment 2) —
325
+ read by the re-ingress worker to HL7-peek the reply (in ``pipeline/``) before
326
+ :meth:`ingress_handoff`. ``None`` if the row/artifact is gone."""
327
+ ...
328
+
329
+ async def mark_failed(
330
+ self, outbox_id: str, error: str, retry: RetryPolicy, now: float | None = None
331
+ ) -> None: ...
332
+
333
+ async def dead_letter_now(self, outbox_id: str, error: str, now: float | None = None) -> None:
334
+ """Force one outbox row terminal (``DEAD``) immediately — **fail-fast**, no retry consumed
335
+ and no backoff. For deliveries that can never succeed as-is and must not hold the FIFO lane:
336
+ a permanent partner reject (``AR``), an internal/code error under the error-and-continue
337
+ policy, or an undecryptable payload. Replayable via the dead-letter API like any dead row.
338
+ Contrast :meth:`mark_failed`, which reschedules with backoff (and only dead-letters once a
339
+ finite ``max_attempts`` is exhausted)."""
340
+ ...
341
+
342
+ # --- recovery / replay ---------------------------------------------------
343
+ async def pending_depth(
344
+ self, name: str, *, stage: str = Stage.OUTBOUND.value
345
+ ) -> tuple[int, float | None]:
346
+ """Backlog shape for one lane at ``stage``: ``(pending_count, oldest_created_at)`` — the number
347
+ of rows still waiting and the enqueue time of the oldest (``None`` when empty). Lane key is
348
+ stage-aware (``destination_name`` outbound, ``channel_id`` ingress). The workers use this to
349
+ raise a ``queue_buildup`` alert when a lane stops draining. Cheap: a single COUNT + MIN."""
350
+ ...
351
+
352
+ async def reset_stale_inflight(
353
+ self, now: float | None = None, *, stage: str | None = None
354
+ ) -> int:
355
+ """Return ``inflight`` rows (claimed before a crash) to ``pending``. ``stage=None`` (default)
356
+ recovers every stage in one pass — the right startup behavior; pass a stage to scope it."""
357
+ ...
358
+
359
+ async def dead_letter_missing_destinations(
360
+ self, valid_names: set[str], now: float | None = None
361
+ ) -> int: ...
362
+
363
+ async def dead_letter_missing_handlers(
364
+ self, valid_names: set[str], now: float | None = None
365
+ ) -> int:
366
+ """Dead-letter non-terminal **routed** rows whose ``handler_name`` left the registry (a removed
367
+ handler no transform worker can run). The routed-stage parallel of
368
+ :meth:`dead_letter_missing_destinations`; call once at startup. Returns the rows killed."""
369
+ ...
370
+
371
+ async def replay(self, message_id: str, now: float | None = None) -> int: ...
372
+
373
+ async def replay_dead(
374
+ self,
375
+ *,
376
+ channel_id: str | None = None,
377
+ destination_name: str | None = None,
378
+ now: float | None = None,
379
+ ) -> int: ...
380
+
381
+ async def cancel_queued(
382
+ self,
383
+ channel_id: str | None,
384
+ destination_name: str,
385
+ *,
386
+ top_only: bool = False,
387
+ now: float | None = None,
388
+ ) -> int: ...
389
+
390
+ # --- read helpers (API / console) ----------------------------------------
391
+ # Row sequences are returned as Sequence[Row] (covariant) so a backend may return its own row
392
+ # type (e.g. aiosqlite.Row) — list[Row] would be invariant and reject that.
393
+ async def get_message(self, message_id: str) -> dict[str, Any] | None: ...
394
+
395
+ async def list_messages(
396
+ self,
397
+ *,
398
+ channel_id: str | None = None,
399
+ status: str | None = None,
400
+ message_type: str | None = None,
401
+ control_id: str | None = None,
402
+ limit: int = 50,
403
+ offset: int = 0,
404
+ allowed_channels: Sequence[str] | None = None,
405
+ ) -> Sequence[Row]: ...
406
+
407
+ async def count_messages(
408
+ self,
409
+ *,
410
+ channel_id: str | None = None,
411
+ status: str | None = None,
412
+ message_type: str | None = None,
413
+ control_id: str | None = None,
414
+ allowed_channels: Sequence[str] | None = None,
415
+ ) -> int: ...
416
+
417
+ async def list_dead(
418
+ self,
419
+ *,
420
+ channel_id: str | None = None,
421
+ destination_name: str | None = None,
422
+ limit: int = 50,
423
+ offset: int = 0,
424
+ allowed_channels: Sequence[str] | None = None,
425
+ ) -> Sequence[Row]: ...
426
+
427
+ async def count_dead(
428
+ self,
429
+ *,
430
+ channel_id: str | None = None,
431
+ destination_name: str | None = None,
432
+ allowed_channels: Sequence[str] | None = None,
433
+ ) -> int: ...
434
+
435
+ async def outbox_for(self, message_id: str) -> Sequence[Row]: ...
436
+
437
+ async def outbox_payloads_for(self, message_id: str) -> Sequence[Row]:
438
+ """Like :meth:`outbox_for` but the rows also carry the **decrypted transformed ``payload``**
439
+ (PHI body) per destination — the #14 parity-comparison read path. Kept separate from
440
+ :meth:`outbox_for` so the metadata-only message-detail view never decrypts bodies; the API
441
+ gates this on ``MESSAGES_VIEW_RAW`` and audits every access."""
442
+ ...
443
+
444
+ async def events_for(self, message_id: str) -> Sequence[Row]: ...
445
+
446
+ async def stats(self) -> dict[str, int]: ...
447
+
448
+ async def in_pipeline_depth(self) -> int:
449
+ """Count of NOT-DONE rows (status ``pending``|``inflight``) across **every** stage
450
+ (ingress + routed + outbound) — a whole-pipeline drain gauge, vs :meth:`stats` which sees only
451
+ the outbound stage. Lets a consumer tell a true drain from a stalled router/transform."""
452
+ ...
453
+
454
+ # --- at-rest key rotation (PHI.md §3, ASVS 11.2.2) -----------------------
455
+ async def reencrypt_to_active(self, *, batch: int = 500) -> int: ...
456
+
457
+ # --- retention / purge + maintenance (PHI.md §8) -------------------------
458
+ async def purge_message_bodies(self, *, older_than: float, now: float | None = None) -> int: ...
459
+
460
+ async def purge_dead_letters(self, *, older_than: float, now: float | None = None) -> int: ...
461
+
462
+ async def purge_state(self, *, older_than: float, now: float | None = None) -> int:
463
+ """Delete transform-state entries (ADR 0005) last written before ``older_than`` (age-based
464
+ retention). Returns the number purged. Off unless ``[retention].state_max_age_days`` is set."""
465
+ ...
466
+
467
+ async def wal_checkpoint(self) -> None: ...
468
+
469
+ async def vacuum(self) -> None: ...
470
+
471
+ # --- store health / metrics ----------------------------------------------
472
+ async def db_status(self) -> DbStatus: ...
473
+
474
+ async def integrity_check(self) -> tuple[bool, str]: ...
475
+
476
+ async def connection_metrics(
477
+ self, *, since: float, now: float | None = None, rate_window: float = 60.0
478
+ ) -> ConnectionMetrics: ...
479
+
480
+
481
+ class AuditStore(Protocol):
482
+ """The audit log + PHI-view trail (tamper-evident hash chain)."""
483
+
484
+ async def record_view(
485
+ self, message_id: str, *, actor: str | None = None, now: float | None = None
486
+ ) -> None: ...
487
+
488
+ async def record_audit(
489
+ self,
490
+ action: str,
491
+ *,
492
+ actor: str | None = None,
493
+ channel_id: str | None = None,
494
+ detail: str | None = None,
495
+ now: float | None = None,
496
+ ) -> None: ...
497
+
498
+ async def list_audit(self, *, limit: int = 50) -> Sequence[Row]: ...
499
+
500
+ async def security_events_for_user(
501
+ self, username: str, *, limit: int = 100
502
+ ) -> Sequence[Row]: ...
503
+
504
+ async def create_pending_approval(
505
+ self,
506
+ *,
507
+ approval_id: str,
508
+ operation: str,
509
+ params: str,
510
+ requester: str,
511
+ requested_at: float,
512
+ expires_at: float | None,
513
+ ) -> None: ...
514
+
515
+ async def get_pending_approval(self, approval_id: str) -> Row | None: ...
516
+
517
+ async def list_pending_approvals(self, *, now: float, limit: int = 100) -> Sequence[Row]: ...
518
+
519
+ async def decide_pending_approval(
520
+ self, approval_id: str, *, status: str, approver: str | None, decided_at: float
521
+ ) -> bool: ...
522
+
523
+ async def audit_anchor(self) -> tuple[int, str]: ...
524
+
525
+ async def verify_audit_chain(
526
+ self, *, expected_anchor: tuple[int, str] | None = None
527
+ ) -> tuple[bool, str | None]: ...
528
+
529
+
530
+ class AuthStore(Protocol):
531
+ """Users, roles, sessions, and AD-group mappings — the contract :class:`AuthService` uses.
532
+
533
+ Segregated from the queue/message contract so the auth subsystem cannot reach inbox/outbox rows.
534
+ """
535
+
536
+ # --- users ---------------------------------------------------------------
537
+ async def create_user(
538
+ self,
539
+ *,
540
+ user_id: str,
541
+ username: str,
542
+ auth_provider: str,
543
+ display_name: str | None = None,
544
+ email: str | None = None,
545
+ password_hash: str | None = None,
546
+ must_change_password: bool = False,
547
+ now: float | None = None,
548
+ ) -> None: ...
549
+
550
+ async def get_user(self, user_id: str) -> UserRecord | None: ...
551
+
552
+ async def get_user_by_username(self, username: str) -> UserRecord | None: ...
553
+
554
+ async def list_users(self) -> Sequence[UserRecord]: ...
555
+
556
+ async def count_users(self) -> int: ...
557
+
558
+ async def set_password(
559
+ self,
560
+ user_id: str,
561
+ *,
562
+ password_hash: str,
563
+ must_change_password: bool = False,
564
+ now: float | None = None,
565
+ ) -> None: ...
566
+
567
+ async def set_user_disabled(
568
+ self, user_id: str, *, disabled: bool, now: float | None = None
569
+ ) -> None: ...
570
+
571
+ async def update_user_profile(
572
+ self,
573
+ user_id: str,
574
+ *,
575
+ display_name: str | None,
576
+ email: str | None,
577
+ now: float | None = None,
578
+ ) -> None: ...
579
+
580
+ async def delete_user(self, user_id: str) -> None: ...
581
+
582
+ # --- MFA: native TOTP second factor (local accounts, WP-14) --------------
583
+ async def set_totp_secret(
584
+ self, user_id: str, *, secret: str | None, now: float | None = None
585
+ ) -> None: ...
586
+
587
+ async def get_totp_secret(self, user_id: str) -> str | None: ...
588
+
589
+ async def enable_totp(
590
+ self, user_id: str, *, recovery_code_hashes: list[str], now: float | None = None
591
+ ) -> None: ...
592
+
593
+ async def disable_totp(self, user_id: str, *, now: float | None = None) -> None: ...
594
+
595
+ async def get_recovery_code_hashes(self, user_id: str) -> list[str]: ...
596
+
597
+ async def consume_recovery_code_hash(
598
+ self, user_id: str, code_hash: str, *, now: float | None = None
599
+ ) -> bool: ...
600
+
601
+ async def consume_totp_step(self, user_id: str, step: int) -> bool: ...
602
+
603
+ async def record_login_success(self, user_id: str, *, now: float | None = None) -> None: ...
604
+
605
+ async def record_login_failure(
606
+ self,
607
+ user_id: str,
608
+ *,
609
+ failed_attempts: int,
610
+ locked_until: float | None,
611
+ now: float | None = None,
612
+ ) -> None: ...
613
+
614
+ # --- roles / AD-group maps -----------------------------------------------
615
+ async def upsert_role(
616
+ self,
617
+ *,
618
+ role_id: str,
619
+ display_name: str,
620
+ description: str | None = None,
621
+ builtin: bool = True,
622
+ ) -> None: ...
623
+
624
+ async def list_roles(self) -> Sequence[Row]: ...
625
+
626
+ async def get_user_role_ids(self, user_id: str) -> list[str]: ...
627
+
628
+ async def set_user_roles(
629
+ self,
630
+ user_id: str,
631
+ role_ids: Sequence[str],
632
+ *,
633
+ assigned_by: str | None = None,
634
+ now: float | None = None,
635
+ ) -> None: ...
636
+
637
+ async def set_user_channel_scope(
638
+ self, user_id: str, scope_json: str | None, *, now: float | None = None
639
+ ) -> None: ...
640
+
641
+ async def roles_for_ad_groups(self, groups: Iterable[str]) -> set[str]: ...
642
+
643
+ async def list_ad_group_role_map(self) -> Sequence[Row]: ...
644
+
645
+ async def set_ad_group_role_map(self, entries: Iterable[tuple[str, str]]) -> None: ...
646
+
647
+ async def channels_for_ad_groups(self, groups: Iterable[str]) -> set[str]: ...
648
+
649
+ async def list_ad_group_scope_map(self) -> Sequence[Row]: ...
650
+
651
+ async def set_ad_group_scope_map(self, entries: Iterable[tuple[str, str]]) -> None: ...
652
+
653
+ # --- sessions ------------------------------------------------------------
654
+ async def create_session(
655
+ self,
656
+ *,
657
+ token_hash: str,
658
+ user_id: str,
659
+ expires_at: float,
660
+ client: str | None = None,
661
+ seed_reauth: bool = True,
662
+ now: float | None = None,
663
+ ) -> None: ...
664
+
665
+ async def get_session(self, token_hash: str) -> SessionRecord | None: ...
666
+
667
+ async def list_sessions(
668
+ self, user_id: str, *, now: float | None = None
669
+ ) -> list[SessionRecord]: ...
670
+
671
+ async def touch_session(self, token_hash: str, *, now: float | None = None) -> None: ...
672
+
673
+ async def mark_session_reauthed(
674
+ self, token_hash: str, *, now: float | None = None, client: str | None = None
675
+ ) -> None:
676
+ """Refresh the session's step-up freshness (``reauth_at``). When ``client`` is given, also
677
+ re-anchor the session's last-verified client address to it (the new-client-IP risk signal in
678
+ WP-L3-13 uses this so a re-verify from a roamed address clears the forced step-up); a ``None``
679
+ ``client`` leaves the stored address unchanged."""
680
+ ...
681
+
682
+ async def mark_session_mfa_verified(
683
+ self, token_hash: str, *, now: float | None = None
684
+ ) -> None: ...
685
+
686
+ async def revoke_session(self, token_hash: str, *, now: float | None = None) -> None: ...
687
+
688
+ async def revoke_user_sessions(
689
+ self, user_id: str, *, except_token_hash: str | None = None, now: float | None = None
690
+ ) -> int: ...
691
+
692
+ async def enforce_session_cap(
693
+ self, user_id: str, *, keep: int, now: float | None = None
694
+ ) -> None: ...
695
+
696
+ async def purge_expired_sessions(self, *, now: float | None = None) -> int: ...
697
+
698
+
699
+ class AdminStore(AuthStore, AuditStore, Protocol):
700
+ """Auth + audit-log reads — the surface :class:`AuthService` exposes to its admin endpoints.
701
+
702
+ Wider than :class:`AuthStore` because the user-administration routes also read the audit log,
703
+ but still excludes :class:`QueueStore`: the auth subsystem can never reach inbox/outbox rows.
704
+ """
705
+
706
+
707
+ @runtime_checkable
708
+ class Store(QueueStore, AuditStore, AuthStore, Protocol):
709
+ """The full store contract — every backend implements all three concerns in one handle.
710
+
711
+ Kept ``runtime_checkable`` so ``isinstance(store, Store)`` can smoke-check a backend. The concerns
712
+ deliberately share one SQLite file/handle (single-file inbox/outbox + audit + auth, no broker);
713
+ the segregation is in the *contract* each consumer depends on, not in the physical store.
714
+ """
715
+
716
+
717
+ def resolve_active_key(settings: StoreSettings) -> str | None:
718
+ """The effective base64 active key, sourced through the :class:`KeyProvider` seam selected by
719
+ ``[store].key_provider`` (ADR 0019). The default ``auto`` provider is the env-then-DPAPI ladder —
720
+ ``encryption_key`` (env/config) if set, else the Windows DPAPI-protected ``encryption_key_file``
721
+ decrypted (WP-11d), else ``None`` (→ identity cipher) — so the default is **byte-identical** to the
722
+ pre-seam behavior. The env key takes precedence so a deployment can override the file.
723
+
724
+ Fail-closed: a configured-but-unreadable/foreign DPAPI key file raises ``DpapiError`` here, and a
725
+ selected-but-unresolvable/unknown provider raises ``KeyProviderError`` — both propagate so
726
+ ``serve`` refuses to start rather than silently degrading to the identity (plaintext) cipher."""
727
+ return resolve_key_provider(settings).active_key()
728
+
729
+
730
+ async def open_store(settings: StoreSettings) -> Store:
731
+ """Open the store for the configured backend — the single backend-selection seam.
732
+
733
+ ``sqlite`` is the default; ``postgres`` is a production server-DB backend with single-node parity
734
+ (lazy-imported, needs the ``postgres`` extra); ``sqlserver`` is a production server-DB backend,
735
+ lazy-imported (needs the ``sqlserver`` extra). Unknown backends raise ``NotImplementedError``.
736
+ """
737
+ # AES-256-GCM keyring at rest when a key is set (STORE-1): active key (env or DPAPI key file) +
738
+ # any retired decrypt-only keys for an in-progress rotation (WP-5). No key → identity cipher.
739
+ retired = [k.strip() for k in settings.encryption_keys_retired.split(",") if k.strip()]
740
+ cipher = make_cipher(resolve_active_key(settings), retired)
741
+ if settings.backend is StoreBackend.SQLITE:
742
+ return await MessageStore.open(
743
+ settings.path, synchronous=settings.synchronous.value, cipher=cipher
744
+ )
745
+ if settings.backend is StoreBackend.SQLSERVER:
746
+ from messagefoundry.store.sqlserver import SqlServerStore # lazy: optional aioodbc dep
747
+
748
+ return await SqlServerStore.open(settings, cipher=cipher)
749
+ if settings.backend is StoreBackend.POSTGRES:
750
+ from messagefoundry.store.postgres import PostgresStore # lazy: optional asyncpg dep
751
+
752
+ return await PostgresStore.open(settings, cipher=cipher)
753
+ raise NotImplementedError(f"store backend {settings.backend.value!r} is not implemented yet")
754
+
755
+
756
+ def sqlite_settings(path: str | Path, *, synchronous: str = "NORMAL") -> StoreSettings:
757
+ """Build a SQLite ``StoreSettings`` (convenience for callers that only have a path)."""
758
+ return StoreSettings(path=str(path), synchronous=SqliteSync(synchronous.lower()))