From ee1d77e8606840ebc514dfdb57c8751a1f17ce48 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sat, 17 Oct 2026 01:21:50 -0500 Subject: [PATCH 1/2] MLLP and TCP deliveries refuse an MSH-led payload holding a second MSH check_frame_bytes in transports/framing.py now refuses, before the encode, a payload that opens with MSH (past whitespace and a byte order mark) and holds another MSH line, CR or LF line ends alike: NegativeAckError (permanent=True), code "multiple-messages", content-free text. The live send (frame_for_delivery), each MLLP batch member (check_frame, rewrite=False) and a shadow outbound all reach it, so wiring_runner.py is unchanged. A payload not led by MSH (X12 relay, the BHS batch envelope) is never read by this rule. The ingress reading _holds_more_than_one_message moves unchanged from pipeline/ingress_guards.py to parsing/split.py beside _MSH_BOUNDARY; the delivery's _sends_more_than_one_message sits beside it, and a test holds the two to one answer. BACKLOG #2858 --- messagefoundry/parsing/split.py | 47 ++++++ messagefoundry/pipeline/ingress_guards.py | 19 +-- messagefoundry/transports/base.py | 4 +- messagefoundry/transports/framing.py | 35 ++++- messagefoundry/transports/mllp.py | 2 +- messagefoundry/transports/tcp.py | 10 +- tests/test_harness_frame_bytes.py | 7 +- tests/test_one_frame_one_message.py | 174 +++++++++++++++++++++- tests/test_outbound_batch.py | 56 ++++++- 9 files changed, 319 insertions(+), 35 deletions(-) diff --git a/messagefoundry/parsing/split.py b/messagefoundry/parsing/split.py index 96d3b2a908..c07bbd43c0 100644 --- a/messagefoundry/parsing/split.py +++ b/messagefoundry/parsing/split.py @@ -42,6 +42,53 @@ # segment id is always exactly three chars, so only an ``MSH`` segment starts with the literal "MSH". _MSH_BOUNDARY = re.compile(r"(?=\rMSH)") +#: The first character the parser keeps: it strips the whitespace around a body before it reads it. +_FIRST_KEPT = re.compile(r"\S") + + +def _holds_more_than_one_message(text: str) -> bool: + """Whether normalized (``\\r``-delimited) ``text`` holds more than one ``MSH`` segment, by the + parser's own reading: it strips the whitespace around the body, then treats every line whose + first three characters are ``MSH`` as a header. C-level scans and no copy. The ingress guard's + reading (ADR 0206 rule 5). The boundary is the one :func:`split_batch` splits on + (``_MSH_BOUNDARY``), so a File source hands that guard one message at a time; keep the two in + step, and :func:`_sends_more_than_one_message` with them.""" + first = _FIRST_KEPT.search(text) + if first is None: + return False + start = first.start() + later = text.count("\rMSH", start) + return later > 1 or (later == 1 and text.startswith("MSH", start)) + + +#: The byte order mark, U+FEFF, named rather than written so the source stays ASCII. +_BOM = "\N{ZERO WIDTH NO-BREAK SPACE}" + +#: ``MSH`` after whitespace and any byte order mark. Wider than the parser's ``_LEADS_WITH_MSH`` by +#: the mark alone, which :func:`split_batch` reads past too: a receiver that drops the mark reads +#: the messages behind it. +_LEADS_WITH_MSH_PAST_BOM = re.compile(r"[\s" + _BOM + r"]*MSH") + +#: A later ``MSH`` line that a byte order mark leads, as two texts read without stripping their +#: marks would join. Searched only when the payload holds a mark, so a clean payload never pays it. +_LATER_MSH_PAST_BOM = re.compile(r"[\r\n]" + _BOM + r"+MSH") + + +def _sends_more_than_one_message(payload: str) -> bool: + """Whether an outbound ``payload`` opens with ``MSH``, past whitespace and a byte order mark, + and holds another line that starts with ``MSH``, or with marks and then ``MSH`` (ADR 0209). The + payload is not normalized, so a line break is a CR or an LF, and on an ``MSH``-led payload with + no mark the answer is :func:`_holds_more_than_one_message`'s on the normalized text. A payload + not led by ``MSH`` answers False. Each search stops at its first hit and copies nothing.""" + lead = _LEADS_WITH_MSH_PAST_BOM.match(payload) + if lead is None: + return False + start = lead.end() + if payload.find("\rMSH", start) >= 0 or payload.find("\nMSH", start) >= 0: + return True + return _BOM in payload and _LATER_MSH_PAST_BOM.search(payload, start) is not None + + #: A UTF-8 byte order mark after the leading whitespace the content sniff tolerates. A match, not a #: strip, so a file led by whitespace is not copied whole to look at its first bytes. In a bytes #: pattern ``\s`` is exactly the bytes of ``sniff._LEADING_WS``; a test pins the two. diff --git a/messagefoundry/pipeline/ingress_guards.py b/messagefoundry/pipeline/ingress_guards.py index 72d8836eae..862bc8e3f5 100644 --- a/messagefoundry/pipeline/ingress_guards.py +++ b/messagefoundry/pipeline/ingress_guards.py @@ -66,6 +66,7 @@ enforce_size_limits, ) from messagefoundry.parsing.sniff import _content_matches_declared, text_sniff_head +from messagefoundry.parsing.split import _holds_more_than_one_message from messagefoundry.parsing.validate import validate from messagefoundry.redaction import safe_exc @@ -413,24 +414,6 @@ def __init__(self) -> None: super().__init__(MULTIPLE_MESSAGES_REJECTED_REASON) -#: The first character the parser keeps: it strips the whitespace around a body before it reads it. -_FIRST_KEPT = re.compile(r"\S") - - -def _holds_more_than_one_message(text: str) -> bool: - """Whether ``text`` holds more than one ``MSH`` segment, by the parser's own reading: it strips - the whitespace around the body, then treats every line whose first three characters are ``MSH`` - as a header. C-level scans and no copy. The boundary is the one ``split_batch`` splits on - (``_MSH_BOUNDARY`` in ``parsing/split.py``), so a File source hands this guard one message at a - time; keep the two in step.""" - first = _FIRST_KEPT.search(text) - if first is None: - return False - start = first.start() - later = text.count("\rMSH", start) - return later > 1 or (later == 1 and text.startswith("MSH", start)) - - def decode_body(raw: str | bytes, ic: InboundConnection, *, encoding: str) -> str: """Decode one received text body for ``ic`` in ``encoding`` at ``errors="strict"``; return the text. diff --git a/messagefoundry/transports/base.py b/messagefoundry/transports/base.py index 78f6481640..bc37af19b7 100644 --- a/messagefoundry/transports/base.py +++ b/messagefoundry/transports/base.py @@ -1027,8 +1027,8 @@ async def aclose(self) -> None: def check_frame(self, payload: str, *, rewrite: bool = True) -> None: """Raise the permanent :class:`NegativeAckError` :meth:`send` would raise for a ``payload`` - its frame cannot carry (ADR 0205 rule 1), or its delimiter rewrite refuses (ADR 0206), - without sending it. The default is a no-op: only + its frame cannot carry (ADR 0205 rule 1), that holds more than one message (ADR 0209), or + that its delimiter rewrite refuses (ADR 0206), without sending it. The default is a no-op: only the MLLP and TCP destinations frame. The delivery stage calls it where no single-payload :meth:`send` runs: on a simulate (shadow) outbound, and on each MLLP batch member with ``rewrite=False``, because a member sits in the envelope as stored and :meth:`send` applies diff --git a/messagefoundry/transports/framing.py b/messagefoundry/transports/framing.py index 636ca0daea..88d8a6a774 100644 --- a/messagefoundry/transports/framing.py +++ b/messagefoundry/transports/framing.py @@ -27,9 +27,11 @@ FramePayloadError, codec_for, ) +from messagefoundry.parsing.split import _sends_more_than_one_message from messagefoundry.transports.base import NegativeAckError, encode_wire_body __all__ = [ + "MULTIPLE_MESSAGES_CODE", "FrameError", "FrameByteFault", "FramePayloadError", @@ -47,6 +49,10 @@ logger = logging.getLogger(__name__) +#: The :class:`NegativeAckError` code of an MLLP or TCP delivery refused for holding more than one +#: ``MSH`` segment (ADR 0209). +MULTIPLE_MESSAGES_CODE = "multiple-messages" + def frame_for_delivery(codec: FrameCodec, payload: str, encoding: str, *, transport: str) -> bytes: """Frame one outbound ``payload`` for the wire, or refuse it permanently (ADR 0205 rule 1). @@ -61,15 +67,21 @@ def frame_for_delivery(codec: FrameCodec, payload: str, encoding: str, *, transp byte and its position, never the content. The encode goes through :func:`encode_wire_body`, so a payload the charset cannot hold is the same content-free permanent failure other destinations raise. Call this once per send, before any dial, so a refused payload opens no connection and - never discards a healthy persistent one.""" + never discards a healthy persistent one. :func:`check_frame_bytes` also refuses a payload + holding more than one message (ADR 0209).""" return codec.frame(check_frame_bytes(codec, payload, encoding, transport=transport)) def check_frame_bytes(codec: FrameCodec, payload: str, encoding: str, *, transport: str) -> bytes: - """Rule 1's check alone: encode ``payload`` and refuse it as :func:`frame_for_delivery` does, - returning the encoded body unframed. The delivery stage calls it, through a destination's + """The delivery checks without the frame: refuse ``payload`` as :func:`frame_for_delivery` + does, or return it encoded and unframed. The delivery stage calls it, through a destination's ``check_frame``, where no single-payload send runs: on each member of an MLLP batch, and on a - shadow (simulate) outbound, so both record the disposition a live send would.""" + shadow (simulate) outbound, so both record the disposition a live send would. + + It refuses, in order, at least: a payload holding more than one message + (:func:`_refuse_more_than_one_message`, ADR 0209), one the charset cannot encode + (:func:`encode_wire_body`), and ADR 0205 rule 1 on the encoded bytes.""" + _refuse_more_than_one_message(payload, transport) body = encode_wire_body(payload, encoding, transport=transport) # The judgement and its wording are the leaf's (FrameCodec.find_frame_byte), shared with the test # harness; what is engine-only is the permanent delivery failure it becomes here. @@ -79,6 +91,21 @@ def check_frame_bytes(codec: FrameCodec, payload: str, encoding: str, *, transpo return body +def _refuse_more_than_one_message(payload: str, transport: str) -> None: + """Refuse an ``MSH``-led ``payload`` that holds a second ``MSH`` line (ADR 0209): one frame is + one message to the receiver. It sits under :func:`check_frame_bytes`, so it reaches at least a + live send, a batch member and a shadow outbound. A payload not led + by ``MSH`` is never read, so a relayed X12 interchange and the ``BHS`` envelope a batching + outbound sends (ADR 0082) pass. The text names the rule, never the content.""" + if _sends_more_than_one_message(payload): + raise NegativeAckError( + f"{transport}: payload holds more than one MSH segment, and one frame carries one " + "message (ADR 0209)", + code=MULTIPLE_MESSAGES_CODE, + permanent=True, + ) + + def frame_reply(codec: FrameCodec, reply: str, encoding: str) -> bytes: """Frame a listener's reply, with each of the codec's start and end bytes replaced by a space. diff --git a/messagefoundry/transports/mllp.py b/messagefoundry/transports/mllp.py index 24ab2138bf..e8a4871362 100644 --- a/messagefoundry/transports/mllp.py +++ b/messagefoundry/transports/mllp.py @@ -925,7 +925,7 @@ def _describe_error(exc: BaseException) -> str: return f"{name}: {' '.join(extras)}" if extras else name def check_frame(self, payload: str, *, rewrite: bool = True) -> None: - """ADR 0205 rule 1 on ``payload`` without sending it: raise the permanent + """ADR 0205 rule 1 and ADR 0209 on ``payload`` without sending it: raise the permanent :class:`NegativeAckError` :meth:`send` would. With ``rewrite`` it checks the payload as :meth:`send` frames it, after the same delimiter rewrites, which drop a frame byte at either end; a payload those rewrites refuse raises their permanent ``reencode`` refusal, as diff --git a/messagefoundry/transports/tcp.py b/messagefoundry/transports/tcp.py index f77d439bb7..3ffd91c105 100644 --- a/messagefoundry/transports/tcp.py +++ b/messagefoundry/transports/tcp.py @@ -4,7 +4,9 @@ Built to relay **X12 (and other non-HL7) feeds over custom-framed TCP** opaquely: the payload is carried as bytes and never parsed (no ISA/GS/ST inspection, no 997/TA1 acks — those are a -documented follow-up). Whether a received body is routed as a structured HL7 +documented follow-up). A delivery still refuses, at least, a payload holding its codec's frame +bytes (ADR 0205) and an ``MSH``-led payload holding a second ``MSH`` (ADR 0209). Whether a +received body is routed as a structured HL7 :class:`~messagefoundry.parsing.message.Message` or a :class:`~messagefoundry.parsing.message.RawMessage` is decided by the **inbound's ``content_type``** (set ``x12`` for these feeds), not by this connector (ADR 0004). @@ -219,9 +221,9 @@ def __init__(self, config: Destination) -> None: self._hop_guard.enforce_construction() def check_frame(self, payload: str, *, rewrite: bool = True) -> None: - """ADR 0205 rule 1 on ``payload`` without sending it, as :meth:`MLLPDestination.check_frame` - does, for a shadow outbound where :meth:`send` does not run. TCP has no delimiter rewrite, - so ``rewrite`` changes nothing.""" + """ADR 0205 rule 1 and ADR 0209 on ``payload`` without sending it, as + :meth:`MLLPDestination.check_frame` does, for a shadow outbound where :meth:`send` does not + run. TCP has no delimiter rewrite, so ``rewrite`` changes nothing.""" check_frame_bytes(self.codec, payload, self.encoding, transport="TCP") async def test_connection(self) -> None: diff --git a/tests/test_harness_frame_bytes.py b/tests/test_harness_frame_bytes.py index a8a91d081b..93a569b843 100644 --- a/tests/test_harness_frame_bytes.py +++ b/tests/test_harness_frame_bytes.py @@ -150,8 +150,11 @@ def test_neutralise_returns_the_same_object_when_clean() -> None: ) @pytest.mark.parametrize("codec", [MLLP_CODEC, STX_ETX_CODEC, FrameCodec(start=0xC3, end=0xA9)]) def test_the_engine_delivery_check_is_the_leaf_check(codec: FrameCodec, payload: str) -> None: - # One definition: the engine's refusal fires exactly where the leaf's does, with the same text, - # and its reply framer neutralises exactly as the leaf does. Imported here only, never by harness. + # One definition of the frame-byte rule: on these payloads, none led by MSH, the engine's + # refusal fires exactly where the leaf's does, with the same text, and its reply framer + # neutralises exactly as the leaf does. The engine also refuses an MSH-led payload holding a + # second MSH (ADR 0209), which the leaf still frames. + # Imported here only, never by harness. from messagefoundry.transports.base import NegativeAckError from messagefoundry.transports.framing import check_frame_bytes, frame_reply diff --git a/tests/test_one_frame_one_message.py b/tests/test_one_frame_one_message.py index 426f580404..5f2878b5da 100644 --- a/tests/test_one_frame_one_message.py +++ b/tests/test_one_frame_one_message.py @@ -38,15 +38,20 @@ Send, ) from messagefoundry.framing import MLLP_CODEC, STX_ETX_CODEC, FrameCodec -from messagefoundry.parsing import _builtin_hl7 +from messagefoundry.parsing import _builtin_hl7, normalize, split from messagefoundry.parsing import message as message_module from messagefoundry.parsing.dicom.hl7_map import encode_segment from messagefoundry.parsing.message import Message +from messagefoundry.parsing.peek import starts_with_msh from messagefoundry.pipeline import ingress_guards, wiring_runner from messagefoundry.pipeline.dryrun import dry_run from messagefoundry.store import MessageStatus, MessageStore, OutboxStatus from messagefoundry.transports.base import NegativeAckError -from messagefoundry.transports.framing import frame_for_delivery, frame_reply +from messagefoundry.transports.framing import ( + MULTIPLE_MESSAGES_CODE, + frame_for_delivery, + frame_reply, +) from messagefoundry.transports.mllp import MLLPDestination, MLLPSource from messagefoundry.transports.tcp import TcpDestination, TcpSource @@ -74,6 +79,8 @@ SMUGGLED_STX = MSH + CR + "PID|1||111\x03\x02" + MSH2 + CR #: Audit probe P15: an embedded start byte only, which an MLLP frame keeps as data. EMBEDDED_SB = MSH + CR + "PID|1||111" + CR + SB + MSH2 + CR + "PID|1||999" + CR +#: ADR 0209: two whole messages in one payload, as a Handler bug or a pasted batch would Send it. +TWO = CLEAN + MSH2 + CR + "PID|1||999^^^MRN||ROE" + CR def _frames(codec: FrameCodec, wire: bytes) -> list[bytes]: @@ -511,6 +518,8 @@ def test_the_tcp_destination_check_frame_refuses_with_its_label() -> None: [ pytest.param(SMUGGLED, DEAD, {}, "frame", id="smuggled"), pytest.param(CLEAN, DONE, {}, "", id="clean-control"), + # ADR 0209: two messages in one payload dead-letter in shadow as a live send refuses them. + pytest.param(TWO, DEAD, {}, "more than one MSH segment", id="two-messages"), # A live send refuses a payload its rewrite cannot re-encode, permanently (ADR 0204, # ADR 0206), and check_frame shares that rewrite, so shadow dead-letters it too. pytest.param( @@ -613,3 +622,164 @@ async def handler(raw: bytes) -> str: finally: await source.stop() assert got.count(b"\x02") == 1 + + +# --- ADR 0209: an MLLP or TCP delivery refuses an MSH-led payload holding a second MSH ---------- +# The shadow single-row case is the "two-messages" row of the shadow test above; the batch cases +# are in tests/test_outbound_batch.py. + +#: TWO with each line-end shape a Handler may produce, with leading whitespace, and with byte +#: order marks where two texts read without stripping them would put them. +TWO_VARIANTS = [ + pytest.param(TWO, id="cr"), + pytest.param(TWO.replace(CR, "\n"), id="lf"), + pytest.param(TWO.replace(CR, "\r\n"), id="crlf"), + pytest.param("\r\n" + TWO, id="leading-whitespace"), + pytest.param(CLEAN + split._BOM + TWO[len(CLEAN) :], id="byte-order-mark-between"), + pytest.param(split._BOM + TWO, id="byte-order-mark"), +] +#: A relayed X12 interchange, which a TCP outbound carries opaquely and never reads as HL7. +X12 = ( + "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER *260101*1200*^*00501" + "*000000001*0*P*:~GS*HC*SENDER*RECEIVER*20260101*1200*1*X*005010X222A1~ST*837*0001~" + "SE*2*0001~GE*1*1~IEA*1*000000001~" +) +#: Message content the refusal text must never carry. +CONTENT = ("SND", "FORGED", "DOE", "ROE", "CTRL1", "CTRL2", "111", "999") + + +def _tcp(persistent: bool = False) -> TcpDestination: + settings: dict[str, object] = { + "host": "127.0.0.1", + "port": 1, + "framing": "stx_etx", + "timeout_seconds": 1, + "persistent": persistent, + } + return TcpDestination(Destination(name="out", type=ConnectorType.TCP, settings=settings)) + + +def _assert_two_messages_refusal(exc: NegativeAckError, transport: str) -> None: + assert exc.permanent is True + assert exc.code == MULTIPLE_MESSAGES_CODE + assert str(exc).startswith(transport + ": payload holds more than one MSH segment") + assert [word for word in CONTENT if word in str(exc)] == [] + + +def _record_wire(monkeypatch: pytest.MonkeyPatch, dest: Any) -> list[bytes]: + """Replace ``dest``'s connect-per-send path with a recorder of the framed bytes it is handed, + so the real ``send()`` runs up to the dial.""" + sent: list[bytes] = [] + + async def record(wire: bytes, *_: Any) -> None: + sent.append(wire) + + monkeypatch.setattr(dest, "_send_once", record) + return sent + + +@pytest.mark.parametrize("payload", TWO_VARIANTS) +@pytest.mark.parametrize("persistent", [False, True]) +@pytest.mark.parametrize("no_ack", [False, True]) +async def test_all_four_mllp_send_paths_refuse_two_messages_before_any_dial( + monkeypatch: pytest.MonkeyPatch, persistent: bool, no_ack: bool, payload: str +) -> None: + # Before ADR 0209 each path framed the payload as asked: one frame, two messages, one ACK. + dest = _mllp(persistent, no_ack) + monkeypatch.setattr(dest, "_dial", _never_dial) + try: + with pytest.raises(NegativeAckError) as caught: + await dest.send(payload) + finally: + await dest.aclose() + _assert_two_messages_refusal(caught.value, "MLLP") + + +@pytest.mark.parametrize("payload", TWO_VARIANTS) +@pytest.mark.parametrize("persistent", [False, True]) +async def test_both_tcp_send_paths_refuse_two_messages_before_any_dial( + monkeypatch: pytest.MonkeyPatch, persistent: bool, payload: str +) -> None: + dest = _tcp(persistent) + monkeypatch.setattr(dest, "_dial", _never_dial) + try: + with pytest.raises(NegativeAckError) as caught: + await dest.send(payload) + finally: + await dest.aclose() + _assert_two_messages_refusal(caught.value, "TCP") + + +async def test_the_mllp_send_control_frames_one_message(monkeypatch: pytest.MonkeyPatch) -> None: + # The control for the refusals above: the same destination sends one message as one frame. + dest = _mllp(persistent=False, no_ack=False) + sent = _record_wire(monkeypatch, dest) + await dest.send(CLEAN) + assert sent == [MLLP_CODEC.frame(CLEAN)] + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param(CLEAN, id="one-hl7-message"), + pytest.param(X12, id="x12-relay"), + # Only an MSH-led payload is read: an opaque payload that merely carries MSH lines is not. + pytest.param("relay header\n" + TWO.replace(CR, "\n"), id="not-msh-led"), + ], +) +async def test_the_tcp_send_carries_one_message_and_an_opaque_relay( + monkeypatch: pytest.MonkeyPatch, payload: str +) -> None: + dest = _tcp() + sent = _record_wire(monkeypatch, dest) + await dest.send(payload) + assert sent == [STX_ETX_CODEC.frame(payload)] + + +@pytest.mark.parametrize( + ("dest", "kwargs", "transport"), + [ + pytest.param(lambda: _SendRecorder(), {}, "MLLP", id="mllp"), + pytest.param(lambda: _SendRecorder(), {"rewrite": False}, "MLLP", id="mllp-as-stored"), + pytest.param( + lambda: _SendRecorder(hl7_raw_separators=True), {}, "MLLP", id="mllp-raw-separators" + ), + # The delimiter override re-encodes first; the refusal is still this one, not "reencode". + pytest.param( + lambda: MLLPDestination( + Destination( + name=DEST, + type=ConnectorType.MLLP, + settings={"host": "127.0.0.1", "port": 1, "encoding_characters": "#@*!%"}, + ) + ), + {}, + "MLLP", + id="mllp-delimiter-override", + ), + pytest.param(lambda: _tcp(), {}, "TCP", id="tcp"), + ], +) +def test_check_frame_refuses_two_messages_as_a_send_does( + dest: Any, kwargs: dict[str, Any], transport: str +) -> None: + # check_frame is what a batch member (rewrite=False) and a shadow outbound are judged by, so + # it must answer as a live send does; CLEAN is the control. + check = dest().check_frame + check(CLEAN, **kwargs) + with pytest.raises(NegativeAckError) as caught: + check(TWO, **kwargs) + _assert_two_messages_refusal(caught.value, transport) + + +@pytest.mark.parametrize( + "body", + [*TWO_VARIANTS, CLEAN, CLEAN.replace(CR, "\n"), "relay header\n" + TWO, X12], +) +def test_the_delivery_reading_is_the_ingress_reading_of_an_msh_led_payload(body: str) -> None: + # The ingress guard reads normalized text; a delivery reads the payload as it will send it. + # On the text a receiver reads once it drops each byte order mark, each must find what the + # other finds. The ingress guard itself reads no mark: the parser refuses a body one leads. + normalized = normalize(body).replace(split._BOM, "").lstrip() + expected = starts_with_msh(normalized) and split._holds_more_than_one_message(normalized) + assert split._sends_more_than_one_message(body) is expected diff --git a/tests/test_outbound_batch.py b/tests/test_outbound_batch.py index c4a6b30d5f..a028263ac6 100644 --- a/tests/test_outbound_batch.py +++ b/tests/test_outbound_batch.py @@ -24,6 +24,7 @@ from messagefoundry.config.models import BatchConfig, ConnectorType, Destination, RetryPolicy from messagefoundry.config.settings import EgressSettings from messagefoundry.config.wiring import Registry +from messagefoundry.framing import MLLP_CODEC from messagefoundry.parsing.split import split_batch from messagefoundry.pipeline import stage_dispatcher, wiring_runner from messagefoundry.pipeline.alerts import LoggingAlertSink @@ -534,9 +535,9 @@ async def _enqueue_bodies(store: Any, bodies: Sequence[str]) -> list[str]: ] -async def _run_one_batch(store: Any, *, simulate: bool, **field: Any) -> _MllpRecorder: +async def _run_one_batch(store: Any, *, simulate: bool, dest: Any = None, **field: Any) -> Any: runner = _runner(store) - rec = _MllpRecorder(**field) + rec = _MllpRecorder(**field) if dest is None else dest _wire_batch(runner, rec, BatchConfig(max_count=5, max_wait_ms=1)) runner._simulate[DEST] = simulate head = await store.claim_next_fifo(DEST) @@ -626,6 +627,57 @@ async def test_a_member_is_judged_as_it_sits_in_the_envelope_not_after_a_rewrite assert await _states(store, mids) == [DELIVERED, DEAD_LETTERED, DELIVERED] +@pytest.mark.parametrize("at", [0, 1], ids=["head", "middle"]) +@pytest.mark.parametrize("simulate", [False, True], ids=["live", "shadow"]) +async def test_a_member_holding_two_messages_is_dead_lettered_alone( + store: Any, simulate: bool, at: int +) -> None: + # ADR 0209: a member is judged as stored, before the envelope exists. Before it, the member's + # second MSH went into the envelope, and BTS-1 counted one message fewer than the batch held. + # Refused at the head, the envelope's BHS-11 comes from the first member that survived. + bodies = [_msg(1), _msg(3)] + bodies.insert(at, _msg(2) + _msg(4)) + mids = await _enqueue_bodies(store, bodies) + rec = await _run_one_batch(store, simulate=simulate) + if simulate: + assert rec.sent == [] + else: + (envelope,) = rec.sent + assert [m.split("|")[9] for m in split_batch(envelope)] == ["MSG1", "MSG3"] + assert envelope.endswith("BTS|2\r") + assert envelope.split("\r")[0].split("|")[10] == "MSG1" # BHS-11, the first kept member + expected = [DELIVERED, DELIVERED] + expected.insert(at, DEAD_LETTERED) + assert await _states(store, mids) == expected + (row,) = await store.outbox_for(mids[at]) + error = str(row["last_error"]) + assert "more than one MSH segment" in error and "DOE" not in error and "MSG" not in error + + +async def test_the_batch_envelope_still_goes_out_through_the_real_send( + store: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + # ADR 0209 refuses an MSH-led payload holding a second MSH. The envelope holds N of them, and + # it leads with BHS, so the real send() frames it: this is the ADR 0082 batch path unharmed. + sent: list[bytes] = [] + + async def record(wire: bytes, sent_control_id: str | None) -> None: + sent.append(wire) + + dest = MLLPDestination( + Destination(name=DEST, type=ConnectorType.MLLP, settings={"host": "127.0.0.1", "port": 1}) + ) + monkeypatch.setattr(dest, "_send_once", record) + mids = await _enqueue(store, 3) + await _run_one_batch(store, simulate=False, dest=dest) + (wire,) = sent + (frame,) = list(MLLP_CODEC.decoder().feed(wire)) + envelope = frame.decode("utf-8") + assert envelope.startswith("BHS|") and envelope.endswith("BTS|3\r") + assert [m.split("|")[9] for m in split_batch(envelope)] == ["MSG1", "MSG2", "MSG3"] + assert await _states(store, mids) == [DELIVERED, DELIVERED, DELIVERED] + + async def test_a_non_hl7_member_is_not_rewritten_per_member(store: Any) -> None: # Only the envelope is rewritten, in send(). A per-member rewrite raised DeliveryError on a # member with no MSH and retried the whole batch. From 86f08bf007c4e502f9ff5c648094542996472154 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sat, 17 Oct 2026 01:22:16 -0500 Subject: [PATCH 2/2] ADR 0209: MLLP and TCP deliveries refuse a payload holding more than one MSH Records the outbound decision ADR 0206 rule 5 deferred, with its index row, a forward link from ADR 0206, the CONNECTIONS.md MLLP and TCP notes, and the changelog fragment. HTTP and File outbounds are out of scope; no batch-sender opt-out (ADR 0082 is the batch path). BACKLOG #2858 Proposed PR title: MLLP and TCP deliveries refuse a payload holding more than one MSH (BACKLOG #2858, ADR 0209) Proposed ledger banner: DONE -- an MLLP or TCP delivery refuses an MSH-led payload holding a second MSH line as a permanent "multiple-messages" dead-letter, on the live send, each batch member and a shadow outbound; the BHS envelope and X12 relays are not read. The decision (refuse, no batch-sender opt-out, HTTP and File out of scope) is the batch 265 Manager's under the owner's driver rule, recorded in ADR 0209; the owner may overrule at review. --- changelog.d/2858.security.md | 11 ++ docs/CONNECTIONS.md | 18 ++- ...encode-never-lets-data-become-structure.md | 4 +- ...use-a-payload-holding-more-than-one-msh.md | 151 ++++++++++++++++++ docs/adr/README.md | 1 + 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2858.security.md create mode 100644 docs/adr/0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md diff --git a/changelog.d/2858.security.md b/changelog.d/2858.security.md new file mode 100644 index 0000000000..325d1faa1a --- /dev/null +++ b/changelog.d/2858.security.md @@ -0,0 +1,11 @@ +- **An MLLP or TCP delivery refuses a payload holding more than one message.** In the shipped code + a Handler that returned two HL7 messages in one `Send` had them framed and sent as one frame, so + a receiver splitting on `MSH` would read two messages under one acknowledgement and the engine + would count one. Now a payload that opens with `MSH` and holds a second `MSH` line, CR or LF line + ends alike, is a permanent failure with code `multiple-messages`, dead-lettered at once with text + that carries no message content. A live send, each batch member and a shadow outbound give the + same answer. The `BHS` envelope of outbound batch aggregation, an X12 relay over TCP and any + other payload not led by `MSH` are never refused by this rule. HTTP and File outbounds are + unchanged. See + [ADR 0209](../docs/adr/0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md). + Vault BACKLOG #2858. diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md index 7027092eef..c5ad3adcdb 100644 --- a/docs/CONNECTIONS.md +++ b/docs/CONNECTIONS.md @@ -468,6 +468,14 @@ Two read/diagnostic endpoints back the console's connection view (auth + per-cha > before parsing if it exceeds **16 MiB** or **10,000 segments** (`ERROR` disposition + AR NAK), > bounding both the tolerant peek and the strict (hl7apy) validation paths. +> **One message per outbound frame.** An MLLP delivery refuses a payload that opens with `MSH` and +> holds a second `MSH` line, CR or LF line ends alike: a Handler that returned two messages in one +> `Send`. It is a permanent failure with code `multiple-messages`, dead-lettered at once, with text +> that carries no message content. A batch member holding two messages is dead-lettered alone, and +> a shadow (`simulate`) outbound records the same. To send a batch, use outbound batch aggregation +> (ADR 0082), whose `BHS` envelope this rule never refuses. See +> [ADR 0209](adr/0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md). + ### Raw TCP — `Tcp(...)` A raw-TCP transport (source **and** destination) with **configurable delimiter framing**, built to @@ -520,8 +528,14 @@ inbound("TCP-IN_PARTNER_X12", Tcp(port=9100, framing="stx_etx", ingress commit, so a Handler's return value never reaches that connection. Reply to a partner by returning `Send("", payload)`; returning the payload bare is an authoring error and raises (ERROR / dead-letter, replayable) rather than delivering. -- **Opaque relay.** Bytes in = bytes out (delimiters stripped/added) — no transformation, - validation, or content sniffing in the connector. +- **Opaque relay.** Bytes in = bytes out (delimiters stripped/added) — no transformation or + validation in the connector. An outbound delivery looks inside in at least two ways: it refuses a + payload holding its codec's own frame bytes (ADR 0205), and one that opens with `MSH` and holds a + second `MSH` line, as MLLP does, with the permanent code `multiple-messages` + ([ADR 0209](adr/0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md)). A + payload not led by `MSH`, an X12 interchange included, is never read for the second. A TCP + outbound has no batch aggregation (that is MLLP only), so a partner that takes several HL7 + messages per frame cannot be fed them from one `Send`. - **At-least-once / duplicates.** An outbound send (and its framed reply, when expected) may be retried, so the receiver may see a duplicate — **the receiver must be idempotent.** - **Egress allowlist.** A `Tcp(...)` destination is gated by `[egress].allowed_tcp` (host or diff --git a/docs/adr/0206-an-hl7-write-or-re-encode-never-lets-data-become-structure.md b/docs/adr/0206-an-hl7-write-or-re-encode-never-lets-data-become-structure.md index d9ddf21e4f..a01327279b 100644 --- a/docs/adr/0206-an-hl7-write-or-re-encode-never-lets-data-become-structure.md +++ b/docs/adr/0206-an-hl7-write-or-re-encode-never-lets-data-become-structure.md @@ -287,7 +287,9 @@ sources split it. What remains is a Handler that builds one itself, by returning messages in one `Send`, or a `Message` parsed from such text. That is structure the Handler author wrote, not data becoming structure, so it is outside this decision. The same payload sent unbatched over MLLP is one frame holding two messages. Whether a delivery should refuse a payload holding more -than one `MSH` is left to its own item. +than one `MSH` is left to its own item. *Decided 2026-10-17 by +[ADR 0209](0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md), vault +BACKLOG #2858.* ## Acceptance Criteria diff --git a/docs/adr/0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md b/docs/adr/0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md new file mode 100644 index 0000000000..a089a77964 --- /dev/null +++ b/docs/adr/0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md @@ -0,0 +1,151 @@ +# 0209 -- MLLP and TCP deliveries refuse a payload holding more than one MSH + +- **Status:** Accepted (2026-10-17, on build; decision delegated by the owner under the driver rule and taken by the batch 265 Manager; the owner may overrule at review) +- **Date:** 2026-10-17 +- **Related:** vault BACKLOG #2858 (the item this closes), #2560 (the inbound refusal), + #134 (outbound batch aggregation), [ADR 0206](0206-an-hl7-write-or-re-encode-never-lets-data-become-structure.md) + (rule 5, the inbound half, which defers this decision), [ADR 0205](0205-an-outbound-frame-holds-exactly-one-message.md) + (one outbound frame holds one message; the check this one sits beside), + [ADR 0082](0082-outbound-batch-aggregation.md) (the `BHS` batch envelope), + [ADR 0204](0204-a-delivery-the-message-itself-makes-impossible-uses-the-permanent-failure-class.md) + (a delivery the message itself makes impossible is a permanent failure), CLAUDE.md section 2 (the count-and-log invariant) + +Built with the change, in the same pull request. With zero deployments (CLAUDE.md section 0), +nothing below is a live exposure: it is what a deploying site would have hit. + +--- + +## Context + +ADR 0206 rule 5 makes the MLLP, TCP and HTTP listeners refuse an inbound `hl7v2` body holding more +than one `MSH`, and the File and remote-file sources split one. Its section *What `encode_batch` +does with a member holding a second `MSH`* names what is left: a Handler that returns one `Send` +whose payload holds two messages, as text or as a `Message` parsed from such text. ADR 0206 leaves +whether a delivery refuses that payload to its own item, which is this one. + +Before this change nothing on the outbound side looked. `MLLPDestination.send` and +`TcpDestination.send` framed the payload they were given, so two messages went out as one frame +under one acknowledgement. A receiver that splits on `MSH` would read two messages, and the +engine's count of what it sent would be one short: one outbound row, one disposition, two +messages. That breaks the reading of the count-and-log invariant ADR 0205 relies on, that one +outbound row is one delivered message. A Handler bug or a pasted batch is the realistic cause. + +## Decision + +**An MLLP or TCP delivery refuses a payload whose first segment is `MSH` and that holds a second +`MSH`.** + +1. **Which payload.** One that opens with `MSH` after whitespace and any byte order mark + (U+FEFF), and holds another line that starts with `MSH`. The mark is read past because + `split_batch` reads past it too, and a receiver that drops it reads the messages behind it, + although the engine's own parser refuses a body it leads. A line break is a CR or an LF, so a + payload with LF or CRLF segment ends is read the way a receiver that normalizes them reads it. The reading is ADR 0206's: `_holds_more_than_one_message`, the ingress guard's, + moved unchanged from `pipeline/ingress_guards.py` to `parsing/split.py` beside `_MSH_BOUNDARY`. + The delivery's `_sends_more_than_one_message` sits beside it. It is the same reading on a + payload that is not normalized, with searches that stop at the first hit, and a test holds the + two to one answer on the normalized text of every shape it tries. The ingress guard reads + normalized text, so it keeps its CR-only count and pays no LF scan. +2. **The refusal.** `NegativeAckError(permanent=True)` with `code="multiple-messages"` + (`MULTIPLE_MESSAGES_CODE` in `transports/framing.py`), so the delivery worker dead-letters the + row at once (ADR 0204). The text names the transport and the rule, never message content. +3. **Where.** In `check_frame_bytes` in `transports/framing.py`, before the encode. The MLLP and + TCP outbound connections reach it on at least three paths: `send()` through + `frame_for_delivery`, before any dial, and `check_frame` for each batch member and for a + shadow (simulate) outbound. So those three give one answer, and a refused payload opens no + connection. On MLLP it judges the payload after the + delimiter rewrites `send()` applies, as ADR 0205 rule 1 does. A rewrite keeps each `MSH` line, + so a two-message payload the rewrite accepts is refused with this code under + `encoding_characters` and `hl7_raw_separators` too; a test pins both. A payload the rewrite + itself refuses, such as one whose second message holds a target delimiter as data, keeps the + rewrite's code, `reencode`, because the rewrite runs first. +4. **Batch members are judged as stored** (`rewrite=False`), before the envelope is built. A + member holding two messages is dead-lettered alone and the rest batch, so `BTS-1` again counts + the messages the envelope holds. +5. **No batch-sender opt-out.** On MLLP, batch sending already exists through ADR 0082 + aggregation. Its envelope opens with `BHS`, not `MSH`, so the refusal never reads it. TCP has + no batch aggregation (wiring refuses `batch` on anything but MLLP), so a TCP partner that takes + several HL7 messages per frame has no sanctioned path; the one left is a Handler-built envelope, + under rule 6. That cost is accepted: no such partner is known, and an opt-out would be a second + batch path with no `BTS-1` count. +6. **Only an `MSH`-led payload is read.** A relayed X12 interchange over TCP, or any other opaque + payload, is never refused by this rule, whatever lines it carries. So is a Handler-built + payload that opens with `FHS`, `BHS` or another segment: an envelope is a batch its author + declared, and this rule does not check its `BTS-1`. That holds for a batch member too, so a + member led by anything but `MSH` is carried into the envelope whatever it holds. + +**Out of scope, on purpose.** HTTP outbounds, which do not frame. An HL7 body holding two messages +POSTed over HTTP still reaches its receiver as one delivery, the count gap this ADR closes for +MLLP and TCP; the engine's own HTTP listener refuses that body (ADR 0206 rule 5). File and +remote-file outbounds, which may legitimately write a batch file. + +## Acceptance Criteria + +- **AC-1** -- IF an MLLP or TCP delivery's payload opens with `MSH` and holds a second `MSH` line, + with CR, LF or CRLF line ends, leading whitespace or a leading byte order mark, THEN THE SYSTEM SHALL refuse it before any + dial, on all four MLLP send paths and both TCP send paths, with `NegativeAckError(permanent=True)`, + code `multiple-messages`, and text that holds no message content. + -> `tests/test_one_frame_one_message.py::test_all_four_mllp_send_paths_refuse_two_messages_before_any_dial`, + `tests/test_one_frame_one_message.py::test_both_tcp_send_paths_refuse_two_messages_before_any_dial` +- **AC-2** -- WHEN the payload holds one message, THE SYSTEM SHALL frame it as before (control). + -> `tests/test_one_frame_one_message.py::test_the_mllp_send_control_frames_one_message`, + `tests/test_one_frame_one_message.py::test_the_tcp_send_carries_one_message_and_an_opaque_relay` +- **AC-3** -- WHEN a TCP outbound relays an X12 interchange, or a payload not led by `MSH`, THE + SYSTEM SHALL send it. + -> `tests/test_one_frame_one_message.py::test_the_tcp_send_carries_one_message_and_an_opaque_relay` +- **AC-4** -- WHEN `check_frame` judges such a payload, with or without the MLLP rewrite, THE + SYSTEM SHALL refuse it as `send()` does, and a shadow outbound SHALL dead-letter it. + -> `tests/test_one_frame_one_message.py::test_check_frame_refuses_two_messages_as_a_send_does`, + `tests/test_one_frame_one_message.py::test_a_shadow_outbound_records_what_a_live_send_would` + (its `two-messages` row) +- **AC-5** -- IF one member of an MLLP batch holds two messages, THEN THE SYSTEM SHALL dead-letter + that member alone with content-free text and send the rest as one envelope, live and in shadow. + -> `tests/test_outbound_batch.py::test_a_member_holding_two_messages_is_dead_lettered_alone` +- **AC-6** -- WHEN an MLLP batch is sent through the real `send()`, THE SYSTEM SHALL frame its + `BHS`...`BTS` envelope as one frame. + -> `tests/test_outbound_batch.py::test_the_batch_envelope_still_goes_out_through_the_real_send` +- **AC-7** -- THE SYSTEM SHALL read an outbound payload as the ingress guard reads its normalized + text, when it opens with `MSH`. + -> `tests/test_one_frame_one_message.py::test_the_delivery_reading_is_the_ingress_reading_of_an_msh_led_payload` + +The AC-1, AC-4 and AC-5 tests failed on this branch with `transports/framing.py` put back to +`origin/main`; the controls passed. + +## Options considered + +1. **Refuse an `MSH`-led payload at MLLP and TCP delivery, with no opt-out.** **CHOSEN.** +2. **Allow it where the connection is declared a batch sender.** A second way to send a batch, with + no envelope and no `BTS-1` count, beside ADR 0082's. Rejected. +3. **Split it into N frames.** One outbound row would become N deliveries under one disposition, + which is the count problem again, and a retry after a partial send would repeat the first + messages. Rejected. +4. **Refuse on every outbound.** A File outbound may write a batch file on purpose, and an HTTP + body is not framed. Rejected. +5. **Check only in `check_frame`.** The live single-row path calls `send()`, not `check_frame`, so + a live send would still have framed two messages. Rejected for the shared check both reach. + +## Consequences + +**Positive** -- An `MSH`-led MLLP or TCP payload holding a second `MSH` line is refused on a live +send, a batch member and a shadow outbound alike, and a Handler that returns two messages in one `Send` is visible at once in +the dead-letter queue, with a named code. + +**Negative / risks** -- + +- A Handler that meant to send two messages in one frame now dead-letters every such row. It should + return two `Send`s, or use ADR 0082 batching. +- The check reads the payload twice more per MLLP or TCP delivery, two C-level `str.find` scans + and no copy; a clean payload scans to its end both times. +- The dry-run, `messagefoundry check` and the Test Bench do not run `frame_for_delivery`, so such a + payload previews as a delivery and dead-letters live. That is the gap ADR 0205 names, vault #2824. +- A payload led by `FHS` or `BHS` is never read, so a Handler-built envelope with a wrong `BTS-1` + goes out as written. +- Only a line whose first three characters are `MSH` counts, the parser's reading. A second + message behind a CR and another character, such as `0x0B` toward an `stx_etx` TCP outbound, or a + space, is not refused here. A receiver that trims each line would read it as a message. +- An MLLP or TCP inbound whose `content_type` is not `hl7v2` admits a body holding several + `MSH`-led messages, because ADR 0206 rule 5 guards `hl7v2` only. A pass-through of it to an MLLP + or TCP outbound now dead-letters, after the sender was acknowledged. Declare such a feed `hl7v2`, + so it is refused at the listener. +- The dry-run gap above means the error surfaces at delivery, not at the Handler that caused it. + Checking where a `Send` becomes an outbox row, for MLLP and TCP outbounds, would move it there; + that is not built. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3a60cccef7..3f309f9994 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -231,6 +231,7 @@ what is withheld and what you can request. | [0206](0206-an-hl7-write-or-re-encode-never-lets-data-become-structure.md) | **An HL7 write or re-encode never lets data become structure** (vault BACKLOG #2558, #2559, #2560) -- `Message.set_data` writes a value read from a message as data at every level, and `copy_field` and `split_field` use it for a component or subcomponent source; `set` keeps its meaning for authored text; the Steps view's Copy Field emits `set_data` for a literal leaf source; the advisory handler-security lint gains `leaf-to-whole-field` for the hand-written `msg.set(field, msg.field(leaf))` shape. The MLLP delimiter override escapes a target delimiter found in a leaf with the target escape character and refuses a value it cannot represent, every override failure being `NegativeAckError(permanent=True)`. A source that does not split refuses an HL7 v2 body holding more than one `MSH`, with an `AR` where it has an ACK channel; `RemoteFileSource` now splits a batch like the File source, which is unchanged. Rejected: escaping every whole-field `set`, refusing a mixed-level copy, a marked `str` subclass, splitting on a listener | **Accepted (2026-10-02, on build)** -- decision delegated by the owner under the driver rule and taken by the batch 186 Manager; the owner may overrule at review. Built with the change. Severity conditional per CLAUDE.md section 0. **Amended 2026-10-04** (vault BACKLOG #2861, #2862): the Steps view's Set Field keeps `set` only for the HL7 separators `^ ~ & \`, so a template holding `:`, `>` or `*` writes `set_data`; the `leaf-to-whole-field` finding's advice names the X12 case | | [0207](0207-a-single-use-enrolment-code-for-a-directory-account-s-first-engine-factor.md) | **A single-use enrolment code for a directory account's first engine factor** (vault BACKLOG #2609, closing item 2; the decision ADR 0142 Amendment C left open) -- a directory account's first engine factor is bound only with a single-use enrolment code an Administrator issues and hands over out of band. Required at the step that binds and stamps (`confirm_mfa_enrollment`, `finish_webauthn_registration`) when the account is `ad`, holds no engine factor, and the session is not stamped MFA-verified: so every Kerberos session and a federated one with the claim gate off, never a claim-gated federated one. That step also re-checks that the account still holds no factor, because the web console's passkey finish route does not, and the TOTP enable turns on only the secret that was verified, since a later begin replaces the staged one today. The passkey finish route gains the per-actor attempt budget the TOTP confirm routes already draw. Disabling the account clears an outstanding code, and every refused code gets one client-facing reply. The handover is the control, so it must leave the directory: an Administrator checks who is asking and never replies through the account's own mail or chat. The rule reads no session mechanism and no `kerberos_enabled`, and holds with `require_mfa` on or off. At least 128 bits, stored as its SHA-256 digest, one per account, 24 hours, spent by one conditional write before the session is stamped. Issued by an Administrator behind step-up, never for the caller's own account, also by an MFA reset of a directory account, and, for a site where no Administrator can issue one, by a host command on `admin-unlock`'s gate, which is not BACKLOG #2711's outage recovery. Rejected: password only, a TOTP-on-directory-password shortcut, an emailed link, a short Argon2 code, gating on the mechanism | **Proposed (2026-10-05)** -- doc-only, no code; drafted on the owner's direction. Merging records it as Proposed and accepts nothing; Accepted needs the owner. Open: the code with `require_mfa` off, a fixed lifetime, a code from an MFA reset, and the host command's reach. Severity conditional per CLAUDE.md section 0 | | [0208](0208-a-theia-desktop-app-gives-interface-analysts-a-simpler-steps-only-editor.md) | **A Theia desktop app gives interface analysts a simpler Steps-only editor** (owner rulings and Manager decisions of 2026-10-07; rewrites the uncommitted 2026-10-02 hosted-Theia drafts after two four-lens reviews) -- two Electron builds sharing one native Theia Steps extension: an analyst build capped at the Steps level, and a developer build that is a full IDE with a Steps / Split / Code switch and no per-user limits. The purpose is a simpler interface, not enforcement. The analyst build signs in to the engine, clears every session gate, proves the session on a non-exempt route at start-up and before each save, and turns on editing only for `code:steps` (new, advisory, read by the editor only) or `code:edit`. That role check is the primary control and a guardrail: the developer build, VS Code and any other tool are outside it. An optional repository check classifies a change as Steps-only by comparing `code` rows, every control header and each typed row's full statement against base, and passes anything else only with a `code:edit` reviewer's approval of the head commit; it holds against a deliberate change only when run from a definition the change cannot edit. Analyst Test takes a generator spec and never passes `--show-phi`. Review R1 is a precondition answered by the R1 fix and a typed-only `lens rewrite` mode the analyst build always sets; both landed in PR 2155 (2026-10-08). Analyst is a built-in role. The analyst build drops three ADR 0076 guardrails (Amendment G). Hosting is deferred to its own ADR. Rejected: a filtered `@theia/plugin-ext` analyst build, per-user limits in the developer build, VS Code for analysts, a Steps editor in the web console. Spec: [`docs/design/theia-analyst-editor.md`](../design/theia-analyst-editor.md) | **Proposed (2026-10-07)** -- doc-only, no editor code. Merging records it as Proposed and accepts nothing; Accepted needs the owner. Licence AGPL-3.0-or-later (owner ruling). Open: spikes S-1 to S-4 (S-1 is met except its Test and language-server legs, measured on a browser target; S-2 is met on the browser build and on Electron, spike S-2b; S-4's classifier half is met by a prototype and its base-ref workflow half is untested), the start-up probe route (an implementation detail), and the differences ADR 0076 Amendment G, G.7, records between PR 2155's lens code and its rules | +| [0209](0209-mllp-and-tcp-deliveries-refuse-a-payload-holding-more-than-one-msh.md) | **MLLP and TCP deliveries refuse a payload holding more than one MSH** (vault BACKLOG #2858; the outbound decision ADR 0206 rule 5 deferred) -- an MLLP or TCP delivery refuses a payload that opens with `MSH` and holds a second `MSH` line, CR or LF line ends alike, as `NegativeAckError(permanent=True)` with code `multiple-messages` and content-free text. The check sits in `check_frame_bytes`, so a live send, each batch member (judged as stored) and a shadow outbound give one answer; the ingress guard's reading moves to `parsing/split.py`, beside the delivery's, and a test holds the two to one answer. No batch-sender opt-out: ADR 0082's `BHS` envelope is the batch path and is never read, nor is an X12 relay or any payload not led by `MSH`. HTTP and File outbounds are out of scope. Rejected: a declared batch sender, splitting into N frames, refusing on every outbound, a `check_frame`-only check | **Accepted (2026-10-17, on build)** -- decision delegated by the owner under the driver rule and taken by the batch 265 Manager; the owner may overrule at review. Built with the change. Severity conditional per CLAUDE.md section 0 | ## Retired allocations — numbers taken but never authored