Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ All notable changes to MessageFoundry are documented here. The format follows
is byte-identical. An **undeclared** proxy (`web_console_public_address` set, no
`tls_terminated_upstream`) deliberately still does not refuse — exposure there would be an inference —
but it no longer passes in silence: a new warning names single-factor admin directly on a PHI instance
with `require_mfa` off. ([BACKLOG #326](docs/BACKLOG.md), [ADR 0140](docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment)
with `require_mfa` off. ([BACKLOG #326](docs/archive/backlog/BACKLOG-CLOSED.md#326-mfa-at-exposure-refusal-reads-serve_ui-after-it-is-flipped-off), [ADR 0140](docs/adr/0140-two-acknowledged-production-phi-no-loosen-carve-outs-single-factor-admin-at-exposure-keyless-phi-in-production.md) amendment)
- **BREAKING — an `[[alerts.rules]]` block that routes to an unconfigured transport now refuses at
startup instead of being silently ignored.** `notifier_from_settings` returned early when **no**
transport was configured, *before* the loop that cross-checks each rule's `transports` against the
Expand Down Expand Up @@ -119,7 +119,7 @@ All notable changes to MessageFoundry are documented here. The format follows
displays the body it edits. **Who this would bite:** a deploying org whose admin had minted such a
custom role; that role would have exceeded its stated scope (HIPAA minimum-necessary) on first
deployment. No built-in role reaches it — `ADMINISTRATOR` and `OPERATOR` grant both permissions —
and every such read was already audited. ([BACKLOG #324](docs/BACKLOG.md))
and every such read was already audited. ([BACKLOG #324](docs/archive/backlog/BACKLOG-CLOSED.md#324-custom-role-with-messagesedit-alone-reads-raw-phi-via-the-ui-editor))

### Fixed
- **A CR/LF inside an exception message could forge a whole log line on the text sink.**
Expand Down Expand Up @@ -453,7 +453,7 @@ is additive / opt-in.
gate never fired (adopters are pip + IT-covered), and it only ever shipped unsigned. **The desktop console
is unaffected** — it stays installable via `pip install messagefoundry[console]` + the ADR 0032 Phase A
`gui-script` and shortcut scripts; only the *frozen, zero-Python* conveyance is gone. The zero-install
audience is now served by the browser ops dashboard ([BACKLOG #75](docs/BACKLOG.md)).
audience is now served by the browser ops dashboard ([BACKLOG #75](docs/archive/backlog/BACKLOG-CLOSED.md#75-browser--web-operator-monitor)).

### Changed
- **Server-DB store opens now skip the schema DDL batch when it already ran** ([ADR 0064](docs/adr/0064-schema-init-fastpath.md)).
Expand Down
2 changes: 1 addition & 1 deletion docs/AI-OFF-MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ with boxes.

---
*Source deliverable for MULTISESSION-PLAN-7 (L0). Not a decision record — the decisions live in
[`docs/BACKLOG.md`](BACKLOG.md) (#26/#48/#84/#92/#104/#105) and [ADR 0072](adr/0072-traced-dryrun-mode.md).*
[`docs/BACKLOG.md`](BACKLOG.md) (#84/#105), [`archive/backlog/BACKLOG-CLOSED.md`](archive/backlog/BACKLOG-CLOSED.md) (#26/#48/#92/#104), and [ADR 0072](adr/0072-traced-dryrun-mode.md).*
6 changes: 3 additions & 3 deletions docs/CONFIGURATION.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ on the shipped posture.
| **LDAPS** (`[auth].ad_tls_verify`, default true) | verifying directory bind |
| **`[logging]` TLS syslog forwarder** (`forward_tls_verify`, default true) | CA-anchored RFC 5425 hop |
| **Webhook alert sink** and the **AI-broker endpoint** | verifying https openers |
| **`[alerts]` SMTP sink** and the **per-user security-event notifier** | `email_tls_verify` defaults **true** ([#323](BACKLOG.md)) — one verifying context, two call sites. Deliberately carries **no** `RevocationHopGuard`: it is constructed outside the `active_hop_posture` scope those guards read, so a guard here could not see the instance posture. Its verify-off / cleartext deviations are gated by `[security].allow_unverified_alert_smtp_tls` at the serve gate instead |
| **`[alerts]` SMTP sink** and the **per-user security-event notifier** | `email_tls_verify` defaults **true** ([#323](archive/backlog/BACKLOG-CLOSED.md#323-smtp-tls-is-unverified-on-all-three-send-paths)) — one verifying context, two call sites. Deliberately carries **no** `RevocationHopGuard`: it is constructed outside the `active_hop_posture` scope those guards read, so a guard here could not see the instance posture. Its verify-off / cleartext deviations are gated by `[security].allow_unverified_alert_smtp_tls` at the serve gate instead |

For every hop in that table, revocation is exactly what the ASVS row above calls *delegated* — **your
PKI's or your egress proxy's job, written into your runbook**. The engine will not make you say so, and
Expand Down
2 changes: 1 addition & 1 deletion docs/DICOM.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ These are **declined or deferred by design** in [ADR 0025](adr/0025-dicom-codec-
| **MPPS** (Modality Performed Procedure Step) | Out of scope |
| **Query/Retrieve** — C-FIND / C-MOVE / C-GET | Out of scope (Mirth doesn't have these) |
| **DICOMweb QIDO-RS / WADO-RS** (query/retrieve over HTTP) | Out of scope; our DICOMweb is **store/send only** |
| **Inbound DICOMweb (STOW-RS) receiver** | Deferred — needs the inbound HTTP listener (ADR 0023, not yet authored; [backlog](BACKLOG.md) #7) |
| **Inbound DICOMweb (STOW-RS) receiver** | Deferred — needs the inbound HTTP listener (ADR 0023, not yet authored; [backlog](archive/backlog/BACKLOG-CLOSED.md#7-inbound-soaprest-listener--web-service-source-v03) #7) |
| **Pixel-data transformation / rendering**, `numpy` | Headers + SR only — also a security boundary (no decompression-bomb surface) |

---
Expand Down
2 changes: 1 addition & 1 deletion docs/PHI.md
Original file line number Diff line number Diff line change
Expand Up @@ -971,7 +971,7 @@ with materially different PHI profiles, so they get their own rows; stream 4 is
| **8. `alert_instance` table — default on wherever an `[alerts]` notifier exists** | resolvable operator alerts: `connection_stopped`, `queue_buildup`, `lane_stuck`, `message_stall`, `saturation`, `connection_error`, `content_match`, `storage_threshold`, `cert_expiry`, `secret_rotation`, `bootstrap_admin_expiring` (the UNCLAIMED first-run bootstrap admin nearing its auto-disable deadline — ASVS 6.4.5; its payload carries only the ISO deadline plus whole hours remaining, never the password or any secret), `integrity_drift`, `update_available`, `backup_failed`, `rcsi_off_degraded`, `leadership_acquired`, `dr_activated`, `gcm_invocations` (the per-key AES-GCM invocation bound crossing its 2^31 soft warn — ASVS 11.3.4; its payload carries a one-way `key_id` fingerprint plus counters, never key bytes) The three reachable **inverse** signals — `connection_restored`, `leadership_lost`, `dr_released` — are never rows here: `_record_state` routes an inverse through `_AUTO_RESOLVE` to `resolve_alert_instances_for`, never to `upsert_alert_instance`. (A fourth mapped key, `connection_started`, is emitted by no code path today.) | rows: `event_type`, `connection`, `severity`, `status`, `first_seen`, `last_seen`, `count`, `reason`, `acked_by`, `acked_at`, `resolved_at`, `suspended_until`, `escalation_tier` | the store database, **all three backends** | the operator alert list — acknowledge / resolve / suspend. Durable state is recorded **before** any suppression or throttle return, so a muted alert still leaves a record | `GET /alerts/active` under **`monitoring:diagnose`** (**not** a PHI permission) with the same per-channel scope; ack/resolve/suspend/**resume** are POSTs on the same tier, and the separate read-only `GET /alerts/rules` view sits on its own gate | shares the connection-event window; **only RESOLVED instances are DELETEd**, by `resolved_at` — an open or acknowledged condition is never aged out from under an operator | **`reason` is free text** taken from the event's `detail`/`reason`/`label`: `safe_text(reason)[:200]` then cipher-encrypted (AAD `("alert_instance","reason",event_type,connection)` — the de-dup grain, so one AAD covers both the INSERT and the re-fire UPDATE). `content_match` is **PHI-free by contract**: the sink method takes no value parameter, only the connection, an operator label and an optional rule id |
| **9. `response` rows with `kind='ack_sent'` — DEFAULT ON** (`[diagnostics].response_sent = true`) | the ACK/NAK the engine returned to an inbound sender, under a sentinel destination `\x1fack:<inbound>` | rows: `ack_code` (`AA`/`AE`/`AR`/`CA`/`CE`/`CR`), `ack_phase` (`decode`/`parse`/`strict`/`ingest`), `outcome`, `body`, `detail` | the store database | "what did we actually reply, and why" — the operator's answer to a sender disputing an ACK | `GET /messages/{id}/responses` under `messages:read` + `require_phi_read`; the `body` only for a caller who also holds `messages:view_raw`; every read writes a `response.read` audit row | `body`, `detail` and `resp_headers` are set to `NULL` in place by `purge_message_bodies` on the message-body window, on all three backends | **PHI fail-safe:** the ACK **body** is stored **only when the store cipher is active** — on a keyless store it is `NULL` rather than plaintext — and every NAK passes no body at all, so the offending field value is never persisted. The disposition metadata (`ack_code`/`ack_phase`/`outcome`) is non-PHI and always captured; `detail` is `safe_text`-scrubbed, 200-char bounded and encrypted |
| **10. `[alerts]` webhook transport** (off by default — `webhook_url` unset) | one HTTPS POST per alert, carrying every non-underscore event key as JSON | JSON | the operator's webhook endpoint (Slack/Teams/PagerDuty/custom) | operator notification | **`https` only** — a plaintext `http://` webhook URL is refused at construction unless the `MEFOR_ALLOW_INSECURE_TLS` escape is set (and then a warning is logged); since #329 this path routes that escape through the clamped `weakened_tls_escape_permitted(posture)` (the instance posture threaded from the API lifespan), so on an enforcing-PHI instance the escape is inert and a cleartext webhook POST stays refused — the same clamp as the connectors, no longer the raw escape. Redirects are refused; an optional `webhook_allowed_hosts` egress allowlist gates the host | the endpoint's | **carries the alert's `detail`/`reason` free text** (`safe_exc()`-scrubbed at the emit sites, but **not** re-run through `safe_text` on this path). Internal `_`-prefixed keys (per-rule recipients, rule id, cooldown) are stripped before send, so recipient addresses never cross the wire |
| **11. `[alerts]` SMTP transport — operator alert list** (off unless `email_smtp_host` + `email_from` + ≥1 `email_to`) | one email per alert; default subject `[MessageFoundry] <SEVERITY> <type> — <connection>`, default body every non-underscore event key as `k: v` | plain text (always kept — never HTML-only); optional HTML alternative | the operators' mailboxes | operator notification | `smtp_allowed_hosts` egress allowlist; the SMTP password comes from `MEFOR_ALERTS_EMAIL_PASSWORD` or a `[secrets]` provider, never the config file; per-send timeout `email_timeout` | the mail system's | carries the same `detail`/`reason` free text as the webhook. #138 operator templates are constrained to a **closed non-PHI variable allowlist** validated fail-closed at config load. **Transport posture:** `send_plain_email` builds an explicit **verifying** context (chain + hostname + strict RFC 5280, TLS 1.2 floor) via `tls_policy.build_smtp_tls_context()` and passes it to `starttls()`, anchored to the OS roots, `[alerts].email_tls_ca_file`, or `[tls].internal_ca_file` — the same factory the EMAIL and DIRECT *message destinations* use, so all three SMTP cells now share one policy ([#323](BACKLOG.md), closed 2026-08-02). Before that this call passed **no** context and Python's stdlib default applied (`ssl._create_stdlib_context` **is** `ssl._create_unverified_context` — `CERT_NONE`, `check_hostname = False`), leaving the hop encrypted but unauthenticated. There is still **no hop gradient or attestation on this path** — unlike the connectors, this cell is constructed outside the `active_hop_posture` scope, so its deviations (`email_use_tls = false`, or `email_tls_verify = false`) are gated by a `[security].allow_unverified_alert_smtp_tls` **acknowledgment switch at the serve gate** rather than by the clamped escape: on an enforcing PHI instance `serve` refuses to start without it, and permits + `AUDIT`-logs the start with it. Both deviations are named by `security_loosenings()` and reported by `messagefoundry check`'s `alert-smtp-tls` advisory |
| **11. `[alerts]` SMTP transport — operator alert list** (off unless `email_smtp_host` + `email_from` + ≥1 `email_to`) | one email per alert; default subject `[MessageFoundry] <SEVERITY> <type> — <connection>`, default body every non-underscore event key as `k: v` | plain text (always kept — never HTML-only); optional HTML alternative | the operators' mailboxes | operator notification | `smtp_allowed_hosts` egress allowlist; the SMTP password comes from `MEFOR_ALERTS_EMAIL_PASSWORD` or a `[secrets]` provider, never the config file; per-send timeout `email_timeout` | the mail system's | carries the same `detail`/`reason` free text as the webhook. #138 operator templates are constrained to a **closed non-PHI variable allowlist** validated fail-closed at config load. **Transport posture:** `send_plain_email` builds an explicit **verifying** context (chain + hostname + strict RFC 5280, TLS 1.2 floor) via `tls_policy.build_smtp_tls_context()` and passes it to `starttls()`, anchored to the OS roots, `[alerts].email_tls_ca_file`, or `[tls].internal_ca_file` — the same factory the EMAIL and DIRECT *message destinations* use, so all three SMTP cells now share one policy ([#323](archive/backlog/BACKLOG-CLOSED.md#323-smtp-tls-is-unverified-on-all-three-send-paths), closed 2026-08-02). Before that this call passed **no** context and Python's stdlib default applied (`ssl._create_stdlib_context` **is** `ssl._create_unverified_context` — `CERT_NONE`, `check_hostname = False`), leaving the hop encrypted but unauthenticated. There is still **no hop gradient or attestation on this path** — unlike the connectors, this cell is constructed outside the `active_hop_posture` scope, so its deviations (`email_use_tls = false`, or `email_tls_verify = false`) are gated by a `[security].allow_unverified_alert_smtp_tls` **acknowledgment switch at the serve gate** rather than by the clamped escape: on an enforcing PHI instance `serve` refuses to start without it, and permits + `AUDIT`-logs the start with it. Both deviations are named by `security_loosenings()` and reported by `messagefoundry check`'s `alert-smtp-tls` advisory |
| **12. Per-user security-event SMTP notifier** — **posture-mandatory on a PHI instance** | `account_locked`, `login_after_failures`, `password_changed`, `password_reset`, `email_changed`, `roles_changed`, `account_disabled`, `mfa_enabled`, `mfa_disabled`, `admin_action_new_ip` | plain-text email | the **affected user's own** mailbox | ASVS 6.3.5 / 6.3.7 out-of-band notification of security-relevant account changes | shares stream 11's SMTP transport and therefore its verifying context and its `[alerts].email_tls_*` knobs — note this is a **separate call site** (`pipeline/security_notify.py`), plumbed in its own right rather than inheriting by accident. On a PHI instance with auth enabled `serve` **refuses to start (exit 2) under `[security].enforcement = enforce`** when no effective channel exists; the explicit, **audited** opt-out is `[alerts].security_notifications_required = false` | the mail system's | the body carries the account username, a fixed description, optionally the failed-attempt count or the new email on file, and the source IP — **no message data, no secrets**. Dispatch is a bounded background queue; a failed send is logged, never raised (the event is still in `audit_log`) |
| **13. `LoggingAlertSink` fallback** (when no `[alerts]` transport is configured) | every alert **this state-less sink implements**, at `WARNING` — `leadership_lost` / `dr_released` at `INFO`, and `connection_restored` is a **deliberate no-op** (a recovery needs no page and there is no instance to auto-resolve), so a lane recovery produces no record on this stream at all. `content_match` exists only on `NotifierAlertSink` and has no fallback-path record | — | folds into stream 1 | so alerts are never silent | inherits stream 1's | inherits stream 1's | includes the `detail`/`reason` free text, and therefore inherits stream 1's filters, ACL, forwarder and retention |

Expand Down
2 changes: 1 addition & 1 deletion docs/POSITIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ remote production database. The durable, code-first core is the same at every si

- **Not a visual / drag-drop transformer.** Code-first authoring *is* the differentiator — a guided
editor that drifts toward declarative *logic* authoring is an anti-goal (see
[BACKLOG.md](BACKLOG.md) #26).
[BACKLOG-CLOSED.md](archive/backlog/BACKLOG-CLOSED.md#26-visual--template-driven-channel-authoring--decision-decline-by-design-no-build) #26).
- **Not a broker-coupled architecture.** The staged database queue *is* the durability layer; we don't
require Kafka/JMS to be reliable.
- **Not chasing native per-core benchmark wins.** The honest trade is interpreted-language flexibility +
Expand Down
2 changes: 1 addition & 1 deletion docs/REMOTE-CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Auth is on by default; remote users sign in with local accounts (± TOTP MFA) or
loopback-behind-a-terminator topology in §3 **does** trip it, including when the default-on console
auto-degrades to JSON-only, and when `serve_web_console = false` disables the console outright: the
single-factor surface being protected is the JSON operator API. (This is a correction —
[BACKLOG #326](BACKLOG.md); the arm used to read the console flag, which the §3 auto-degrade clears
[BACKLOG #326](archive/backlog/BACKLOG-CLOSED.md#326-mfa-at-exposure-refusal-reads-serve_ui-after-it-is-flipped-off); the arm used to read the console flag, which the §3 auto-degrade clears
first, and would have missed exactly that topology on first deployment.) An **undeclared** proxy —
a set `[security].web_console_public_address` with no `tls_terminated_upstream` — is outside the
predicate and does **not** refuse: nothing was declared, so exposure would be an inference. It gets
Expand Down
2 changes: 1 addition & 1 deletion docs/SECURITY-LOOSENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ trail.
factor over the network. This ack **downgrades that refusal to a loud, audited warning** (the same
warn-and-start `enforcement = warn` takes, but scoped to this one control), so the instance boots
single-factor while staying at `enforce`.
- **Scope correction ([BACKLOG #326](BACKLOG.md)):** "a declared reverse proxy" above means exactly
- **Scope correction ([BACKLOG #326](archive/backlog/BACKLOG-CLOSED.md#326-mfa-at-exposure-refusal-reads-serve_ui-after-it-is-flipped-off)):** "a declared reverse proxy" above means exactly
`[api].tls_terminated_upstream` — the bind-and-proxy posture, **independent of the browser console**. The
shipped predicate additionally required the console to be *served*, which the ADR 0143 auto-degrade had
already turned off, so a loopback-behind-a-declared-proxy instance would not have reached this refusal at
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0024-smart-backend-services-token-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
(the at-least-once/purity invariant the per-request mint preserves), ADR 0023 (the **inbound** FHIR server
facade — a *separate, deferred, not-yet-written* decision; this ADR is client-only and needs nothing from it),
[CLAUDE.md](../../CLAUDE.md) §6/§8/§9 (asyncio-non-blocking, HL7/FHIR version-explicitness, never-log-secrets/PHI),
[FEATURE-MAP.md](../FEATURE-MAP.md) §7, [BACKLOG.md](../BACKLOG.md) #35.
[FEATURE-MAP.md](../FEATURE-MAP.md) §7, [BACKLOG-CLOSED.md](../archive/backlog/BACKLOG-CLOSED.md#35-smart-backend-services-token-provider--fhirrest-client-oauth2-p2--adr-0024) #35.

## Context

Expand Down
Loading
Loading