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
Binary file modified assets/docs/MessageFoundry-Adoption-Guide.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Architecture.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Configuration.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Deployment-Guide.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-HA-Reference-Architecture.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Install-Guide.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Remote-Console-Customer-Guide.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Security-Overview.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Users-and-Security.pdf
Binary file not shown.
Binary file modified assets/docs/MessageFoundry-Windows-Service.pdf
Binary file not shown.
37 changes: 23 additions & 14 deletions assets/docs/_md/Adoption-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ operate it over a localhost HTTP API. See [ARCHITECTURE.md](ARCHITECTURE.md) for
and who are comfortable validating a pre-1.0 tool against their own traffic before trusting it. A single
engine node on a trusted network is the simplest pilot; **native TLS** (API + MLLP) and an opt-in
**active-passive failover** cluster on a shared server-DB store (PostgreSQL or SQL Server) are both built when you need them (see
§2/§6/§14). What is genuinely *not* there yet is MFA, off-box log shipping, and a de-identification
framework — track those items (§2) and pilot the parts that are ready. (Horizontal *active-active*
§2/§6/§14), as are **native TOTP MFA** for local accounts and **off-box log/audit forwarding** to a
syslog/SIEM collector. What is genuinely *not* there yet is a de-identification
framework — track that item (§2) and pilot the parts that are ready. (Horizontal *active-active*
scale-out was dropped and is not a planned milestone; active-passive HA is the supported HA model.)

---
Expand Down Expand Up @@ -102,8 +103,7 @@ use the table below alongside them when planning.

**The early-adopter bargain, stated plainly:** you get a durable engine with native TLS, real auth,
opt-in active-passive failover, and a real validation toolchain, in exchange for validating capacity on
your own hardware and supplying the operational pieces that aren't built yet (MFA, off-box logs,
de-identification). If that trade is acceptable, the rest of this guide is your playbook.
your own hardware and supplying the one operational piece that isn't built yet (de-identification). If that trade is acceptable, the rest of this guide is your playbook.

---

Expand Down Expand Up @@ -207,11 +207,13 @@ host.
| `harness` | PySide6 | Running the standalone test-harness GUI |
| `sftp` | paramiko | SFTP connectors |
| `sqlserver` | `aioodbc` **+ OS-level Microsoft ODBC Driver 18** | The SQL Server *store* backend (`backend=sqlserver`, production) and the DATABASE connector family. |
| `dev` | pytest/ruff/mypy/httpx | Development & CI |
| `dev` | pytest (+ asyncio/timeout/rerunfailures plugins), ruff, mypy | Development & CI |

> ⚠️ There is **no friendly preflight** for the `postgres` extra: if you set `backend=postgres` but
> forgot `pip install 'messagefoundry[postgres]'`, you get a raw `ImportError` at startup instead of a
> clear message. Install the extra with the backend.
> ⚠️ `messagefoundry check` cannot catch a missing `postgres` extra — it validates a *declared*
> backend without dialing the database. If you set `backend=postgres` but forgot
> `pip install 'messagefoundry[postgres]'`, the engine fails at startup with a clear error naming the
> extra and the exact pip command (the `sqlserver` backend behaves the same way). Install the extra
> with the backend.

### Start your own config repo (`messagefoundry init`)

Expand Down Expand Up @@ -370,9 +372,10 @@ Guidance for a clean first flow:
## 6. Security & PHI hardening before real data

Full references: **[SECURITY.md](SECURITY.md)**, **[PHI.md](PHI.md)**, and **[DEPLOYMENT.md](DEPLOYMENT.md)**
(network exposure). MEFOR ships real auth, RBAC, audit, opt-in at-rest encryption, and **native TLS**
(API + MLLP, with a fail-closed off-loopback bind guard); the remaining transport gaps are **MFA** and
**off-box log shipping**. Complete this checklist **before any real PHI flows**:
(network exposure). MEFOR ships real auth, RBAC, audit, opt-in at-rest encryption, **native TLS**
(API + MLLP, with a fail-closed off-loopback bind guard), **native TOTP MFA** for local accounts, and
**off-box log/audit forwarding**; the remaining transport gap is **TLS for the raw TCP and X12
connectors**, which are plaintext-only. Complete this checklist **before any real PHI flows**:

