Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
aa11acd
feat(asvs): prove an absence claim bites, not just that its pattern m…
wshallwshall Aug 6, 2026
089bcda
docs(backlog): flip #1006 to shipped -- the absence gate can now prov…
wshallwshall Aug 6, 2026
3450c3f
test(connscale): dynamic contiguous inbound-port allocation, drop the…
wshallwshall Aug 6, 2026
43f0595
backlog: close #1014 -- dynamic connscale port allocation ships, flak…
wshallwshall Aug 6, 2026
7cdfa52
feat(anon): structural PHI-shape detectors + coverage report + token-…
wshallwshall Aug 6, 2026
dfa3d6f
docs(backlog): flip #331 banner to SHIPPED, worded to the default beh…
wshallwshall Aug 6, 2026
9409984
test(sandbox): a static ast guard pins the codec+worker import bounda…
wshallwshall Aug 6, 2026
0e2a050
backlog: flip #346 to SHIPPED -- the static ast import-boundary guard…
wshallwshall Aug 6, 2026
90dc30a
fix(tls): route four insecure-TLS escape cells through the ADR-0092 c…
wshallwshall Aug 6, 2026
bb8ee56
docs(backlog): flip #329 to shipped -- four insecure-TLS cells clampe…
wshallwshall Aug 6, 2026
4e5df59
fix(dev): anchor setup-leak-gate.ps1 to its own checkout, not the cwd…
wshallwshall Aug 6, 2026
e4bf77f
fix(coord): anchor alloc.ps1 and claim.ps1 to their own checkout (BAC…
wshallwshall Aug 6, 2026
a042c0f
docs(backlog): record the test-isolation trap #1060's fix walked into…
wshallwshall Aug 6, 2026
8282998
Merge branch 'main' into claude/analyze-issues-1057-1060-1063-9e9220
wshallwshall Aug 7, 2026
b78e0aa
Merge branch 'main' into claude/analyze-issues-1057-1060-1063-9e9220
wshallwshall Aug 7, 2026
27d7746
Merge branch 'main' into fix-1006-absence-mutation
wshallwshall Aug 7, 2026
61d7374
Merge branch 'main' into fix-1014-connscale-ports
wshallwshall Aug 7, 2026
bdb7cbf
Merge branch 'main' into fix-331-anon-phi-detectors
wshallwshall Aug 7, 2026
84eec22
docs(CONNECTIONS): repoint the serial/ASTM decline at the archive; #2…
wshallwshall Aug 7, 2026
a574fbd
Merge remote-tracking branch 'origin/fix-1006-absence-mutation' into …
wshallwshall Aug 7, 2026
e9d3b55
Merge remote-tracking branch 'origin/fix-1014-connscale-ports' into m…
wshallwshall Aug 7, 2026
19e467c
Merge remote-tracking branch 'origin/fix-331-anon-phi-detectors' into…
wshallwshall Aug 7, 2026
746cc2a
Merge remote-tracking branch 'origin/fix-346-sandbox-import-static' i…
wshallwshall Aug 7, 2026
a8d0aee
Merge remote-tracking branch 'origin/fix-329-insecure-tls-cells' into…
wshallwshall Aug 7, 2026
383a5c4
Merge remote-tracking branch 'origin/claude/analyze-issues-1057-1060-…
wshallwshall Aug 7, 2026
9e57b6c
Merge remote-tracking branch 'origin/claude/connections-pointer-rot-2…
wshallwshall Aug 7, 2026
8476756
Merge branch 'main' into merge-train-a
wshallwshall Aug 7, 2026
6d3e013
Merge branch 'main' into merge-train-a
wshallwshall Aug 7, 2026
e12bb78
Merge branch 'main' into merge-train-a
wshallwshall Aug 7, 2026
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
40 changes: 26 additions & 14 deletions docs/BACKLOG.md

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions docs/CONNECTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -840,9 +840,11 @@ poll/write shape against a remote server, selected by an internal `protocol` set
(`pip install 'messagefoundry[sftp]'`, lazily imported so an install that never uses SFTP skips it).
**Host-key verification is ON by default** (the system host keys plus an optional extra `known_hosts`,
paramiko `RejectPolicy`); an unknown key is **refused** unless `MEFOR_ALLOW_INSECURE_TLS` is set (and
loudly logged when it is). **This one cell reads the raw escape and is *not* clamped** — unlike the
`tls_verify` / `encrypt` cells elsewhere in this document, the variable still works here on a
production-PHI enforcing instance, so it is the SFTP setting to audit for rather than assume inert.
loudly logged when it is). **Since #329 this cell routes the escape through the clamped
`weakened_tls_escape_permitted_here()`** — like the `tls_verify` / `encrypt` cells elsewhere in this
document, so on a production-PHI enforcing instance the escape is inert and an unknown host key stays
refused (`RejectPolicy`) even with the variable set; it takes effect only on a non-enforcing / non-PHI
instance.
- **`Ftp(...)`** — stdlib `ftplib`, **no extra**: `tls=False` is plain FTP, `tls=True` is **FTPS**
(explicit TLS + `PROT P`, encrypting the control *and* data channels). FTPS **verifies the server
certificate and hostname by default** (a verifying `SSLContext`, not ftplib's no-verify fallback).
Expand Down Expand Up @@ -2433,7 +2435,7 @@ Legend: ✅ native · ~ partial / via extension / via another transport · ❌ n
| **IBM MQ / MSMQ** | ~ | ❌ | ✅ | ❌ | not on roadmap |
| **Kafka / streaming** | ~ | ❌ | ✅ | ❌ | not on roadmap |
| **DICOM** (imaging) | ✅ | ~ | ✅ | ✅ | `DICOM-IN` C-STORE SCP (Phase 1) + `DICOM-OUT` C-STORE SCU/C-ECHO + `DICOMWEB-OUT` STOW-RS all shipped (ADR 0025); DICOMweb send exceeds both incumbents |
| **Serial (RS‑232)** + X/Y‑Modem/Kermit + **ASTM E1381/E1394/E1318** | ~ | ❌ | ✅ | ❌ | **declined-by-design (v0.2+)** — legacy/niche lab-instrument connectivity, no feed demand ([BACKLOG.md](BACKLOG.md) #27) |
| **Serial (RS‑232)** + X/Y‑Modem/Kermit + **ASTM E1381/E1394/E1318** | ~ | ❌ | ✅ | ❌ | **declined-by-design (v0.2+)** — legacy/niche lab-instrument connectivity, no feed demand ([BACKLOG #27](archive/backlog/BACKLOG-CLOSED.md#27-serial-rs-232--astm-e1381e1394e1318--decision-decline-unless-lab-analyzer-demand-no-build)) |
| **FHIR** endpoint/client | ✅ | ✅ | ✅ | ~ | `FHIR-OUT` shipped (`FHIR()`, ADR 0022) + SMART Backend Services client auth (ADR 0024); the inbound **server facade** is deferred (BACKLOG #20) |
| **Internal channel‑to‑channel** | ✅ | ✅ | ✅ | ✅ | the routing graph (wired by name) — plus two first-class internal inbounds: `Loopback()` (a captured reply) and `PassThrough()` (1:N internal re-ingress), ADR 0013 |
| Printer / command‑line / screen‑scrape | ~ | ❌ | ✅ | ❌ | not on roadmap (niche) |
Expand Down
14 changes: 8 additions & 6 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,12 +410,12 @@ With it set, these otherwise-refused settings become permitted (each logs a loud
- DATABASE destination / store: `Encrypt=false` or `TrustServerCertificate=true` (SQL Server),
`[store].trust_server_certificate=true` / `[store].encrypt=false`. *(Clamped.)*
- Plain-FTP credentials. *(Clamped.)*
- RemoteFile SFTP: accepting an unknown host key. *(Not clamped — the raw escape still applies.)*
- RemoteFile SFTP: accepting an unknown host key. *(Clamped since #329.)*
- Cleartext SMTP submission on a **Direct** (S/MIME) destination. *(Not clamped; AUTH credentials over
cleartext stay refused outright either way.)*
- The non-connection cells that have nowhere to carry a per-hop declaration: the `[logging]` syslog/SIEM
forwarder and the API PHI-read serve hop *(both clamped)*, plus LDAPS, the webhook alert sink and the
AI-broker endpoint *(raw escape)*.
forwarder and the API PHI-read serve hop, plus LDAPS, the webhook alert sink and the
AI-broker endpoint. *(All clamped — LDAPS / the webhook sink / the AI broker since #329.)*

**Two limits worth stating plainly.** *(a)* Since [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md)
this variable has been **unhooked from the cleartext-hop authority** — that decision no longer reads it,
Expand All @@ -426,9 +426,11 @@ cleartext HTTP family are now governed only by a per-connection `cleartext_accep
factory parameter and no `connections.toml` key, so it is unreachable from config today. Refusal messages
that suggest it are ahead of the code.) *(b)* Where it does still apply it is mostly
**clamped** (ADR 0092 decision 2 / ADR 0148): it cannot relax a hop while `[security].enforcement =
enforce`, and for the MLLP/FTPS/plain-FTP and store-TLS cells the clamp additionally requires the instance
to be PHI — which is also the default. Either way, on the shipped posture those cells are inert; the
bullets marked *not clamped* are the exceptions that still honour the raw variable.
enforce`, and for the weakened-TLS / cleartext-escape cells that route through
`weakened_tls_escape_permitted` — at least the store-TLS, MLLP/FTPS and plain-FTP cells and, since #329,
LDAPS, the SFTP host key, the webhook sink and the AI broker — the clamp additionally requires the
instance to be PHI, which is also the default. Either way, on the shipped posture those cells are inert;
the bullets marked *not clamped* are the exceptions that still honour the raw variable.

**Never set `MEFOR_ALLOW_INSECURE_TLS` in production.** Its presence is the single **environment-variable**
switch that turns the remaining fail-closed verification checks into best-effort.
Expand Down
2 changes: 1 addition & 1 deletion docs/PHI.md
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@ with materially different PHI profiles, so they get their own rows; stream 4 is
| **7. `connection_event` table — DEFAULT ON** (`[diagnostics].connection_events = true`) | transport/lifecycle events per connection: `established`, `closed` (reason `eof` or `idle_timeout` — no path produces any other), `idle_timeout`, `at_capacity`, `peer_not_allowlisted`, `frame_oversize`, `framing_error`, `peer_reset`, the inbound-HTTP intake-auth refusals `intake_auth_failed` / `auth_subject_denied` / `auth_rate_limited` (ADR 0154 D6 — peer address and mode only; **never** the credential, a prefix of it, or its length. Each of these also writes a tamper-evident audit-log row — the copy that survives an operator turning this diagnostics stream off), plus the runner's `connection_lost` / `connection_restored`. That is the whole vocabulary, asserted in CI against the literal emit call sites in `transports/` and the pipeline runner **and** cross-checked against the console's own filter tuple. The MLLP, raw-TCP and HTTP listeners emit these; the **DICOM inbound C-STORE SCP** and the **`ISA`/`IEA`-framed X12 inbound** emit none — the runner injects the sink onto **every** source (`wiring_runner.py`, over the base-class `on_connection_event` field), so both connectors *have* the wiring and simply never call it — so this stream covers those three listeners plus the runner's outbound-lane transitions — not literally every connection. An X12 feed's connects, allow-list refusals and at-capacity refusals are therefore **absent** from this stream | rows: `ts`, `connection`, `transport`, `direction`, `kind`, `peer_host`, `message_id` (correlation hint), `reason` | the store database, **all three backends** | Corepoint-style transport diagnostics — "did the sender connect, and why did it drop" | `GET /events` and `GET /connections/{name}/events` under **`monitoring:read`** (**not** a PHI permission) with per-channel RBAC — an out-of-scope `connection=` is 403'd *and* audited — server-clamped to ≤1000 rows | `[retention].connection_event_retention_hours` (its own **hours** window); 0 inherits `[retention].messages_days`; both 0 = keep forever. Plain age `DELETE` (metadata-only) | **`reason` is free text that can carry sensitive fragments.** Defended twice — `safe_exc()` at the source, `safe_text(reason)[:200]` at the store — then cipher-encrypted (AAD `("connection_event","reason",connection,ts,kind)`). Every other column is config metadata; the table is documented **metadata-only** — never a frame, body or HL7 field value. Writes are a pure side observer: a bounded in-memory queue drained by a background task outside any handoff transaction, so a flood can never block a listener or pin a message disposition |
| **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 raw `MEFOR_ALLOW_INSECURE_TLS` escape is set (and then a warning is logged); note this path reads the **unclamped** escape, unlike the connectors. 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 |
| **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 |
| **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
Loading
Loading