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,96 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Centralized field-level (property) authorization for API responses (WP-9; ASVS 8.1.2 / 8.2.3).
4
+
5
+ Some response properties carry PHI — the patient-identifying ``summary``, and exception text
6
+ (``error`` / ``last_error``) that can quote field values — and must be withheld from a caller who may
7
+ see the rest of the object but lacks the unlocking permission. This module is the **single declarative
8
+ place** that maps each PHI-bearing property to the :class:`~messagefoundry.auth.Permission` that
9
+ unlocks it, plus the one helper that enforces it. Centralizing it means the policy lives in one
10
+ auditable spot instead of being re-implemented inline per endpoint, where a new endpoint or field could
11
+ silently leak PHI (the Broken Object Property Level Authorization risk, ASVS 8.2.3).
12
+
13
+ **Read-side only.** The API exposes no client-writable PHI properties — mutations are coarse, separately
14
+ permission-gated actions (replay / purge / reload / connection-control) — so there is no per-field
15
+ *write* authorization surface today. See docs/SECURITY.md "Field-level authorization" for the model and
16
+ the trigger that would add one.
17
+
18
+ The full message **body** (``MessageDetail.raw``) is governed separately, at the endpoint, by the
19
+ coarser whole-body ``messages:view_raw`` gate — not by this per-property map.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Sequence
25
+ from typing import TypeVar
26
+
27
+ from pydantic import BaseModel
28
+
29
+ from messagefoundry.api.models import (
30
+ CapturedResponseInfo,
31
+ DeadLetterRow,
32
+ EventInfo,
33
+ MessageDetail,
34
+ MessageSummary,
35
+ OutboxInfo,
36
+ )
37
+ from messagefoundry.auth import Identity, Permission
38
+
39
+ #: Response model → {property → Permission that unlocks it}. The single source of truth for which
40
+ #: response properties are PHI-gated and by which permission; :func:`redact_unauthorized` nulls a
41
+ #: property when the caller lacks its permission. Both summary-tier PHI fields gate on
42
+ #: ``messages:view_summary`` today (the body, gated by ``messages:view_raw``, is handled at the
43
+ #: endpoint). Add a row here when a new PHI-bearing response property is introduced.
44
+ PHI_FIELDS: dict[type[BaseModel], dict[str, Permission]] = {
45
+ MessageSummary: {
46
+ "summary": Permission.MESSAGES_VIEW_SUMMARY,
47
+ "error": Permission.MESSAGES_VIEW_SUMMARY,
48
+ },
49
+ DeadLetterRow: {
50
+ "summary": Permission.MESSAGES_VIEW_SUMMARY,
51
+ "last_error": Permission.MESSAGES_VIEW_SUMMARY,
52
+ },
53
+ # The single-message detail view and its nested rows (#120). Redaction keys on the EXACT type
54
+ # (no MRO walk), so MessageDetail must be declared explicitly even though it subclasses
55
+ # MessageSummary — otherwise its inherited PHI ``summary``/``error`` would be returned un-gated.
56
+ # Gated on view_summary (NOT view_raw) so the same logical fields (error / last_error / detail)
57
+ # sit on one tier across the list and detail surfaces; the detail route already requires view_raw,
58
+ # so a view_raw gate here would be dead code. The raw body stays on the route's view_raw gate.
59
+ MessageDetail: {
60
+ "summary": Permission.MESSAGES_VIEW_SUMMARY,
61
+ "error": Permission.MESSAGES_VIEW_SUMMARY,
62
+ },
63
+ OutboxInfo: {
64
+ "last_error": Permission.MESSAGES_VIEW_SUMMARY,
65
+ },
66
+ EventInfo: {
67
+ "detail": Permission.MESSAGES_VIEW_SUMMARY,
68
+ },
69
+ CapturedResponseInfo: {
70
+ "detail": Permission.MESSAGES_VIEW_SUMMARY,
71
+ },
72
+ }
73
+
74
+ M = TypeVar("M", bound=BaseModel)
75
+
76
+
77
+ def gated_properties(model_cls: type[BaseModel]) -> dict[str, Permission]:
78
+ """The PHI property→permission map declared for ``model_cls`` (empty if it has none)."""
79
+ return PHI_FIELDS.get(model_cls, {})
80
+
81
+
82
+ def redact_unauthorized(model: M, identity: Identity) -> M:
83
+ """Return ``model`` with each PHI property the caller may **not** see set to ``None`` — a no-op
84
+ when the caller holds every relevant permission. The single per-property read gate (ASVS 8.2.3)."""
85
+ withheld = {
86
+ prop: None for prop, perm in gated_properties(type(model)).items() if not identity.has(perm)
87
+ }
88
+ return model.model_copy(update=withheld) if withheld else model
89
+
90
+
91
+ def count_exposed(models: Sequence[BaseModel]) -> int:
92
+ """How many ``models`` still carry a non-empty PHI property — call **after** redaction, so the
93
+ count reflects what is actually returned. Fed to the server-side PHI-exposure audit."""
94
+ return sum(
95
+ 1 for m in models if any(getattr(m, prop, None) for prop in gated_properties(type(m)))
96
+ )
@@ -0,0 +1,374 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Response schemas for the localhost API.
4
+
5
+ These are the wire contract the console (and any other client) sees — deliberately
6
+ separate from the internal SQLite rows and channel-config models so storage/runtime
7
+ changes don't leak into the API. Message *list* responses carry metadata only; the raw
8
+ body (PHI) appears only in the single-message detail view, which is audited.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from messagefoundry.config.ai_policy import AiDataScope, AiMode, DataClass
18
+
19
+
20
+ class ChannelInfo(BaseModel):
21
+ id: str
22
+ name: str
23
+ enabled: bool
24
+ running: bool
25
+ source_type: str
26
+ destinations: list[str]
27
+
28
+
29
+ class MessageSummary(BaseModel):
30
+ id: str
31
+ channel_id: str
32
+ received_at: float
33
+ source_type: str | None
34
+ control_id: str | None
35
+ message_type: str | None
36
+ status: str
37
+ error: str | None
38
+ event: str | None = None # latest processing event (received/delivered/failed/dead/replayed)
39
+ summary: str | None = None # ingest-derived: MRN/name (+ order/accession for ORM/ORU)
40
+ metadata: str | None = None # code/operator-attached values (mechanism TBD)
41
+
42
+
43
+ class MessageList(BaseModel):
44
+ total: int
45
+ limit: int
46
+ offset: int
47
+ messages: list[MessageSummary]
48
+
49
+
50
+ class OutboxInfo(BaseModel):
51
+ id: str
52
+ destination_name: str
53
+ status: str
54
+ attempts: int
55
+ next_attempt_at: float
56
+ last_error: str | None
57
+
58
+
59
+ class EventInfo(BaseModel):
60
+ ts: float
61
+ event: str
62
+ destination: str | None
63
+ detail: str | None
64
+
65
+
66
+ class MessageDetail(MessageSummary):
67
+ """Full single-message view, including the raw body and delivery/audit trail."""
68
+
69
+ raw: str
70
+ outbox: list[OutboxInfo]
71
+ events: list[EventInfo]
72
+
73
+
74
+ class CapturedResponseInfo(BaseModel):
75
+ """One captured request/response reply (ADR 0013). ``outcome``/``detail`` are visible with the
76
+ message-read permission; ``body`` is PHI and populated only when the caller also holds the raw-body
77
+ permission (``None`` otherwise, and ``None`` once retention has purged it)."""
78
+
79
+ destination_name: str
80
+ response_seq: int
81
+ outcome: str
82
+ detail: str | None
83
+ captured_at: float
84
+ body: str | None = None
85
+
86
+
87
+ class MessageResponses(BaseModel):
88
+ """The captured-reply history for one message (ADR 0013), ordered by destination then seq."""
89
+
90
+ message_id: str
91
+ responses: list[CapturedResponseInfo]
92
+
93
+
94
+ class OutboundPayloadInfo(BaseModel):
95
+ """One outbound delivery's **transformed payload** (#14 parity tool). ``payload`` is the PHI body
96
+ MEFOR routed/transformed for ``destination_name``; it is returned in full only to a caller holding
97
+ ``MESSAGES_VIEW_RAW``, and every access is audited. (Distinct from :class:`OutboxInfo`, which is
98
+ the body-free delivery *metadata* shown in the message-detail view.)"""
99
+
100
+ destination_name: str
101
+ status: str
102
+ payload: str
103
+
104
+
105
+ class OutboundPayloads(BaseModel):
106
+ """The transformed outbound payloads for one message — one entry per destination (#14). Populated
107
+ on both simulate/shadow and live runs (the transformed payload is retained on the done outbound
108
+ row in either mode), enabling the ``tee compare`` parity check against Corepoint's output."""
109
+
110
+ message_id: str
111
+ payloads: list[OutboundPayloadInfo]
112
+
113
+
114
+ class ReplayResult(BaseModel):
115
+ message_id: str
116
+ requeued: int
117
+
118
+
119
+ class PurgeResult(BaseModel):
120
+ cancelled: int
121
+
122
+
123
+ class DeadLetterRow(BaseModel):
124
+ """One dead-lettered delivery (a message→destination that exhausted its retries)."""
125
+
126
+ outbox_id: str
127
+ message_id: str
128
+ channel_id: str
129
+ destination_name: str
130
+ attempts: int
131
+ last_error: str | None
132
+ failed_at: float # when the delivery was dead-lettered (outbox.updated_at)
133
+ control_id: str | None
134
+ message_type: str | None
135
+ received_at: float
136
+ summary: str | None = None # PHI-bearing (MRN/name); display is audited
137
+
138
+
139
+ class DeadLetterList(BaseModel):
140
+ total: int
141
+ limit: int
142
+ offset: int
143
+ dead_letters: list[DeadLetterRow]
144
+
145
+
146
+ class DeadLetterReplayRequest(BaseModel):
147
+ # Connection names; bounded so an over-long value can't reach the store query (ASVS 1.3.3).
148
+ channel_id: str | None = Field(None, max_length=256) # scope replay to one inbound (None = all)
149
+ destination_name: str | None = Field(None, max_length=256) # scope to one outbound (None = all)
150
+
151
+
152
+ class DeadLetterReplayResult(BaseModel):
153
+ requeued: int
154
+
155
+
156
+ class PendingApprovalResponse(BaseModel):
157
+ """Returned (HTTP 202) when a high-value action is held for dual-control approval (ASVS 2.3.5)
158
+ instead of executing inline. A distinct second approver must release it via ``/approvals``."""
159
+
160
+ approval_id: str
161
+ operation: str
162
+ status: str = "pending_approval"
163
+ detail: str
164
+
165
+
166
+ class PendingApprovalInfo(BaseModel):
167
+ """One open (still-pending, unexpired) approval request in the approver's queue."""
168
+
169
+ id: str
170
+ operation: str
171
+ label: str
172
+ requester: str
173
+ requested_at: float
174
+ expires_at: float | None = None
175
+
176
+
177
+ class ApprovalList(BaseModel):
178
+ approvals: list[PendingApprovalInfo]
179
+
180
+
181
+ class ApprovalDecisionResult(BaseModel):
182
+ """The outcome of approving or rejecting a pending request. On approval, ``result`` carries the
183
+ executed operation's summary (e.g. ``{"requeued": 3}``)."""
184
+
185
+ operation: str
186
+ requested_by: str
187
+ approved_by: str | None = None
188
+ rejected_by: str | None = None
189
+ result: dict[str, Any] | None = None
190
+
191
+
192
+ class ReloadRequest(BaseModel):
193
+ # Directory of code-first config modules to load + apply. Optional: omitted/None reloads the
194
+ # server's startup --config dir. Any value must resolve within an allowed reload root (the
195
+ # startup dir or [api].config_reload_roots) — the loader executes Python from it. Length-bounded
196
+ # (ASVS 1.3.3); the allow-list confinement remains the real control.
197
+ config_dir: str | None = Field(None, max_length=4096)
198
+ # dry_run: validate the graph against THIS environment (loads + build-checks connectors, which
199
+ # resolves env() values for the target) and report the result WITHOUT swapping the live graph.
200
+ # The promote pre-flight: catch a missing env value / bad spec before it goes live.
201
+ dry_run: bool = False
202
+
203
+
204
+ class ReloadResult(BaseModel):
205
+ """Summary of the graph that is now live after a reload — or, for a dry run, the graph that
206
+ *would* go live (``dry_run=True``; ``running`` then reflects the still-current graph)."""
207
+
208
+ inbound: int
209
+ outbound: int
210
+ routers: int
211
+ handlers: int
212
+ running: bool
213
+ dry_run: bool = False
214
+
215
+
216
+ class ConnectionRow(BaseModel):
217
+ """One endpoint (a channel's source, or one of its destinations) for the connections
218
+ dashboard. Fields are role-dependent: source rows carry read/inbound-errored/idle and the
219
+ listen peer/port; destination rows carry queue/written/dead/backlog/delivered-age and the
220
+ remote peer/port. Unused fields are None so the UI can render blanks."""
221
+
222
+ role: str # "source" | "destination"
223
+ channel_id: str
224
+ channel_name: str
225
+ destination: str | None # destination name; None for the source row
226
+ name: str # display name
227
+ status: str # "running" | "stopped"
228
+ direction: str # "in" (source) | "out" (destination)
229
+ method: str # connection method/protocol, e.g. MLLP / File / TCP / REST
230
+ peer: str | None # MLLP host or file directory
231
+ port: int | None
232
+ queue_depth: int | None
233
+ idle_seconds: float | None
234
+ alerts_active: int # stubbed 0 until the alerts feature exists
235
+ errored: int | None # source: inbound errors; destination: dead-lettered
236
+ read: int | None # source only: inbound received
237
+ written: int | None # destination only: delivered
238
+ backlog_seconds: float | None # destination only; None = unknown/stalled
239
+ delivered_age_seconds: float | None # destination only; age of oldest queued item
240
+ simulated: bool | None = None # destination only; True = egress-suppressed shadow lane (#15)
241
+
242
+
243
+ class StatsResponse(BaseModel):
244
+ outbox_by_status: dict[str, int]
245
+ # NOT-DONE rows (pending|inflight) across every stage (ingress + routed + outbound) — a
246
+ # whole-pipeline drain gauge, vs outbox_by_status which sees only the outbound stage. Defaults to 0
247
+ # so a client reading an older engine (no field) degrades gracefully.
248
+ in_pipeline: int = 0
249
+
250
+
251
+ class Health(BaseModel):
252
+ status: str = "ok"
253
+ # WP-L3-07 (ASVS 13.4.6): the build version is a fingerprinting detail, disclosed only to an
254
+ # authenticated caller. A tokenless liveness probe gets ``status`` with ``version`` omitted/None.
255
+ version: str | None = None
256
+
257
+
258
+ class EngineInfo(BaseModel):
259
+ version: str
260
+ uptime_seconds: float
261
+ pid: int
262
+ channels_total: int
263
+ channels_running: int
264
+ channels_stopped: int
265
+ outbox_by_status: dict[str, int]
266
+
267
+
268
+ class DbInfo(BaseModel):
269
+ path: str
270
+ size_bytes: int # db file + -wal + -shm
271
+ disk_free_bytes: int
272
+ journal_mode: str
273
+ messages: int
274
+ events: int
275
+ audit: int
276
+
277
+
278
+ class SystemStatus(BaseModel):
279
+ engine: EngineInfo
280
+ db: DbInfo
281
+
282
+
283
+ class IntegrityResult(BaseModel):
284
+ ok: bool
285
+ detail: str
286
+
287
+
288
+ class ClusterStatus(BaseModel):
289
+ """This node's cluster posture (Track B Step 7), from the cheap in-memory coordinator gates — no DB
290
+ round-trip. ``clustered`` is False on a single node (NullCoordinator), where ``is_leader`` is always
291
+ True and ``config_version`` is 0. ``role`` (Workstream A5) is the operator-facing active-passive
292
+ role: ``"single-node"`` when not clustered, else ``"primary"`` when this node is the leader (it runs
293
+ the graph) or ``"standby"`` when it is a warm follower (no listeners bound, no workers running)."""
294
+
295
+ node_id: str
296
+ clustered: bool
297
+ is_leader: bool
298
+ role: str
299
+ config_version: int
300
+
301
+
302
+ class ClusterNode(BaseModel):
303
+ """One node in the cluster (Track B Step 7). ``is_leader`` is the DERIVED live leader (the durable
304
+ ``nodes.is_leader`` heartbeat flag filtered for freshness, so a crashed ex-leader's stale flag is not
305
+ reported). ``started_at``/``last_seen`` are epoch seconds, ``None`` only for the single-node
306
+ synthetic self-entry."""
307
+
308
+ node_id: str
309
+ host: str | None
310
+ pid: int | None
311
+ status: str
312
+ started_at: float | None
313
+ last_seen: float | None
314
+ is_leader: bool
315
+
316
+
317
+ class ClusterNodeList(BaseModel):
318
+ """Cluster membership (Track B Step 7). ``leader_node_id`` is the node_id of the single derived
319
+ leader (from the ``nodes.is_leader`` heartbeat flag), or ``None`` if no fresh node currently holds
320
+ it. ``lease_owner`` / ``lease_expires_at`` (Workstream A5) are the **authoritative** leadership-lease
321
+ state — who holds the self-fencing lease and the DB-clock epoch at which it expires (when a standby
322
+ could acquire if the leader stops renewing). ``lease_owner`` normally equals ``leader_node_id``; a
323
+ brief divergence during failover is expected (the lease is the source of truth). ``lease_expires_at``
324
+ is ``None`` single-node (no lease)."""
325
+
326
+ nodes: list[ClusterNode]
327
+ leader_node_id: str | None
328
+ lease_owner: str | None
329
+ lease_expires_at: float | None
330
+
331
+
332
+ class AiPolicy(BaseModel):
333
+ """The effective AI-assistance policy for the IDE gate. ``assist_permitted`` is the
334
+ identity-dependent bit: ``True``/``False`` when the caller's RBAC can be evaluated, ``None`` when
335
+ no/invalid token under enabled auth made it unknown (a tokenless read still gets mode/scope, so a
336
+ central ``off`` is honored)."""
337
+
338
+ mode: AiMode
339
+ data_scope: AiDataScope
340
+ environment: str | None # the free-form active-environment NAME (ADR 0017)
341
+ data_class: DataClass | None = None # PHI posture (synthetic|phi), if resolvable
342
+ production: bool | None = None # production-tier posture, if resolvable
343
+ assist_permitted: bool | None
344
+ reason: str | None = None
345
+
346
+
347
+ class ConnectionMetadata(BaseModel):
348
+ """Static metadata for one connection (operability Tier 4). ``metadata`` is the operator's
349
+ free-form label table (owner / runbook / environment); ``settings`` is **secret-scrubbed**
350
+ (``env()`` refs shown as ``{"env": key}``, inline credentials redacted). No live probe — use
351
+ ``POST /connections/{name}/test`` for reachability."""
352
+
353
+ name: str
354
+ direction: str # "in" (inbound) | "out" (outbound)
355
+ method: str # connector type, e.g. "mllp" / "file" / "rest"
356
+ running: bool
357
+ router: str | None = None # inbound only
358
+ metadata: dict[str, Any] | None = None # operator labels
359
+ settings: dict[str, Any] # secret-scrubbed view
360
+ simulated: bool | None = None # outbound only; True = egress-suppressed shadow lane (#15)
361
+
362
+
363
+ class ConnectionTestResult(BaseModel):
364
+ """Result of ``POST /connections/{name}/test`` — a reachability probe that sends no real payload.
365
+ ``supported`` is False when the connector has nothing external to probe (a bound listen source, a
366
+ timer); ``success`` is the reachability outcome; ``detail`` carries the failure / not-supported
367
+ reason."""
368
+
369
+ name: str
370
+ direction: str # "in" | "out"
371
+ supported: bool
372
+ success: bool
373
+ duration_ms: float
374
+ detail: str | None = None