diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index c228972..5b39004 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -398,7 +398,7 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal | **#22b Alerts GUI page** | Med | **done** | S | ✅ Endpoint `/alerts/rules` shipped (#415); console Alerts page shipped (**PR #420**, merged 2026-06-20). Operator-parity polish, not a migration-unblocker. | | **#33 config-UX audit** | Med | do-next | M | Right timing to audit config sprawl — but value realizes **only if** the spawned follow-ups are funded. Keep bounded to transport/service config (logic stays code-first — never drift toward #26). | | **FEATURE-MAP refresh** | Med | do-next → **done** | S | Verified stale (Dead-Letters row); ASVS score synced to 212/0/0/133 (#425). | -| **#7 inbound HTTP listener (+ SOAP/FHIR-facade tail)** | Med | **done (first slice) / on-trigger (tail)** | XL | ✅ REST body-POST listener shipped `0.2.10` (ADR 0023, PR #624). **Deferred tail (on-trigger):** SOAP sync-reply, intake-auth, routing-metadata, inbound FHIR-server facade (#20) / DICOMweb receiver (#24). | +| **#7 inbound HTTP listener (+ SOAP/FHIR-facade tail)** | Med | **done (first slice) / on-trigger (tail)** | XL | ✅ REST body-POST listener shipped `0.2.10` (ADR 0023, PR #624). Intake auth (ADR 0154 increment A) and the SOAP sync-reply (`reply_from`, increment B) have since shipped. **Deferred tail (on-trigger):** routing-metadata, inbound FHIR-server facade (#20) / DICOMweb receiver (#24). | | **#16 eventlog (0021 half)** | Med | **done** | L | ✅ Shipped `0.2.3` (#541): metadata-only `connection_event` log + "Response Sent" ACK/NAK capture + console Event Log (jointly with #46). **ADR 0020 raw protocol-trace stays dropped** (raw-PHI-at-rest tier nobody named). | | **#34 per-connection retention** | Med | **done** | M | ✅ Shipped `0.2.9` (ADR 0027): per-connection `messages_days` / `dead_letter_days` over the global default, across all 3 backends. | | **least-priv service-account default** | Med | on-trigger | S | Account + ACLs already built; only the default flip off LocalSystem remains. Ride the next green `windows-service-smoke`. | @@ -881,8 +881,12 @@ functionally tested; the `ide` build job (PR #133) closes the compile gap, not t > inbound HTTP/1.1 listener (`transports/http_listener.py`, `ConnectorType.HTTP`) accepts a partner's body > POST, feeds it to payload-agnostic ingress (ADR 0004) as a `RawMessage`, and returns **respond-with- > receipt** under ACK-on-receipt — living in `transports/`, not `api/` (the one-way dependency rule holds). -> **Deferred tail (still open):** the synchronous **SOAP-envelope reply** (block-on-captured-downstream via -> the ADR 0013 query-response seam), **intake auth**, **routing-metadata**, and the inbound **FHIR-server +> **Intake auth SHIPPED** (2026-08-01, ADR 0154 increment A, `f2ef0ea9`) and the synchronous +> **SOAP-envelope reply SHIPPED** (ADR 0154 increment B, `reply_from`, PR #119) — naming +> `reply_from` blocks the HTTP turn until the named outbound's reply is captured **and committed**, +> then returns it as the response body. Its `capture_error_responses` gap is open: a partner 4xx +> still yields a fixed-JSON `502` rather than the partner's own status and body. +> **Deferred tail (still open):** **routing-metadata**, and the inbound **FHIR-server > facade** (#20) / **DICOMweb STOW-RS receiver** (#24). Original deferred-to-v0.3 description kept below for history. **Type:** feature — a new inbound transport direction (HTTP-listener source). Deferred by design: diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 2f6f68a..c855283 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -70,11 +70,15 @@ yet implemented — the suffix documents intent today.) both built now: the **payload-agnostic ingress** contract ([ADR 0004](adr/0004-payload-agnostic-ingress.md) — an inbound's `content_type` selects the HL7 path vs. a `RawMessage` route) *and* the **inbound HTTP listener** it needed ([ADR 0023](adr/0023-inbound-http-listener.md) — `Http(...)`, below), so a partner -can `POST` a JSON / XML / SOAP-envelope / FHIR body today and a Handler un-wraps it. What is still -deferred is the **SOAP-specific** half of `SOAP-IN` — the *synchronous* SOAP-envelope reply -(block-on-captured-downstream-reply) — plus **request authentication** on the intake socket (API key / -bearer): the listener's peer controls are the per-connection IP allowlist, TLS/mTLS, and the off-loopback -exposed gate. Routing on HTTP method/path/headers is likewise deferred (the Handler sees the body). +can `POST` a JSON / XML / SOAP-envelope / FHIR body today and a Handler un-wraps it. **Request +authentication on the intake socket has since shipped** ([ADR 0154](adr/0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md) +increment A): `intake_auth` — API key, bearer or mTLS subject — now joins the per-connection IP +allowlist, TLS/mTLS and the off-loopback exposed gate as a peer control. The **SOAP-specific** half of +`SOAP-IN` — the *synchronous* envelope reply — shipped with increment B (`reply_from`: the HTTP turn +blocks on the named outbound's captured, committed reply). Routing on HTTP method/path/headers remains +deferred (the Handler sees the body). For this listener's current state, settings and remaining gaps, +[`Http(...)`](#http-web-service-listener--http-inbound-only-adr-0023) below is the single authority — +this note is a pointer, not a second copy of that status. ## Authoring a connection @@ -537,6 +541,12 @@ routes a `Message`; `json`/`xml`/`text`/`fhir` route a `RawMessage` the Handler | `intake_auth_health` | `"require"` | whether `GET`/`HEAD` health probes must authenticate too. **`"allow"` is a real exemption**: it hands anyone who can reach the socket an unauthenticated "is MessageFoundry up, and where" oracle. Set it only when a load-balancer check cannot carry the credential. | | `intake_auth_rate_limit` | `10` | **failed** intake-auth attempts per minute per peer, then `429` + `Retry-After`. A *successful* authentication never consumes budget, so this bounds guessing without capping throughput. `None`/`0` disables. | | `intake_auth_rate_limit_global` | `60` | **failed** attempts per minute across all peers. Consulted only for peers with no successful authentication in the window, so one attacker cannot `429` an authenticated partner. `None`/`0` disables. | +| `reply_from` | — | **presence is the mode switch** ([ADR 0154](adr/0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md) D4): names the outbound whose **captured** reply becomes this request's HTTP body, turning the listener from fire-and-forget into a **proxy**. See *Synchronous captured-downstream reply* below. One knob, not two — a separate `sync_reply: bool` would admit a half-configured "mode on, no target" state that could only fail at runtime. | +| `reply_timeout` | `30.0` | seconds the HTTP turn may block waiting for that reply. Must be **positive** — an unbounded or zero budget is not a timeout. Expiry answers `reply_on_timeout` and **leaves the message flowing**; the engine does not cancel it. | +| `reply_on_timeout` | `"504"` | what to answer when `reply_timeout` expires: `504` (the partner did not answer in time) or `202` (demote to the ordinary receipt path). | +| `reply_content_type` | `"passthrough"` | `passthrough` echoes the partner's **own** captured `content-type`; a literal MIME type (must contain `/`) pins it instead. | +| `reply_on_empty` | `"204"` | answer for a captured but *deliberately empty* partner reply — `204` or `200`. An empty reply is distinguished from a missing one, never conflated. | +| `reply_write_timeout` | `30.0` | seconds to drain the (partner-sized) response body back to the caller. Must be **positive**. | > **TLS is confidentiality; intake auth is authentication — neither argues the other away.** A bare > `tls` + `tls_ca_file` means *"any certificate this CA ever signed"*, with **no subject binding at @@ -566,14 +576,29 @@ request didn't fully arrive within `receive_timeout`), `413` (over `max_body_byt `max_connections` — the connection is accepted, then refused and closed at the application layer). `GET`/`HEAD` are static, non-PHI health probes and write **no** ingress row; any other method is `405`. -**Not built (first slice, ADR 0023).** **Request authentication** on the intake socket (API key / bearer / -per-request token) is shaped but not shipped — the peer controls are the IP allowlist, TLS/mTLS and the -exposed gate, so an exposed listener belongs behind the reverse proxy that terminates auth. The -**synchronous downstream-reply** (SOAP-envelope block-on-captured-reply) path and **routing metadata** -(HTTP method / path / headers as Router inputs) are defined follow-ons — a Handler sees the body only. The -inbound **FHIR facade** (BACKLOG #20) and **DICOMweb STOW-RS receiver** (#24) are consumers of this -listener, each its own build. `POST /connections/{name}/test` reports `supported=false` — a bound listener -has nothing external to probe. +**Synchronous captured-downstream reply (`reply_from`, ADR 0154 increment B).** Naming `reply_from` makes +the HTTP turn **block** until the named outbound's reply has been captured **and committed to the store**, +then returns that reply as the response body — a proxy API rather than a receipt. The **committed row is +the sole authority** for the returned bytes: every in-process signal is only a latency hint and the waiter +re-reads the store, which is what keeps this correct under engine sharding, HA failover, every claim mode, +and any race between the capturing worker and the reader. A reply is therefore returned only once it is +durable and replayable. An inbound **without** `reply_from` keeps the `202`-on-receipt path above byte for +byte. + +Refused at **check time** (`messagefoundry check`) rather than at runtime: a `reply_from` naming no +deployed outbound; an outbound that does not capture responses; `reply_content_type="passthrough"` against +an outbound not capturing the content type; and — because either would make N concurrent callers queue +behind one lane and let a single stuck message time out every caller — an effective `ordering` of **FIFO** +or a **finite `max_attempts`** on the named outbound. Setting any `reply_*` knob **without** `reply_from` +is refused at the factory, since the path is off and the knob would never be read. + +**Not built.** **Routing metadata** (HTTP method / path / headers as Router inputs) is a defined follow-on +— a Handler sees the body only. **`capture_error_responses` is the headline gap:** a partner `4xx` +dead-letters and the caller receives a fixed-JSON `502`, **not** the partner's own status and body, so a +proxy API built on `reply_from` is currently correct only when the partner *succeeds*. The inbound **FHIR +facade** (BACKLOG #20) and **DICOMweb STOW-RS receiver** (#24) are consumers of this listener, each its own +build. `POST /connections/{name}/test` reports `supported=false` — a bound listener has nothing external to +probe. ```python from messagefoundry import ContentType, Http, env, inbound, router @@ -2382,8 +2407,8 @@ Legend: ✅ native · ~ partial / via extension / via another transport · ❌ n | **SFTP** | ✅ | ✅ | ✅ | ✅ | `SFTP-IN/OUT` shipped — `Sftp()`, source + destination, `[sftp]` extra; host-key verification on by default | | **SMB / network share** | ✅ | ✅ | ✅ | ✅ | `File()` on a UNC path, with an optional **alternate Windows credential** (`credential_*`, ADR 0132) | | **S3 / cloud blob** | ✅ | ~ | ✅ | ❌ | not built — the one remaining remote-file scheme | -| **HTTP/HTTPS** listener + sender (REST) | ✅ | ✅ | ✅ | ✅ | `REST-OUT` (`Rest()`) + `REST-IN` (`Http()`, ADR 0023) both shipped; request auth on the intake socket is deferred | -| **SOAP / Web Services** | ✅ | ✅ | ✅ | ~ | `SOAP-OUT` shipped incl. WS-\* mTLS/WS-Security (ADR 0015); a SOAP body can be **received** via `Http()`, but the *synchronous* SOAP-envelope reply is deferred | +| **HTTP/HTTPS** listener + sender (REST) | ✅ | ✅ | ✅ | ✅ | `REST-OUT` (`Rest()`) + `REST-IN` (`Http()`, ADR 0023) both shipped, incl. **intake authentication** on the listen socket (`intake_auth` — API key / bearer / mTLS subject, ADR 0154) | +| **SOAP / Web Services** | ✅ | ✅ | ✅ | ~ | `SOAP-OUT` shipped incl. WS-\* mTLS/WS-Security (ADR 0015); a SOAP body is **received** via `Http()`, and the *synchronous* envelope reply shipped with ADR 0154 (`reply_from`). Still `~` for one reason: a partner **error** body is not relayed (`capture_error_responses`), so the reply path is correct only when the partner succeeds | | **Database** reader/writer | ✅ (JDBC) | ✅ | ✅ (JDBC) | ✅ (ODBC) | `DB-OUT` + `DB-IN` shipped (SQL Server preset, production — a live aioodbc round-trip runs in CI); `dialect='generic'` reaches any DB with an OS-installed ODBC driver. **No JDBC** — MF is pure Python, no JVM | | **SMTP** (email send) | ✅ | ✅ | ✅ | ✅ | `SMTP-OUT` shipped — `Email()`/`SMTP()` (ADR 0029); **plus** `Direct()`, Direct-Project S/MIME over SMTP (ADR 0085), which none of the three ships natively | | **Email reader** (POP3/IMAP) | ~ | ~ | ✅ | ❌ | `MAIL-IN` planned | @@ -2404,9 +2429,9 @@ against: the **Timer** clock-driven source (`Timer()`, ADR 0011) and — folded - **Tier 1 — table stakes (all three have these): now shipped, bar one.** Raw TCP, HTTP/REST (**both** directions), `SOAP-OUT`, Database (`DB-IN` + `DB-OUT`), SFTP, FTP/FTPS, UNC/SMB, and the **FHIR** client - all ship. What is left on this tier: **S3 / cloud blob**, and the two inbound facades — `SOAP-IN`'s - *synchronous* envelope reply and `FHIR-IN` (both consumers of the shipped `Http()` listener, not new - substrate). + all ship — as does `SOAP-IN`'s *synchronous* envelope reply (ADR 0154 `reply_from`), bar the + partner-error relay noted in its row above. What is left on this tier: **S3 / cloud blob**, and the + `FHIR-IN` inbound facade (a consumer of the shipped `Http()` listener, not new substrate). - **Tier 2 — present in 2 of 3:** JMS (Mirth + Rhapsody) and the **Email reader** (POP3/IMAP) are the open ones — SMTP *send* shipped (ADR 0029). **DICOM is full-lane** — the `DICOM-IN` C-STORE SCP (Phase 1) plus the `DICOM-OUT` C-STORE SCU/C-ECHO and the `DICOMWEB-OUT` STOW-RS sender (Phase 2, ADR 0025) ship; the diff --git a/docs/FEATURE-MAP.md b/docs/FEATURE-MAP.md index ecf2fc5..0fd101e 100644 --- a/docs/FEATURE-MAP.md +++ b/docs/FEATURE-MAP.md @@ -54,7 +54,8 @@ is sourced, and the Mirth/NextGen Connect parity reference lives in | Direct-Project S/MIME-over-SMTP destination | ✅ | ADR 0085 PR1 — sign-then-encrypt to a per-partner recipient cert (core `cryptography` pkcs7, no new dep) over STARTTLS SMTP; `[egress].allowed_direct` gate; fail-loud at construction. Outbound only — inbound Direct mail, MDNs, DNS CERT / LDAP discovery and XDR/XDM are deferred | | Internal inbounds — `Loopback()` + `PassThrough()` | ✅ | ADR 0013 — inert sources (no socket, no poll): a message arrives only via the internal handoff and is re-ingressed as a new inbound message with its own Router. `Loopback()` carries a captured reply (1:1); `PassThrough()` is the 1:N internal hop a Handler `Send`s into | | MLLP-over-TLS | ✅ | Gate #4 (WP-13b) | -| SOAP-IN synchronous reply / FHIR-IN server facade | ⏭️ | The generic inbound HTTP listener above is built, so a partner can `POST` a JSON / XML / SOAP-envelope / FHIR body today and a Handler un-wraps it. Still deferred: the *synchronous* SOAP-envelope reply, routing on HTTP method/path/headers, on-socket request authentication (API key / bearer), and the inbound FHIR facade | +| SOAP-IN synchronous reply (`reply_from`) | ✅ | ADR 0154 increment B — naming `reply_from` on the inbound `Http()` listener blocks the HTTP turn until the named outbound's reply is captured **and committed to the store**, then returns it as the response body. The committed row is the sole authority for those bytes, so it stays correct under engine sharding, HA failover and any capture/read race. **Known gap:** a partner *error* body and status code are not relayed (`capture_error_responses`) — the caller gets a fixed-JSON `502` — so the reply path is correct only when the partner succeeds | +| FHIR-IN server facade | ⏭️ | The generic inbound HTTP listener above is built, so a partner can `POST` a JSON / XML / SOAP-envelope / FHIR body today and a Handler un-wraps it. **On-socket request authentication shipped** with ADR 0154 increment A (`intake_auth` — API key / bearer / mTLS subject). Still deferred: the inbound FHIR facade itself, and routing on HTTP method/path/headers as Router inputs | | MLLP persistent outbound connection (`persistent=true`) | ✅ | ADR 0067 — opt-in, **default off**: one lazily-established connection reused across deliveries (a single cached connection, **not** a pool), with a no-I/O liveness check before reuse plus idle/age caps. Removes the per-message handshake and its `TIME_WAIT` pressure; the default flips once the ADR 0067 §8 trigger is met | ## 2. Routing & Handling (code-first) diff --git a/docs/adr/0023-inbound-http-listener.md b/docs/adr/0023-inbound-http-listener.md index 2681202..e0cd88c 100644 --- a/docs/adr/0023-inbound-http-listener.md +++ b/docs/adr/0023-inbound-http-listener.md @@ -1,6 +1,6 @@ # ADR 0023 — Inbound HTTP listener (a connector-owned SOAP/REST web-service source) -- **Status:** Accepted (2026-06-27, built — first slice in 0.2.10; SOAP-reply/auth/routing-metadata deferred) +- **Status:** Accepted (2026-06-27, built — first slice in 0.2.10). The deferred tail is now built: intake auth and the SOAP-envelope synchronous reply both shipped via [ADR 0154](0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md), which is the authority on their current state. **Routing-metadata remains deferred.** - **Date:** 2026-06-27 - **Related:** BACKLOG #7 (inbound web-service listener — this consumes the pre-Reserved README row) · unblocks #20 (inbound FHIR server facade) and #24 (inbound DICOMweb STOW-RS receiver) · diff --git a/docs/adr/0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md b/docs/adr/0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md index e1de69c..39dd151 100644 --- a/docs/adr/0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md +++ b/docs/adr/0154-synchronous-captured-downstream-reply-and-intake-authentication-for-the-inbound-http-listener-adr-0023-deferred-tail.md @@ -1,22 +1,28 @@ # ADR 0154 — Synchronous captured-downstream-reply and intake authentication for the inbound HTTP listener (the ADR 0023 deferred tail) -- **Status:** **Accepted (2026-07-31) — owner-ratified at revision 5. Authorises INCREMENT A ONLY; increment A is BUILT and merged (2026-08-01, `f2ef0ea9`). Increment B remains unauthorised.** +- **Status:** **Accepted (2026-07-31) — owner-ratified at revision 5. BOTH increments are now authorised, built and merged: increment A 2026-08-01 (`f2ef0ea9`, PR #109), increment B 2026-08-01 (PR #119). `AC-18` was deliberately deferred and remains outstanding — see the note below.** - **Date:** 2026-07-30 (rev 1–4), 2026-07-31 (rev 5, ratified) -- **⚠️ What acceptance does and does not authorise.** "Accepted" normally means *build may start*. Here it - is **scoped**, because rev 4 split the build and that split is part of what was ratified: +- **⚠️ What acceptance authorised, and what has since been built.** "Accepted" normally means *build may + start*. Here it was **scoped**, because rev 4 split the build and that split is part of what was + ratified. **Both halves have since been authorised and built** — the split below is retained as the + decision record, not as a live restriction: - **Increment A — `intake_auth` + the D7 peer-control gate (D6, D7), plus the three incidental listener - defects — is AUTHORISED and may start now.** It closes a live hole: `check_http_tls_exposure` returns - early on truthy `tls`, so an off-loopback `Http(tls=True)` listener binds a PHI intake socket with no - peer identity requirement, and `tests/test_exposed_with_tls_passes` currently pins that as passing. - Acceptance criteria: **AC-11 … AC-16, AC-19**, plus **AC-17**. - - **Increment B — the synchronous captured-downstream reply (D1–D5, D8) — is NOT authorised yet.** It - remains deferred until a customer exists whose partner semantics can shape it. It carries roughly 70 % - of this document's complexity and effectively all of its concurrency risk, and there is no one to - serve. Do **not** read this Accepted status as a green light for `reply_from`. Acceptance criteria - **AC-1 … AC-10 and AC-18** are specified but dormant. - - Building increment B requires a further owner decision, not merely a reading of this line. The design is - ratified; the *schedule* is deliberately partial. + defects.** Authorised at ratification, because it closed a live hole: `check_http_tls_exposure` + returned early on truthy `tls`, so an off-loopback `Http(tls=True)` listener bound a PHI intake socket + with no peer identity requirement, and `tests/test_exposed_with_tls_passes` pinned that as passing. + Acceptance criteria: **AC-11 … AC-16, AC-19**, plus **AC-17**. **BUILT — merged 2026-08-01 + (`f2ef0ea9`, PR #109).** + - **Increment B — the synchronous captured-downstream reply (D1–D5, D8).** Deferred at ratification + until a customer existed whose partner semantics could shape it, since it carries roughly 70 % of this + document's complexity and effectively all of its concurrency risk. **The owner authorised it on + 2026-08-01**: the prospect whose proxy-API shape rev 4 was waiting for supplied that partner + semantics. Acceptance criteria **AC-1 … AC-10** are met. **BUILT — merged 2026-08-01 (PR + #119).** + + **`AC-18` remains outstanding.** The `message_events` observability row for a returned-or-timed-out sync + reply was deliberately deferred out of increment B and accepted as a gap, along with the in-process + latency hints and `capture_error_responses` (see the open items). The design is ratified and the reply + path is built; at least that observability tail is still open. - **Revision 5 — every open item resolved, on one principle.** The owner confirmed there are **no deployed instances of MessageFoundry**: no production installs, no installed base, nobody to upgrade. That single fact settles the six remaining `To resolve on acceptance` items, because every one was a trade between @@ -278,7 +284,7 @@ hostage to a speculative feature. | | Driver | When | |---|---|---| | **Increment A — intake auth + the peer-control gate** (D6, D7) | A **live defect in shipped code.** `check_http_tls_exposure` returns early on truthy `tls`, so an off-loopback `Http(tls=True)` listener binds a PHI intake socket with no peer identity requirement — and `tests/test_exposed_with_tls_passes` pins that configuration as passing. This is wrong today, in any such deployment, with or without a customer. | **Now.** Independent of the prospect. | -| **Increment B — the synchronous captured-downstream reply** (D1–D5, D8) | The prospect's proxy-API shape. Roughly 70 % of this document's complexity and effectively all of its concurrency risk: the multi-handler terminality test, the rendezvous, the throughput envelope and the `max_connections` interaction. | **When a customer exists,** so their partner's real error and latency semantics shape it rather than a guess. | +| **Increment B — the synchronous captured-downstream reply** (D1–D5, D8) | The prospect's proxy-API shape. Roughly 70 % of this document's complexity and effectively all of its concurrency risk: the multi-handler terminality test, the rendezvous, the throughput envelope and the `max_connections` interaction. | **Was:** when a customer exists, so their partner's real error and latency semantics shape it rather than a guess. **BUILT 2026-08-01** (PR #119), once the prospect supplied that shape. | The acceptance criteria already fall along this seam, so the split costs no renumbering and no design change: **AC-11 … AC-16 and AC-19** cover increment A, **AC-1 … AC-10 and AC-18** cover increment B, and @@ -288,9 +294,12 @@ The one genuine coupling is the `_read_request` → `_read_head`/`_read_body` sp **increment A**, because authenticating *before* buffering a 16 MiB body is a requirement of the auth work itself, not of the reply work. -Deferring increment B also defers its headline gap — `capture_error_responses` (see the open items). With -no customer to disappoint, a fixed-JSON `502` on a partner rejection is a documented limitation rather than -a broken promise, and the knob is better designed against a real partner's error semantics than in advance. +Increment B shipped **without** its headline gap closed — `capture_error_responses` (see the open items). +A partner rejection still returns a fixed-JSON `502` rather than the partner's own status and body. Rev 4 +justified that by increment B being deferred; **that justification expired when increment B was built**. +The gap now rests on an explicit owner decision at authorisation to ship the reply path without it, and it +is a **live limitation of shipped code** rather than a deferral — a proxy API that is correct only when the +partner succeeds is not a Corepoint replacement for that feed. ### D1 — The committed row is the sole authority for the returned bytes; every in-process signal is a latency hint @@ -1114,7 +1123,10 @@ worth its own look, as that asymmetry is a request-smuggling shape). **Out of scope / deferred** — **Relaying the partner's error body and status code.** `RestDestination` captures only `accepted`/`no_reply`; a partner 4xx raises `NegativeAckError(permanent=True)`, dead-letters, and writes **no** `response` row — so a Salesforce validation message cannot reach the caller in increment 1 -(they get a fixed-JSON `502`). **With no committed customer this is a documented limitation rather than a blocker** (rev 4; it would be a serious gap for a live proxy API, and increment B is deferred until one exists): a +(they get a fixed-JSON `502`). **This is now a live limitation of shipped code.** Rev 4 called it "a +documented limitation rather than a blocker" *because increment B was itself deferred until a customer +existed*; that premise expired when increment B was built (PR #119), and the gap was instead +accepted explicitly by the owner at authorisation. It remains a serious gap for a live proxy API: a proxy API that is correct only when the partner succeeds is not a Corepoint replacement for that feed. Full parity needs an outbound-side `capture_error_responses` knob plus a typed `DeliveryResponse.status_code` and an additive nullable `response.status_code` column, as an ADR 0013 amendment. **ADR 0013 Increment 2 @@ -1182,7 +1194,9 @@ the CIDR-aware predicate; the others are left as found. - [x] **Sequencing `capture_error_responses`.** ~~Blocking prerequisite, or fast-follow?~~ **rev 4:** neither. With no committed customer it is a normal roadmap item, and increment B — where it would land — - is itself deferred until a customer exists. Revisit when one does. + is itself deferred until a customer exists. Revisit when one does. **Post-build (2026-08-01):** increment + B shipped without it, so it is no longer gated on increment B at all — it is the top open follow-on, and + the fixed-JSON `502` on a partner rejection that it leaves in place is live in shipped code. - [x] **The `reply_wait_state` read — YES, add it.** `outbox_for` is `SELECT *`, decrypts `last_error` on every tick, and is scoped to `stage='outbound'` so it structurally cannot see a pending `routed` row — the state D3 must distinguish. A metadata-only read on three backends is additive, touches no schema, diff --git a/messagefoundry/config/wiring.py b/messagefoundry/config/wiring.py index 87ce5cb..18ebb76 100644 --- a/messagefoundry/config/wiring.py +++ b/messagefoundry/config/wiring.py @@ -1128,8 +1128,8 @@ def Http( happens *after* the ``202`` and is **not** reflected in the HTTP status (it surfaces as the message's ``ERROR``/dead-letter + the AlertSink). A pre-ingress refusal (oversize/malformed/allowlist) returns a synchronous ``4xx`` + an ADR 0021 ``connection_event``. ``GET``/``HEAD`` are static health probes (no - ingress row). The synchronous downstream-reply (SOAP-envelope) path is a defined ADR 0013 follow-on, - not built here. + ingress row). This is the behaviour of an inbound **without** ``reply_from``; naming it switches + to the synchronous captured-downstream reply described below. **DoS guards** are HTTP twins of MLLP's: ``max_connections`` (flood), ``receive_timeout`` (slow-loris — bounds the whole-request read), ``max_body_bytes`` (the frame-cap twin — refused on the declared diff --git a/messagefoundry/transports/http_listener.py b/messagefoundry/transports/http_listener.py index 9245505..7960e74 100644 --- a/messagefoundry/transports/http_listener.py +++ b/messagefoundry/transports/http_listener.py @@ -25,10 +25,12 @@ headers + body), and ``max_body_bytes`` (the ``MLLPDecoder`` frame cap's HTTP twin — a ``Content-Length`` / read ceiling). -**First slice (ADR 0023 D3).** Only the cheap, correct ``202``-respond-with-receipt path is built. A -synchronous downstream-reply (the SOAP-envelope ``block-on-captured-downstream-reply`` seam) is a -defined ADR 0013 follow-on and is **not** built here. ``GET``/``HEAD`` are answered with a static, -non-PHI health response **without** an ingress row; any other method is ``405``. +**Two response modes.** The default is the cheap, correct ``202``-respond-with-receipt path (ADR 0023 +D3). An inbound naming ``reply_from`` instead **blocks** the HTTP turn until the named outbound's reply +has been captured *and committed*, then returns it as the body (ADR 0154 increment B, via the ADR 0013 +capture seam) — see :func:`~messagefoundry.config.wiring.Http` for the settings and their check-time +refusals. ``GET``/``HEAD`` are answered with a static, non-PHI health response **without** an ingress +row; any other method is ``405``. """ from __future__ import annotations