- [ ] **API off-loopback requires native TLS.** The API binds `127.0.0.1` by default. To reach it from
another host, configure **in-process TLS** (`[api].tls_cert_file` + `[api].tls_key_file`,
Expand Down Expand Up @@ -637,7 +640,11 @@ as a **separate, later** step so you retain a fallback.
**Verify-it-runs (after every start/restart):** `GET /health` → `{"status":"ok"}`, send a synthetic
message, and confirm the **"wiring started"** banner in `service.out.log`.

**Monitoring surfaces (poll the API + parse logs — there is no Prometheus exporter):**
**Monitoring surfaces (scrape `/metrics` with Prometheus; poll the JSON API and watch the logs for the rest):**

- `/metrics` — **Prometheus text exposition**: per-connection counters, queue-depth / in-pipeline /
oldest-pending gauges, latency histograms, and host CPU/memory. Gated by `monitoring:read` exactly
like `/stats`, so point your scraper at it with a service token.
- `/stats` — outbox counts by status.
- `/status` — DB size vs disk free, journal mode, counts. **Scrape db-size-vs-disk-free.**
- `/status/integrity-check` — on-demand store integrity.
Expand All @@ -654,8 +661,10 @@ message, and confirm the **"wiring started"** banner in `service.out.log`.

**Log management:** logs land under `<DataDir>\logs` via NSSM. Configure rotation, keep the level at
`INFO` or above (DEBUG can leak PHI — §6), treat `service.out/err.log` as **potential-PHI artifacts**
(ACL them; don't ship them off-box — off-box logging is deferred), and include them in your retention
policy.
(ACL them; rather than shipping the raw files, use the built off-box evidence copy — the
`[logging].forward_*` syslog/SIEM stream, to which PHI redaction applies exactly as it does to stdout —
and set `forward_protocol = "tls"`, since the transport default is UDP), and include them in your
retention policy.

**Graceful drain for maintenance:** stopping the service (Ctrl+C / NSSM stop) triggers the ASGI
lifespan to call `engine.stop()` for a clean drain. Always **drain → stop → back up → change → restart
Expand Down
12 changes: 8 additions & 4 deletions assets/docs/_md/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ WebSocket API (FastAPI/uvicorn). The same API serves three deployments without c
process (or a browser) that reaches the engine only over the HTTP API, never by importing it.
- **Local daemon** — engine runs as a Windows service / Linux daemon; the web console attaches over
the API. See [SERVICE.md](SERVICE.md) for the Windows service setup (NSSM).
- **Remote** — same API over the network (Phase 2+, with auth/TLS).
- **Remote** — the same API over the network. Supported today, **off by default**: set
`[security].local_access_only = false` and name the bind address in `[security].listen_address`,
with TLS either **in-process** (`[api].tls_cert_file`) or **terminated upstream** at a declared
trusted proxy (`[api].tls_terminated_upstream` + `[api].trusted_proxies`). Auth is always required,
and an off-loopback plaintext bind is refused at startup.

We deliberately did **not** start with two separate processes + hand-rolled IPC. The
logical boundary (library API) comes first; physical split is a deployment choice.
Expand All @@ -87,7 +91,7 @@ flowchart TB

subgraph ENGINE["Engine — headless asyncio service (no GUI imports)"]
PIPE["pipeline/ — RegistryRunner<br/>listener · router · transform · delivery workers"]:::engine
TRANS["transports/ — connector registry<br/>MLLP · File · X12 · DICOM C-STORE SCP (TCP/HTTP/DB planned)"]:::engine
TRANS["transports/ — connector registry<br/>MLLP · TCP · HTTP · File · SFTP/FTP · REST · SOAP · Database · X12 · FHIR · DICOM · Email · …"]:::engine
PARSE["parsing/ — pure HL7/X12/DICOM library<br/>python-hl7 · hl7apy · X12 codec · DICOM codec · base64 binary codec"]:::engine
STORE[("store/ — staged queue<br/>SQLite WAL · SQL Server · AES-256-GCM")]:::engine
CFG["config/ — code-first wiring<br/>Connections · Routers · Handlers · environments/"]:::engine
Expand Down Expand Up @@ -360,12 +364,12 @@ hashed resolution lives in the committed **`uv.lock`** / **`requirements.lock`**

**Optional extras**

- `harness` → `PySide6` (LGPL — chosen so the OSS test harness GUI is distributable; not PyQt) + `httpx` + `truststore` (was `[console]` before the desktop console was retired — BACKLOG #103)
- `harness` → `PySide6` only (LGPL — chosen so the OSS test harness GUI is distributable; not PyQt); the harness is the sole PySide6 consumer (was `[console]` before the desktop console was retired — BACKLOG #103; its HTTP client, `httpx` + `truststore`, is now a base runtime dependency rather than part of this extra)
- `sqlserver` → `aioodbc` (production SQL Server store; also needs the OS-level Microsoft ODBC
Driver 18 for SQL Server, which is not pip-installable; lazy-imported so SQLite-only installs skip it)
- `dicom` → `pydicom` + `pynetdicom` (DICOM codec — headers/SR only, no numpy — and the C-STORE SCP
inbound connector; ADR 0025; lazy-imported so non-DICOM installs skip it)
- `dev` → `pytest`, `pytest-asyncio`, `httpx` (ASGI test client for the API), `ruff`, `mypy`
- `dev` → `pytest` (with the `asyncio`, `timeout` and `rerunfailures` plugins), `ruff`, `mypy` (`httpx`, the ASGI test client, is now a base dependency and is no longer declared here)

**Build / tooling** — `hatchling` (build backend), Ruff (format + lint, no Black), mypy (strict),
pytest.
2 changes: 1 addition & 1 deletion assets/docs/_md/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ The one row that *does* vary:
| `tls_ciphers` | str | _unset_ | optional OpenSSL cipher string (default = the interpreter's secure defaults). |
| `tls_client_ca_file` | str | _unset_ | CA bundle to **require + verify client certs** (opt-in mTLS, e.g. the console). Requires `tls_cert_file`. |
| `tls_client_ca_pin` | str | _unset_ | optional lowercase-hex SHA-256 pin over the corresponding CA anchor PEM (`tls_client_ca_file`); a mismatch refuses at load + reload (ASVS 6.7.1); unset = no pin (dormant). |
| `tls_client_cert_identities` | map str→str | `{}` | **(#200, ADR 0002):** mTLS client-cert → MessageFoundry principal map. Meaningful only with in-process mTLS (`tls_client_ca_file` set, so uvicorn `CERT_REQUIRED`-verifies the peer): a **verified** peer cert's subject CN / SAN is resolved to an existing username through this **allow-list**, and that principal's RBAC authorizes the request — a service-to-service identity that carries no bearer token. Keys are the qualified cert name `CN:<commonName>` or `SAN:<type>:<value>` (e.g. `SAN:DNS:svc.internal`); values are existing usernames. **Deny-by-default:** an unmapped verified cert — or a spoofed CN not listed here — resolves to no identity and is denied. A structured map, so **TOML-only** (no env-string form). Empty (the default) disables cert-identity. **Honest caveat:** stock uvicorn does not surface the peer cert to the ASGI scope, so the resolver is **inert** until a TLS-extension-capable server/shim populates it. |
| `tls_client_cert_identities` | map str→str | `{}` | **(#200, ADR 0002):** mTLS client-cert → MessageFoundry principal map. Meaningful only with in-process mTLS (`tls_client_ca_file` set, so uvicorn `CERT_REQUIRED`-verifies the peer): a **verified** peer cert's subject CN / SAN is resolved to an existing username through this **allow-list**, and that principal's RBAC authorizes the request — a service-to-service identity that carries no bearer token. Keys are the qualified cert name `CN:<commonName>` or `SAN:<type>:<value>` (e.g. `SAN:DNS:svc.internal`); values are existing usernames. **Deny-by-default:** an unmapped verified cert — or a spoofed CN not listed here — resolves to no identity and is denied. A structured map, so **TOML-only** (no env-string form). Empty (the default) disables cert-identity. **LIVE, not inert ([ADR 0083](adr/0083-mtls-client-certificate-identity.md)).** Stock uvicorn does not surface the peer cert to the ASGI scope, so `serve` swaps in a scope-populating uvicorn HTTP-protocol subclass (`messagefoundry/api/tls_client_cert.py`) **whenever `tls_client_ca_file` and this map are both set** — that shim is what lets a `CERT_REQUIRED`-verified peer cert reach the resolver. Both conditions are required: set the map without the CA and nothing is verified, so nothing resolves. |
| `tls_client_cert_files` | list[str] | `[]` | **(ASVS 6.4.5):** PEM paths of **inbound service callers'** client certs you hold a copy of. Folded into the [`[cert_monitor]`](#cert_monitor) scan, so a caller's cert expiry is caught **even while that caller has stopped connecting** — the handshake-time check can only see a cert still being presented. These are certs the engine *verifies*, not ones it *presents*, so the served-cert scan cannot see them. Public certificates only (never a key); empty = off. |
| `trusted_proxies` | list[str] | `[]` | **`[BUILT]` (WP-15):** reverse-proxy IP(s) whose `X-Forwarded-For`/`-Proto` are trusted (uvicorn `forwarded_allow_ips`), so the audit/rate-limit source IP is the **real client**, not the proxy. **Empty = trust nothing** (the direct TCP peer is used). Set ONLY to the proxy's address(es), or XFF spoofing returns — every host inside an entry may declare its own source address, so a broad range (e.g. `10.0.0.0/8` on a LAN numbered out of 10/8) makes every workstation a trusted spoofer. `"*"` and unparseable entries are **refused at load** (uvicorn would silently treat the latter as a never-matching literal, collapsing every client to the proxy). |
| `tls_terminated_upstream` | bool | `false` | **`[BUILT]` (WP-15):** declare that a reverse proxy / load balancer terminates TLS in front of the engine. Lets a non-loopback bind satisfy the TLS gate **without** in-process TLS — but only when `trusted_proxies` is set (else refused at load). |
Expand Down
13 changes: 11 additions & 2 deletions assets/docs/_md/Deployment-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@ the engine needs, see [`ANTIVIRUS-FIREWALL.md`](ANTIVIRUS-FIREWALL.md).

MessageFoundry runs **on-premises** and binds **loopback (`127.0.0.1`) by default** — the engine API
(`[security].local_access_only = true`), and every inbound listener via `[inbound].bind_host`. In that
posture there is **no off-host network exposure**: nothing PHI-bearing crosses a wire. Everything below
is about what changes when you deliberately bind a channel to a routable address.
posture **no MessageFoundry listener is reachable off-host**.

**That is a claim about listeners only — it is not a claim that nothing PHI-bearing crosses a wire.**
Outbound delivery is **unaffected by the bind posture**: a loopback-bound engine still dials every
configured destination (MLLP, REST/SOAP, FHIR, DICOMweb, SFTP/FTPS, SMTP, Direct, a customer database),
still performs `db_lookup` / `fhir_lookup` reads, and still forwards syslog/SIEM logs and webhook
alerts — all off-box, and those delivery hops carry PHI. The outbound controls below (egress
allow-lists, the cleartext-hop rules, per-connection TLS) apply to a loopback deployment too.

Everything else below is about what changes when you deliberately bind a *channel* to a routable
address.

**Fail-closed rule (ADR 0002 §0):** a non-loopback **API** bind is *refused at startup* unless TLS is
configured (or an upstream TLS terminator is trusted), and every inbound **listen** type — MLLP, HTTP,
Expand Down
2 changes: 1 addition & 1 deletion assets/docs/_md/HA-Reference-Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Four points belong in the design rather than the runbook:

- **Synchronous commit belongs inside one site.** The staged pipeline makes several durable commits per message, and within an ordered lane they run serially — so every millisecond added to the commit round trip is paid several times over, per message. A synchronous replica across a WAN link multiplies the link's round trip into each of those commits. Keep synchronous replicas local, keep the remote replica asynchronous, and accept a small, *measured* recovery-point objective there instead.
- **If the database tier is a Windows failover cluster, quorum comes back — theirs, not ours.** An availability-group design wants an odd vote count with a witness outside both data centres, and zero votes at the recovery site so a flaky link can never cost the primary site its quorum. The engine has no dependency on any of it.
- **Cross-subnet listeners need DNS-side care.** The store's connection string is a fixed keyword list, so the engine cannot emit the multi-subnet-failover hint an availability-group client normally would. Configure the listener to register only the active subnet's address with a short record TTL — the documented workaround — and drill the cross-site connect path. An opt-in setting for the hint is on the backlog, not shipped.
- **Cross-subnet listeners need DNS-side care.** The store connection takes one Always On-aware ODBC keyword, and it has shipped: `[store].multi_subnet_failover` emits `MultiSubnetFailover=Yes`, so the driver races the subnets instead of serially waiting each one out. It is **opt-in and defaults to off**, it has not yet been exercised against a live cross-subnet availability group, and it covers the *store* connection only — the separate database-lookup connector builds its own connection string and does not emit it. So set the keyword *and* configure the listener to register only the active subnet's address with a short record TTL, then drill the cross-site connect path.
- **Backups and restores are yours.** On a server database the engine's DR backup covers **configuration only**; restoring the database itself is delegated to the DBA. Promotion at the recovery site is fail-closed on that: it requires an explicit per-activation attestation that the database was restored, plus a live provenance check on the restored store, and refuses otherwise.

## Third tier — the DR standby
Expand Down
7 changes: 5 additions & 2 deletions assets/docs/_md/Install-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ pip install "messagefoundry==0.3.2" # pin the exact version (core runti

Add extras only for what a host actually runs — `messagefoundry[postgres]` (PostgreSQL store),
`messagefoundry[sqlserver]` (SQL Server store + the DATABASE connectors, needs OS-level ODBC Driver 18),
`messagefoundry[harness]` (the PySide6 test harness GUI), `messagefoundry[sftp]` (SFTP connectors),
`messagefoundry[harness]` (PySide6 only — the harness itself is the separate `messagefoundry-harness`
distribution), `messagefoundry[sftp]` (SFTP connectors),
`messagefoundry[fhir]` (FHIR codec + FHIR outbound), `messagefoundry[dicom]` (DICOM C-STORE SCP + codec).

Key properties this install model gives you:
Expand Down Expand Up @@ -265,7 +266,9 @@ explicitly — a default-on console on an exposed bind quietly degrades to the J
and it additionally requires TLS, plus `[security].web_console_public_address` behind a declared
TLS-terminating proxy (see [REMOTE-CONSOLE.md](REMOTE-CONSOLE.md)). The former PySide6 desktop console
was retired in favour of this browser console (BACKLOG #103); PySide6 now backs only the standalone
test harness (`pip install "messagefoundry[harness]"`).
test harness, which ships as its own lockstep distribution — `pip install messagefoundry-harness`,
then `python -m harness`. (The engine wheel does not contain `harness/`; the `messagefoundry[harness]`
extra supplies only PySide6, and the harness distribution pulls it in for you.)

---

Expand Down
8 changes: 5 additions & 3 deletions assets/docs/_md/Remote-Console-Customer-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ No code changes are required; everything here is configuration.

## What you're setting up

The MessageFoundry **engine** runs on a server (usually as a Windows service). The **console** is a
separate desktop application that monitors and operates the engine. By default the engine only
accepts connections from the **same machine** (`127.0.0.1`), so the console must run on the server.
The MessageFoundry **engine** runs on a server (usually as a Windows service). Its operator UI is the
**web console** — a browser application the engine serves same-origin at `/ui`, on by default at a
loopback bind, shipping as a separate distribution (`pip install messagefoundry-webconsole`). There is
no desktop application to install. By default the engine only accepts connections from the **same
machine** (`127.0.0.1`), so the console is reachable only from the server until you expose it.

To use the console from another PC, you:

Expand Down
Loading