From 656bbae5007206f0fe62262a1b3a0359425cf91c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 19 Sep 2026 01:04:08 +0200 Subject: [PATCH] Move the decisions to docs/decisions.md and index them in AGENTS.md --- AGENTS.md | 852 +++++----------------------------------------- docs/decisions.md | 772 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 859 insertions(+), 765 deletions(-) create mode 100644 docs/decisions.md diff --git a/AGENTS.md b/AGENTS.md index 42e8372..3a7c258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,7 +168,7 @@ is why a concatenated GSM segment is 153 characters plus a 6-octet UDH — 159 o size and would truncate every long GSM message by a fifth. GSM 7-bit is the only alphabet it applies to. What each of the three is budgeted, and why, is a -decision under [The wire](#the-wire). +decision under [The wire](docs/decisions.md#the-wire). ## Conventions @@ -216,8 +216,10 @@ Each file answers one question, and a fact belongs to the file whose question it reason changes how you would call the thing. - **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the behaviour that changed on the wire. -- **AGENTS.md — what may not change, and why.** Hard rules, architecture, conventions, and the - decisions the goals do not already settle. It does not restate behaviour or goals README states. +- **AGENTS.md — what may not change, and why.** Hard rules, architecture, conventions, and an index + of the decisions. It does not restate behaviour or goals README states. +- **docs/decisions.md — what was settled, and against what.** The decisions the goals do not + already settle, each with the constraint that settled it and the alternative rejected. - **todo.md** is a working file that sets its own rules; nothing here governs it. A sentence living in two of them is a defect: delete the copy in the file whose question it does not @@ -229,776 +231,96 @@ follows from it; a decision record decides one. So reach for the goal list first add one, or move one up the order — and write a decision only for what is left over: a choice a competent change would otherwise re-open, that no goal implies. Give the claim, the constraint that settled it and the alternative rejected, and nothing the code or README already says. Where a -compiler or a test already forbids the other way, it is not a decision, it is a test name. Delete one -once it no longer constrains anything; this is not a changelog. +compiler or a test already forbids the other way, it is not a decision, it is a test name. It goes in +`docs/decisions.md` with its title indexed below. Delete one once it no longer constrains anything; +this is not a changelog. ## Decisions -Grouped by what each one constrains. +The decisions themselves live in [docs/decisions.md](docs/decisions.md). Their titles are indexed +here, so a reader sees that a decision exists without carrying its reasoning; the reasoning is in +the file. -### The public surface +### [The public surface](docs/decisions.md#the-public-surface) -- **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` - public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to - (`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`, - `DlrMerger`, `PduTransport`, `submitSms`) stay unpublished so they can be reshaped. +- `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` + public too. +- `acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints. +- `session.sock` is a getter over `PduTransport`. +- Both emitters re-declare their listener methods to accept a promise. +- `PduRefusedError` is exported, and `sessionError` names it in the event's type. +- `bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because + `EncodingName` is what keeps an alphabet with no codec away from them. +- A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string. -- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own - senders consult them; `session.send({ tlvs })` is passed through as written, because silently - stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than - sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the - three the library dispatches by it, of which it sends the first two. +### [The wire](docs/decisions.md#the-wire) -- **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer - compiles, which never rewired the handlers and so never worked. +- The declared interface version is an option on both `client()` and `server()`, and is not the + optional-parameter threshold. +- A peer that declared no version is pre-3.4, and `undefined` means no bind yet. +- `esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing. +- A body is read from `message_payload` where `short_message` carries none, and `short_message` wins + where a peer filled both. +- A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none, + and each spelling groups in a reference space of its own. +- `sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller + writes. +- An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes + it, and none goes out. +- A receipt's body is read as octets, and its own `data_coding` never says how. +- A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message + with no alphabet to carry it is refused. +- A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and + `SCHEDULED` say otherwise. +- A `stat:` an operator spells outside Appendix B is read as the state it names, and the two + researched ones are `FAILED` and CM.com's `DELIVERD`. +- A transient state goes out as an intermediate delivery notification (0x20), every other state as a + delivery receipt (0x04). +- A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it + arrived. +- The optional parameters run to `command_length` exactly, and the only slack tolerated is one NULL + octet where a peer padded `short_message`. +- `smsIdFormat` names a notation per place, and normalisation never reaches inside a `-` + id. +- A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them + and 134 octets of anything it does not. +- An alphabet the caller named has to carry the message, and a time the format cannot express is + refused, both before a segment goes out. +- A string body is written in the alphabet its own `data_coding` names, and one that alphabet cannot + carry is refused by the codec — `message_payload` on the same terms as `short_message`. +- A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM. -- **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call, - 2026-08-27: `EventEmitter` types every listener as void-returning, so the - `session.on('sms', async sms => …)` README documents reads as a misused promise in any strict - consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`, - which emits nothing and needs no cast; overriding them as real methods cannot work, because the - `super.on()` call needs one. The cost is that a subclass can no longer reach those seven through - `super` — re-declaring them the same way is its way out. `unknown` rather than - `void | Promise` because a listener may return anything: `session.on('close', () => - set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's - own promise would be the better completion signal, and reaching it needs `listeners()`, which - cannot be re-declared the same way — Node types it invariantly enough that widening `void` to - `unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal. +### [The session's life](docs/decisions.md#the-sessions-life) -- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's - call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the - session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows — - without the class as a value an application is left string-matching `err.message`. Goal 7 is paid by - exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because - an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not - because `reason` is compared against string literals, and an accessor - (`PduRefusedError['header']`) names either one where a signature wants it. The payload union - enforces nothing — a subclass narrows out of `Error` either way — and is there so the event's own - type names what to narrow to, which is also what makes it a half-truth if a second `Error` subclass - ever reaches this event without joining it. Rejected: a `SessionError` alias for that union, a - third name for a type that is structurally `Error`. Rejected: a separate `pduRefused` event, which - splits the failure channel so an application that wants every failure listens twice and an existing - listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens - the standing decision that `sessionError` carries every failure, never coalesced or suppressed — - the filtering belongs where the application is, since only it knows which peer is routinely sloppy. - Rejected: an error code on a plain `Error`, which reads back off an `unknown` property only through - a cast and types nothing it carries. Accepted: a second copy of the package installed alongside - this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`. +- A close arriving after our own `unbind` is a clean unbind, not an error. +- `close` means the session is over, and a drop the loop will retry is `disconnected`. +- An answer belongs to the link the message arrived on; a receipt does not. +- `reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off +- Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff. +- `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and + `client()` then resolves only once it is bound. +- A stream this library cannot frame is a dead link; one PDU it cannot parse is not. +- A deliberate shutdown drains; an unusable link and an abort do not. +- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the + application's own signal rather than the peer's answer. +- `server()` composes the application's `onRequest` after its own bind handling, and offers it every + request that handling did not answer. +- The drain waits on the messages the application holds, and `sendResp()` is what says it is done + with one. +- A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. +- A message id base is merged at most once. +- A send that never reached the socket waits for the next link; one that did is counted, not resent. +- A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else. +- The gate decides whether a link can carry a request, and a bind is what makes it one. -- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because - `EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call, - 2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95): - all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1 - forbids. That PR left no such name to pass — `encodings` is a `Record`, so - every member of the union has a codec and one added without a codec, or without a segment budget, - fails to compile in four places. What was missing is the door for a caller holding a name at - runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it - narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`, - which serve their own tables that way. Rejected: a `Result` signature on all three, which costs - every typed consumer a narrow forever — goal 7, and the tag is the last cheap chance to spend it — - to guard a state the compiler refuses. Where the domain really is open the check is already there: - `sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller - without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded - under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`. +### [Internals and tests](docs/decisions.md#internals-and-tests) -- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.** - Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote - empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a - correlation table by the array, compiled and then misbehaved, and one message's empty entry - collides with another's. Telesign names an id for the first segment of a concatenated submit only, - so it is a documented operator's shape rather than a hypothesis. Nothing else moves: - `parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined` - reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]` - because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves - a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()` - reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and - that reading stays separate from this one rather than sharing a helper, since it must leave a - Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked. - What settles it here is the resolved text rather than the parameter, because `writeParams()` - substitutes the field's own default: a peer that omits `message_id` and one that writes it empty - build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''` - and documenting it, which leaves the published type promising what the value does not keep — goal - 2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks - the positional correspondence with `pduObjs` that README promises and loses which segment a PDU - belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional promise - structural where today the compiler cannot check it; deferred to the next breaking release, the - first place two documented fields may become one. Accepted: every consumer reading `smsIds` - narrows, including the majority whose SMSC names every id; indexing narrows too, except for the - consumer who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined` - already. - -### The wire - -- **The declared interface version is an option on both `client()` and `server()`, and is not the - optional-parameter threshold.** That threshold is fixed at 0x34 by the spec, so an implementation - that must declare 5.0 throughout can, without moving it. - -- **A peer that declared no version is pre-3.4, and `undefined` means no bind yet.** `acceptBind()` - records what the ESME declared and the client's `bind()` records the `sc_interface_version` the - SMSC answered with; a peer that declared nothing is recorded as `undeclaredInterfaceVersion` (0x00) - and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`. - -- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.** - The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and - `INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format - `dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound - SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not - scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement - is the handset's word about a message, not the network's. A message type of 0 or one of the ten - reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same - footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one - across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is - authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for - MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to - the body. - -- **A body is read from `message_payload` where `short_message` carries none, and `short_message` - wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability - phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot - carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone - handed the application an empty message - ([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is - the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()` - cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever - it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the - spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the - rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the - TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both, - which discards a message that is almost certainly present twice over, where goal 3 keeps the - traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like - any other segment. - -- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none, - and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from - the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make - `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says, - Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed - the application one `sms` per fragment - ([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). - `concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to - where a body is, and both are exported for the same reason: an application on the low-level - surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the - reference, so the key is two tokens the reassembler joins and interprets neither of, and so the - refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL` - for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would - assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num` - is 16 and neither counts the other's messages; the two UDH widths share a space because they are - one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names - the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps - the change additive for every message that reassembled before, and leaves the library nothing to - guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a - gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the - references are not comparable at all, and where the parts are, the UDH is still what the message - is assembled by, so the report would name a failure the application cannot act on. Accepted: a - peer that switches spelling mid-message now has two groups that expire rather than fragments that - arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()` - goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second - spelling of one message whose only difference is which peers accept it. - -- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller - writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer - the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and - Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every - segment through `send()` — giving up the split, the per-segment ids, the send window and the - receipt merge, which is what goal 7 means by beating "the application can do this itself". The four - modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their - names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH - indicator a segment carrying a header needs cannot be cleared by anything the option can express. - It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out - of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by - that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7 - will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the - report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a - report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they - had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number, - which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected: - taking a number beside a name, two spellings of one goal — which is why a value naming no mode is - refused, by name, before a segment goes out. Rejected: a session-level default with a per-send - override; an operator's requirement is a property of the link, but the library can verify nothing - the caller's own options object does not, and shipping both buys a precedence rule to document and - test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable. - -- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes - it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: - SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and - Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID` - dropped with nothing reported to the application at all. Every command but this one names its own - direction, which is why the bind gate and the dispatch never had to be told which end of the link - they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a - delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a - submission and is read as one, because a report about a message this end never sent is goal 2's - wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by - segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves - `deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs` - rather than a `SessionOptions` entry, so the code that knows which end this is writes it and - nothing else can contradict what the session then binds as. Rejected: grouping the command with - `deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with - `submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one — - `send()` reaches the command raw, and an option choosing which command a message goes out on would - be a second spelling of `sendSms()` whose only difference is which peers accept it. - -- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's - call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding` - onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same - echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss - against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads - `PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a - character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings - because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding` - where the octets yield no field, which reads one body two ways for the sake of a peer writing a - UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here, - which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing - but `data_coding` can say how a message was written. - -- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message - with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target - of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the - ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only - one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM - 03.38, and the one SMPPSim demonstrated - ([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17). - `messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as - `concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0, - carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and - `encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group - masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class - other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the - `sms` event, which pays goal 7 for three classes nothing here acts on, where the boolean the - application already had covers the one it does. Compressed text is out of scope and stays out — - nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever - its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as - class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38 - coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an - extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the - alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the - class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins - (0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is - spelling 03.38 at all. Send-side: `flash` - is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while - `encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this - library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is - SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets, - which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to - the handset that would display it; rejected: promoting it to UCS2, which overrides the one option - the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which - named `data_coding` 0x10 among the alphabets and so reached the class through the option that - chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character - to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()` - and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a - caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING` - keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group - as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no - peer means as immediate display. - -- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and - `SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final, - `UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken - at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this - library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not - withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a - second time in `DlrMerger`, so the library cannot answer the application one way and conclude the - other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE` - is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's - `SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying - nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an - absent one cannot land there. - -- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two - researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the - operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal - delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints - `DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN` — - the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an - operator's "it failed" from its "I do not know", nor a delivered message from one whose state - could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send - carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on - writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which - is 3.4's own numbered table — the code has no number there, so one would have to be invented, and - every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it - `UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal - failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the - numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of - their own rather than the seven characters `stat:` holds. Accepted: all three of those operators - document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an - application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote. - Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a - report where it used to arrive as an inbound message, which is what every code already in the table - does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the - page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a - spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely - contain a typo saves an application everything where they do not. - -- **A transient state goes out as an intermediate delivery notification (0x20), every other state as - a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so - 0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to - reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list - from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift. - Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost - accepted here is that such a peer stops recognising a transient report as a report at all and hands - its application receipt text as an inbound message, where under 0x04 it would have read the state - from `stat:` and been right. A transient state also carries `err:000`, since a message still on its - way has not failed. - -- **A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it - arrived.** Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always - parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part - that would not parse. Rejected: nacking a refused *response*, whose sequence number is one of - ours — the `generic_nack` would land in the peer's own numbering and nack a request of the peer's - we never saw, so a refused response is written back nothing and settles the request it names - instead. An unknown command id with the response bit set takes that branch too: a peer echoing a - sequence number of ours is answering something, and settling it reaches the undetermined outcome - `responseTimeout` would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x00000001–0x7FFFFFFF into range - before answering, which correlates with nothing at the peer — stacks write the field as a plain - uint32 (ukarim/smscsim signs every unprompted `deliver_sm` with a raw `rand.Int()`), so goal 3 - keeps that traffic and `PendingRequests.nextSeqNr()`, the only thing that invents one, is what - holds our own sends inside the spec. - -- **The optional parameters run to `command_length` exactly, and the only slack tolerated is one - NULL octet where a peer padded `short_message`.** Maintainer's call, 2026-09-06, from the - Java-client interoperability phase: accepting any parse that merely did not error answered - `ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the - `receipted_message_id` that makes a receipt a receipt - ([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). Goal 2 - settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region - that does not end on `command_length` — the padded read included — is refused with the `tlvs` - reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid - once, in `readCstring()`: a trailing C-Octet String a peer left out entirely consumes no octet, - where reporting the terminator it never sent puts every later offset past the declared end and - refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them - at the tail all read empty — `outbind` is the only command with two, and an absent field and an - empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance - for the one to three trailing octets too few to hold a TLV header, which no researched peer sends - and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as - `body`/`ESME_RINVCMDLEN`, which names the mandatory fields — the part the peer got right. - -- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `-` - id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one - transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id` - TLV too, which SMPP 3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one - notation for whichever id a receipt yields would break the peer that sends both. Omitting a place - is what leaving it alone means, so there is no `raw` notation, and a caller-supplied formatter is - refused because it would make the promise that the two ids are comparable unverifiable — `onRequest` - and the PDU on the `dlr` event are the escape hatches. A `-` id parses as no number and so - reaches `expect()` and `collect()` unchanged, which is what keeps `DlrMerger` working; normalising - the base instead would break that pair. The option is on `client()` only, since a `server()` session - writes both ids itself. - -- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them - and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture - review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to - everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus - a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns - it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so - Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153 - that is the odd number rather than the other way round. `Record` is what makes - a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the - unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters - one SMS holds now costs more segments than it did, and `smsIds` is that much longer. - -- **An alphabet the caller named has to carry the message, and a time the format cannot express is - refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and - stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on - `あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'` - flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote - `NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the - caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can - carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks - the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it, - so the guard cannot drift from what the encoder writes for any one character, and a fourth - alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller - composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read - this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()` - reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit - binary body Latin-1 is kept for. The guard is - on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns - carries every character it was picked for, over the whole code point range. `smppTime.encode()` - returns a `Result`, where the three encoding helpers stayed total: that argument was that - `EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an - invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit - one", and `decode()` has been fallible for the same reason since it was written. Rejected: - transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice - — the same reason a flash Latin-1 message is refused rather than promoted, and an operator that - accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected: - guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this - library's own published helper composing the garbage the guard exists to stop. Rejected: a - `holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with - the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller - formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the - field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to - it, a negative one and `Infinity` with it — clamping `86400 * 365` reported success for a year and - put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists - to remove. The ceiling is this encoder's rather than the format's: 3.4's `YYMMDDhhmmss000R` - carries years and months, which `decode()` reads back, and no fixed number of seconds is either - one, so spelling a second count in days and below is where the guess would go — which is why the - too-long refusal names the `Date` that reaches every instant the absolute form holds, and the - negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which - leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an - extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is - the one input a per-character reading passes and the encoder then writes as the extended character - — the only composition in any of the three codecs, and not a character a message is written in. - -- **A string body is written in the alphabet its own `data_coding` names, and one that alphabet - cannot carry is refused by the codec — `message_payload` on the same terms as `short_message`.** - Maintainer's call, 2026-09-09, from the architecture review of - [#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's - own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう` - returned `42 44 46` — `"BDF"` — reported as built, while a string `message_payload` was cut to its - low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above. - The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given - under any `data_coding`, which is what keeps goal 7's escape hatch open — the raw UDH, 8-bit binary - and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string - with no `data_coding` is untouched, detection carrying every character it was picked for. The - guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the - character, its code point and its index said once for both refusals — unexported where - `unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a - caller would get wrong, where asking the codec is, and publishing it would freeze this library's - error prose as API for an application whose own refusal should read like itself. Goal 7, from the - architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is - reached through - `encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives: - `encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the - alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it, - since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because - `splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment - could name is not the one in the message. The TLV is encoded rather than merely checked because - `data_coding` names the alphabet of the body wherever it is carried — that is how - `messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one - — so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would - close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the - order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will - be read under — and a `short_message` on a command whose table declares none is ignored here as - `writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the - same input rather than three. A `data_coding` on a command that declares no such field is honoured - the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in. - Every entry carrying the payload tag is resolved, by tag id rather - than by record key, since `tagIdOf()` lets a caller name it anything and a spelling that escaped - the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string - `message_payload` outright and demanding - octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued - field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's - own definition. - -- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.** - Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet - through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT - T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's - STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the - same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written - under, as `unencodable()` is to whether one can carry a message: the mirror of - `encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected - one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is - exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a - `data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the - *SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee; - it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile - and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and - so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01 - to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no - researched peer means IA5 by it. The two tables agree over most of the printable range and part at - 0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed, - carriage return and escape are common to both — which is where a peer that did mean IA5 is - misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two - names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different - values and so the only one a reader could carry from the option's vocabulary into SMPP's flat - table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name. - Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the - section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding` - option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as - Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing - researched has found. Accepted: a message already in flight is unmoved — both codings resolve to - the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the - concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the - change reads exactly as it did. - -### The session's life - -- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call, - 2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would - otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated - reason, which is accepted — the peer sees the same TCP close either way. - -- **`close` means the session is over, and a drop the loop will retry is `disconnected`.** - Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on - `close` ends up holding two binds on one account. `teardown()` picks the event by whether the - reconnect loop is still live, and `end()` stops that loop before tearing down, so every deliberate - shutdown emits `close`. A retry that opens a socket and then loses it clears `closed` through - `attach()`, which is why a second drop emits again. - -- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call, - 2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response - that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused - response names an id the peer has no record of. - -- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means - on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a - connection the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d, - so a process with nothing else left to do still exits between attempts. - -- **Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.** - An unreadable stream is found after the bind returns, so resetting on connect gave a link that died - on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop - after a healthy link still retries at `minDelay`. - -- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and - `client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application - started before its SMSC is up otherwise writes that retry itself, around the one this library - already owns. A field on `reconnect` rather than an option of its own, so the combination that - would contradict `false` cannot be written at all — `false` carries no fields — and a top-level - `fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the - wait: a bound of its own would be a second spelling of a deadline the caller already writes with - that signal, and giving up after one is what the default does. A bind the SMSC refuses is - retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`, - which would have the initial attempts and a rebind disagree about what a refused bind means, and - gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the - rate goal 4 cares about. The attempts before the first link report nothing, because the session - running one has not reached the application: `disconnected` would have no listener and `close` - would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason - `LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is - `client()` would exit unbound. - -- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.** - Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below - 16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the - link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh - framer. Every other codec failure honoured `command_length`, so the stream is still in sync and - the next PDU starts where it says — tearing the link down there cost one peer half its receipts - and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting - for answers it was owed. `sessionError` carries every failure of either kind, never coalesced or - suppressed, so a peer that only ever sends garbage is visible in the log rather than silent. - -- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()` - wait on the send window rather than the pending map — the map misses a segment still queued behind - a full window, and finishing a half-sent multipart message is the point. The window counts slots, - never outcomes, and empties on a drop too, where `teardown()` settles everything the link was - carrying, which is why `drain()` reads `closed` before it reads the count. A stream the framer or - the codec cannot read takes `teardown()` instead, and `close({ signal })` on an aborted signal and - a peer's own `unbind` take `end()`: nothing on a dead link can answer, an abort means stop now, and - a peer that has declared itself finished will not answer what it still owes, so draining any of the - three would only hold a socket open for the timeout. `unbind()` sends its own PDU through - `request()` past both the window and the drain gate, because it must go out either way. - `shutdownTimeout` stays a session option rather than a `close()` argument: `server()` builds - sessions on the caller's behalf, so the option is the only composition point. `SmppServer.close()` - reports each session's unfinished drain through `serverError`, because its own result says nothing - but that the listener stopped. - -- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the - application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the - Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will - not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole - deadlocked every multi-segment message against a production gateway - ([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). Goal 1 has the answer - a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is - generated when it opens and each segment is answered `-`, the notation `sms-id.ts` owns - and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId` - or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent - no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the - discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is - untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for - an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` - answers every segment it will not carry rather than leaving it unanswered, which is the same stall - in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL` - where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: - answering every segment but the one that completes the group, which leaves the peer holding some - segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour - a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment, - which asks the application to name a message it cannot read yet — what it wants is `sms.smsId` - afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only - distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or - dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` - as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the - `PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the - application could act on, and goal 7 does not buy a second exported class to make a count - distinguishable. Accepted: a completing segment whose own answer the socket would not carry still - reaches the application, because the message is whole and correct and the failed answer is on - `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message - in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can - never precede the acceptance of the message it reports on. - -- **`server()` composes the application's `onRequest` after its own bind handling, and offers it - every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review - of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch - the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up - bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is - what goal 7 means by beating "the application can do this itself". What the library verifies is the - ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind - request on a session already bound, so no bind — a second one on a live session included — and - nothing a peer sends before one can be intercepted however the hook is written. One - `OnRequest` type on both option bags, because a second contract under one name is two spellings of - one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need - not be `async`. Nothing of ours is written for a request whose hook failed, the same on both - surfaces: the library cannot tell one that failed before answering from one that failed after, so - goal 2 reports the outcome as undetermined rather than guessing, and the peer's own - `responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that - throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all, - and is visible only as the request that was never answered. That takes the keepalive with it, since - a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the - back-pressure wanted, because an application that cannot serve a link should not hold one. - `sessionError` rather than `serverError` because the - failure belongs to one session's request, and that channel already carries every failure of one. - The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound - peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`. - Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the - hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where - `AuthenticateInput` is exported, because that hook's argument is a shape this library invents and - this one's are two types already published. Rejected: consulting the hook first, which puts - bind and authentication inside the application's reach for nothing. Rejected: a narrower hook - returning a status for the library to write, which makes the answer verifiable but pays a second - contract under a second name for it, and could not express what the session-level hook already - does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only - half the surface can do. Rejected: falling the request through to the built-in handling on a - failure, which reads as the answer the peer would have had with no hook — true only of a hook - that failed before answering, where one that failed after put a second response on the peer's own - sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the - fall-through could be gated on it, which buys a fail-open path with state and an internal contract - no other collaborator needs. - -- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done - with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session - down while the application was still answering a `submit_sm`, so the peer timed out and re-sent — - the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was - added to the `sms` event: `sendResp()` is what an application already calls when it is done with a - message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected — - an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every - close — and a message no listener took is released at once, since nothing is going to answer it. - A listener that failed before answering gives it up the same way, but only once every listener has: - a throw stops `emit()` where it stands, while a rejection leaves the others running, so the release - waits for the last of them rather than answering on their behalf. What ends the wait is the response - reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not - carry, leaves the message held, so `close()` still reports the one the peer is owed. Where the - segments were answered as they arrived there is no response left to write, so the call itself ends - the wait, an argument the library refuses excepted. `teardown()` - drops what is still held for the same reason it drops inbound segments. The release is one turn - late, so a listener that sends its receipt straight after the response is still holding when the - drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, and only while the - message is still held — past that it is an ordinary send, because the drain it would slip past is - no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half: - waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the - caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is - what you reach for when the application is stuck, so it may not block on the application coming - unstuck. That half falls back to `responseTimeout`, the same answer the link gate's hold already - takes — and to that option's default where it is 0 as well, since neither option is an answer about - the application. What is held is capped and expiring like every other inbound store, on constants - rather than options, because a bound the application cannot raise is the point: an application that - answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message - that falls out of the bound is one the drain stops waiting for, so `close()` can report fewer - unanswered than there were — accepted, because the alternative is holding what nothing will answer, - and both exits are logged. - -- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.** - `onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()` - runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the - merges there loses receipts no peer has a reason to send again. They are cleared where the session - is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the - peer's own counter, so a half-arrived group kept across a drop would take a later message's - segments as readily as the rest of its own, and goal 2 will not hand the application a message - assembled that way. What goes there is traffic already answered, which is why each group reaches - `sessionError` like every other one given up on. - -- **A message id base is merged at most once.** A receipt carries nothing but `-`, so a - straggler for a message whose group is gone cannot be told from a receipt for a later message the - peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic - case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the - groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an - earlier one whose receipts are still arriving is dropped rather than left to collect the later - one's. Every segment still reaches the application as a `dlr`. `expect()` ignores a lone id, so a - single-part message never claims a base. - -- **A send that never reached the socket waits for the next link; one that did is counted, not - resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a - `submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed twice, - while a request that never left this process can be lost for free. `attempt()` therefore wraps all - three ways a written request can fail in `UnansweredError`; counting only the dropped-link case, as - the first cut did, would have called the commonest one safe to resend. A count rather than a - boolean because `sendSms()` aggregates segments into one `err` slot, and required rather than - optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is - the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an - option of its own — that is already the answer to how long one request may wait — and its clock - starts when the send is issued rather than when it first finds the gate shut, so one budget covers - every hold a single call makes. That timer is the one here that is not `unref()`'d: a held request - is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining - work is that send exit without settling it. - -- **A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.** - Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the - `acquire()` on the next line did not, so a caller that aborted while the window was full waited for - a slot it no longer wanted — at `responseTimeout: 0` for as long as the peer stayed quiet, which is - the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an - unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in - time because capping it would refuse a send the application asked for, and nothing in it keeps the - peer waiting — which is what separates it from the inbound stores capped on constants. Rejected: - having `release()` skip a waiter whose signal already fired, which leaves the departed waiter in - the queue where `unfinished()` still counts it and the drain waits on it; the waiter leaves as it - settles instead. Rejected: bounding this wait by `responseTimeout` as the hold is bounded — a full - window is this end's own concurrency draining as the peer answers rather than a link going nowhere, - and that bound would fail a message with more segments than `maxOutstanding` partway through - against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer - an abort at the gate already gives. The drain half needs nothing: `close({ signal })` already hands the signal to - `window.idle()`, and `unbind()` taking none is the shape README states. - -- **The gate decides whether a link can carry a request, and a bind is what makes it one.** - Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one - round trip before the bind is answered, so gating on `closed` let a send arriving in that window go - out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)` - on every teardown, `open()` only once `comeBackUp()` has a bound link — and - `OutgoingRequests.linkDown()` reads it rather than `closed`. The bind itself cannot wait for what it - creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door - `unbind()` takes through `now()`. The gate is told what happened and never reads back into the - session: a collaborator that has to ask does not own its decision, which is how the first cut ended - up answering the same question two different ways at admit and at release. For the same reason the - retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a - condition that loops on something the gate does not gate on spins against a gate that admits it - straight back. `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true - only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and - `end()` are the only callers of `stop()`. A third caller has to shut the gate itself. - - -### Internals and tests - -- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both - emitters construct with `captureRejections: true` and implement - `[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError` - or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from - `emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4 - forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both - handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land - on a bare `process.nextTick` with nothing to catch it. - -- **The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and - `SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach - `{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry - and what settling means — a FIFO handing over a slot, a set released together, a map keyed by - sequence number, a count recomputed at settle — so a shared `Waiters` fits two of the four and - is a shallower module than the copies. Extract it once a fifth appears. - -- **`SmppLog` is a five-method contract this library declares, not a dependency.** `debug`, `error`, - `info`, `verbose` and `warn` are what the code actually calls, so an application can satisfy it - with an object literal. `@larvit/log` implements it structurally and stays a devDependency, where - `test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled. - -- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of - adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image - `node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and - fail on every developer machine, and a committed key leaks in a public repository. Valid while the - dev image has no openssl. - -- **`src/` stays flat until a module has to move for another reason.** Architecture review, - 2026-09-06: the grouping the file map above already implies — `wire/` for `pdu*` and `defs`, - `link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`, - `dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to - `dist/index.js`, the one published entry. Valid while that map is what a reader navigates by. - -- **`test/` stays flat too, and a file there is named for the question it answers rather than for the - module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest - is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where - it came from, cutting across four modules, where filing it by module would enter each new operator - twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of - their own. The four files that are not tests are the exception the rule needs stated: - `dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are - named for what they hold. - -- **CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.** Maintainer's - call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there: - shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode. - That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts - run on Linux by goal 9. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea - host-mode runners on a Windows VM and a Mac. +- A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching. +- The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and + `SendWindow` rather than extracted. +- `SmppLog` is a five-method contract this library declares, not a dependency. +- The TLS tests build their own self-signed certificate in DER +- `src/` stays flat until a module has to move for another reason. +- `test/` stays flat too, and a file there is named for the question it answers rather than for the + module it covers. +- CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..df1797b --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,772 @@ +# Decisions + +The standing decisions for `@larvit/smpp`, grouped by what each one constrains. A decision is +written down only when it cannot be put better as a goal — [AGENTS.md](../AGENTS.md) carries that +rule and an index of the titles below. + +## The public surface + +- **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` + public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to + (`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`, + `DlrMerger`, `PduTransport`, `submitSms`) stay unpublished so they can be reshaped. + +- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own + senders consult them; `session.send({ tlvs })` is passed through as written, because silently + stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than + sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the + three the library dispatches by it, of which it sends the first two. + +- **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer + compiles, which never rewired the handlers and so never worked. + +- **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call, + 2026-08-27: `EventEmitter` types every listener as void-returning, so the + `session.on('sms', async sms => …)` README documents reads as a misused promise in any strict + consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`, + which emits nothing and needs no cast; overriding them as real methods cannot work, because the + `super.on()` call needs one. The cost is that a subclass can no longer reach those seven through + `super` — re-declaring them the same way is its way out. `unknown` rather than + `void | Promise` because a listener may return anything: `session.on('close', () => + set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's + own promise would be the better completion signal, and reaching it needs `listeners()`, which + cannot be re-declared the same way — Node types it invariantly enough that widening `void` to + `unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal. + +- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's + call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the + session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows — + without the class as a value an application is left string-matching `err.message`. Goal 7 is paid by + exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because + an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not + because `reason` is compared against string literals, and an accessor + (`PduRefusedError['header']`) names either one where a signature wants it. The payload union + enforces nothing — a subclass narrows out of `Error` either way — and is there so the event's own + type names what to narrow to, which is also what makes it a half-truth if a second `Error` subclass + ever reaches this event without joining it. Rejected: a `SessionError` alias for that union, a + third name for a type that is structurally `Error`. Rejected: a separate `pduRefused` event, which + splits the failure channel so an application that wants every failure listens twice and an existing + listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens + the standing decision that `sessionError` carries every failure, never coalesced or suppressed — + the filtering belongs where the application is, since only it knows which peer is routinely sloppy. + Rejected: an error code on a plain `Error`, which reads back off an `unknown` property only through + a cast and types nothing it carries. Accepted: a second copy of the package installed alongside + this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`. + +- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because + `EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call, + 2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95): + all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1 + forbids. That PR left no such name to pass — `encodings` is a `Record`, so + every member of the union has a codec and one added without a codec, or without a segment budget, + fails to compile in four places. What was missing is the door for a caller holding a name at + runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it + narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`, + which serve their own tables that way. Rejected: a `Result` signature on all three, which costs + every typed consumer a narrow forever — goal 7, and the tag is the last cheap chance to spend it — + to guard a state the compiler refuses. Where the domain really is open the check is already there: + `sendSms()` takes its options as `unknown` and refuses `encoding` by name, which is what a caller + without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded + under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`. + +- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.** + Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote + empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a + correlation table by the array, compiled and then misbehaved, and one message's empty entry + collides with another's. Telesign names an id for the first segment of a concatenated submit only, + so it is a documented operator's shape rather than a hypothesis. Nothing else moves: + `parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined` + reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]` + because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves + a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()` + reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and + that reading stays separate from this one rather than sharing a helper, since it must leave a + Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked. + What settles it here is the resolved text rather than the parameter, because `writeParams()` + substitutes the field's own default: a peer that omits `message_id` and one that writes it empty + build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''` + and documenting it, which leaves the published type promising what the value does not keep — goal + 2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks + the positional correspondence with `pduObjs` that README promises and loses which segment a PDU + belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional promise + structural where today the compiler cannot check it; deferred to the next breaking release, the + first place two documented fields may become one. Accepted: every consumer reading `smsIds` + narrows, including the majority whose SMSC names every id; indexing narrows too, except for the + consumer who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined` + already. + +## The wire + +- **The declared interface version is an option on both `client()` and `server()`, and is not the + optional-parameter threshold.** That threshold is fixed at 0x34 by the spec, so an implementation + that must declare 5.0 throughout can, without moving it. + +- **A peer that declared no version is pre-3.4, and `undefined` means no bind yet.** `acceptBind()` + records what the ESME declared and the client's `bind()` records the `sc_interface_version` the + SMSC answered with; a peer that declared nothing is recorded as `undeclaredInterfaceVersion` (0x00) + and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`. + +- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.** + The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and + `INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format + `dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound + SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not + scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement + is the handset's word about a message, not the network's. A message type of 0 or one of the ten + reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same + footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one + across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is + authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for + MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to + the body. + +- **A body is read from `message_payload` where `short_message` carries none, and `short_message` + wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability + phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot + carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone + handed the application an empty message + ([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is + the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()` + cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever + it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the + spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the + rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the + TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both, + which discards a message that is almost certainly present twice over, where goal 3 keeps the + traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like + any other segment. + +- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none, + and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from + the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make + `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says, + Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed + the application one `sms` per fragment + ([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). + `concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to + where a body is, and both are exported for the same reason: an application on the low-level + surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the + reference, so the key is two tokens the reassembler joins and interprets neither of, and so the + refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL` + for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would + assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num` + is 16 and neither counts the other's messages; the two UDH widths share a space because they are + one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names + the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps + the change additive for every message that reassembled before, and leaves the library nothing to + guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a + gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the + references are not comparable at all, and where the parts are, the UDH is still what the message + is assembled by, so the report would name a failure the application cannot act on. Accepted: a + peer that switches spelling mid-message now has two groups that expire rather than fragments that + arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()` + goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second + spelling of one message whose only difference is which peers accept it. + +- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller + writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer + the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and + Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every + segment through `send()` — giving up the split, the per-segment ids, the send window and the + receipt merge, which is what goal 7 means by beating "the application can do this itself". The four + modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their + names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH + indicator a segment carrying a header needs cannot be cleared by anything the option can express. + It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out + of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by + that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 7 + will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the + report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a + report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they + had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number, + which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected: + taking a number beside a name, two spellings of one goal — which is why a value naming no mode is + refused, by name, before a segment goes out. Rejected: a session-level default with a per-send + override; an operator's requirement is a property of the link, but the library can verify nothing + the caller's own options object does not, and shipping both buys a precedence rule to document and + test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable. + +- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes + it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: + SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and + Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID` + dropped with nothing reported to the application at all. Every command but this one names its own + direction, which is why the bind gate and the dispatch never had to be told which end of the link + they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a + delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a + submission and is read as one, because a report about a message this end never sent is goal 2's + wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by + segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves + `deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs` + rather than a `SessionOptions` entry, so the code that knows which end this is writes it and + nothing else can contradict what the session then binds as. Rejected: grouping the command with + `deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with + `submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one — + `send()` reaches the command raw, and an option choosing which command a message goes out on would + be a second spelling of `sendSms()` whose only difference is which peers accept it. + +- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's + call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding` + onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same + echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss + against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads + `PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a + character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings + because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding` + where the octets yield no field, which reads one body two ways for the sake of a peer writing a + UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here, + which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing + but `data_coding` can say how a message was written. + +- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message + with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target + of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the + ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only + one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM + 03.38, and the one SMPPSim demonstrated + ([interop-tests/findings/02-smppsim.md](interop-tests/findings/02-smppsim.md), C17). + `messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as + `concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0, + carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and + `encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group + masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class + other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the + `sms` event, which pays goal 7 for three classes nothing here acts on, where the boolean the + application already had covers the one it does. Compressed text is out of scope and stays out — + nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever + its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as + class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38 + coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an + extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the + alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the + class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins + (0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is + spelling 03.38 at all. Send-side: `flash` + is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while + `encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this + library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is + SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets, + which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to + the handset that would display it; rejected: promoting it to UCS2, which overrides the one option + the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which + named `data_coding` 0x10 among the alphabets and so reached the class through the option that + chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character + to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()` + and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a + caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING` + keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group + as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no + peer means as immediate display. + +- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and + `SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final, + `UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken + at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this + library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not + withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a + second time in `DlrMerger`, so the library cannot answer the application one way and conclude the + other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE` + is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's + `SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying + nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an + absent one cannot land there. + +- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two + researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the + operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal + delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints + `DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN` — + the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an + operator's "it failed" from its "I do not know", nor a delivered message from one whose state + could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send + carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on + writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which + is 3.4's own numbered table — the code has no number there, so one would have to be invented, and + every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it + `UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal + failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the + numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of + their own rather than the seven characters `stat:` holds. Accepted: all three of those operators + document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an + application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote. + Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a + report where it used to arrive as an inbound message, which is what every code already in the table + does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the + page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a + spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely + contain a typo saves an application everything where they do not. + +- **A transient state goes out as an intermediate delivery notification (0x20), every other state as + a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so + 0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to + reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list + from `transientStates` in `dlr.ts`, the same one the reader uses, so the two cannot drift. + Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost + accepted here is that such a peer stops recognising a transient report as a report at all and hands + its application receipt text as an inbound message, where under 0x04 it would have read the state + from `stat:` and been right. A transient state also carries `err:000`, since a message still on its + way has not failed. + +- **A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it + arrived.** Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always + parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part + that would not parse. Rejected: nacking a refused *response*, whose sequence number is one of + ours — the `generic_nack` would land in the peer's own numbering and nack a request of the peer's + we never saw, so a refused response is written back nothing and settles the request it names + instead. An unknown command id with the response bit set takes that branch too: a peer echoing a + sequence number of ours is answering something, and settling it reaches the undetermined outcome + `responseTimeout` would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x00000001–0x7FFFFFFF into range + before answering, which correlates with nothing at the peer — stacks write the field as a plain + uint32 (ukarim/smscsim signs every unprompted `deliver_sm` with a raw `rand.Int()`), so goal 3 + keeps that traffic and `PendingRequests.nextSeqNr()`, the only thing that invents one, is what + holds our own sends inside the spec. + +- **The optional parameters run to `command_length` exactly, and the only slack tolerated is one + NULL octet where a peer padded `short_message`.** Maintainer's call, 2026-09-06, from the + Java-client interoperability phase: accepting any parse that merely did not error answered + `ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the + `receipted_message_id` that makes a receipt a receipt + ([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)). Goal 2 + settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region + that does not end on `command_length` — the padded read included — is refused with the `tlvs` + reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid + once, in `readCstring()`: a trailing C-Octet String a peer left out entirely consumes no octet, + where reporting the terminator it never sent puts every later offset past the declared end and + refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them + at the tail all read empty — `outbind` is the only command with two, and an absent field and an + empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance + for the one to three trailing octets too few to hold a TLV header, which no researched peer sends + and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as + `body`/`ESME_RINVCMDLEN`, which names the mandatory fields — the part the peer got right. + +- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `-` + id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one + transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id` + TLV too, which SMPP 3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one + notation for whichever id a receipt yields would break the peer that sends both. Omitting a place + is what leaving it alone means, so there is no `raw` notation, and a caller-supplied formatter is + refused because it would make the promise that the two ids are comparable unverifiable — `onRequest` + and the PDU on the `dlr` event are the escape hatches. A `-` id parses as no number and so + reaches `expect()` and `collect()` unchanged, which is what keeps `DlrMerger` working; normalising + the base instead would break that pair. The option is on `client()` only, since a `server()` session + writes both ids itself. + +- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them + and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture + review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to + everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus + a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns + it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so + Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153 + that is the odd number rather than the other way round. `Record` is what makes + a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the + unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters + one SMS holds now costs more segments than it did, and `smsIds` is that much longer. + +- **An alphabet the caller named has to carry the message, and a time the format cannot express is + refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and + stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on + `あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'` + flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote + `NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the + caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can + carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks + the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it, + so the guard cannot drift from what the encoder writes for any one character, and a fourth + alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller + composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read + this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()` + reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit + binary body Latin-1 is kept for. The guard is + on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns + carries every character it was picked for, over the whole code point range. `smppTime.encode()` + returns a `Result`, where the three encoding helpers stayed total: that argument was that + `EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an + invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit + one", and `decode()` has been fallible for the same reason since it was written. Rejected: + transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice + — the same reason a flash Latin-1 message is refused rather than promoted, and an operator that + accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected: + guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this + library's own published helper composing the garbage the guard exists to stop. Rejected: a + `holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with + the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller + formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the + field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to + it, a negative one and `Infinity` with it — clamping `86400 * 365` reported success for a year and + put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists + to remove. The ceiling is this encoder's rather than the format's: 3.4's `YYMMDDhhmmss000R` + carries years and months, which `decode()` reads back, and no fixed number of seconds is either + one, so spelling a second count in days and below is where the guess would go — which is why the + too-long refusal names the `Date` that reaches every instant the absolute form holds, and the + negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which + leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an + extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is + the one input a per-character reading passes and the encoder then writes as the extended character + — the only composition in any of the three codecs, and not a character a message is written in. + +- **A string body is written in the alphabet its own `data_coding` names, and one that alphabet + cannot carry is refused by the codec — `message_payload` on the same terms as `short_message`.** + Maintainer's call, 2026-09-09, from the architecture review of + [#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's + own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう` + returned `42 44 46` — `"BDF"` — reported as built, while a string `message_payload` was cut to its + low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above. + The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given + under any `data_coding`, which is what keeps goal 7's escape hatch open — the raw UDH, 8-bit binary + and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string + with no `data_coding` is untouched, detection carrying every character it was picked for. The + guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the + character, its code point and its index said once for both refusals — unexported where + `unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a + caller would get wrong, where asking the codec is, and publishing it would freeze this library's + error prose as API for an application whose own refusal should read like itself. Goal 7, from the + architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is + reached through + `encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives: + `encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the + alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it, + since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because + `splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment + could name is not the one in the message. The TLV is encoded rather than merely checked because + `data_coding` names the alphabet of the body wherever it is carried — that is how + `messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one + — so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would + close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the + order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will + be read under — and a `short_message` on a command whose table declares none is ignored here as + `writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the + same input rather than three. A `data_coding` on a command that declares no such field is honoured + the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in. + Every entry carrying the payload tag is resolved, by tag id rather + than by record key, since `tagIdOf()` lets a caller name it anything and a spelling that escaped + the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string + `message_payload` outright and demanding + octets, which contradicts `short_message` on the same PDU. Rejected: guarding every string-valued + field, which `data_coding` says nothing about — an address is a C-Octet String and ASCII by 3.4's + own definition. + +- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.** + Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet + through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT + T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's + STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the + same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written + under, as `unencodable()` is to whether one can carry a message: the mirror of + `encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected + one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is + exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a + `data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the + *SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee; + it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile + and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and + so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01 + to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no + researched peer means IA5 by it. The two tables agree over most of the printable range and part at + 0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed, + carriage return and escape are common to both — which is where a peer that did mean IA5 is + misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two + names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different + values and so the only one a reader could carry from the option's vocabulary into SMPP's flat + table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name. + Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the + section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding` + option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as + Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing + researched has found. Accepted: a message already in flight is unmoved — both codings resolve to + the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the + concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the + change reads exactly as it did. + +## The session's life + +- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call, + 2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would + otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated + reason, which is accepted — the peer sees the same TCP close either way. + +- **`close` means the session is over, and a drop the loop will retry is `disconnected`.** + Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on + `close` ends up holding two binds on one account. `teardown()` picks the event by whether the + reconnect loop is still live, and `end()` stops that loop before tearing down, so every deliberate + shutdown emits `close`. A retry that opens a socket and then loses it clears `closed` through + `attach()`, which is why a second drop emits again. + +- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call, + 2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response + that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused + response names an id the peer has no record of. + +- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means + on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a + connection the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d, + so a process with nothing else left to do still exits between attempts. + +- **Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.** + An unreadable stream is found after the bind returns, so resetting on connect gave a link that died + on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop + after a healthy link still retries at `minDelay`. + +- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and + `client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application + started before its SMSC is up otherwise writes that retry itself, around the one this library + already owns. A field on `reconnect` rather than an option of its own, so the combination that + would contradict `false` cannot be written at all — `false` carries no fields — and a top-level + `fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the + wait: a bound of its own would be a second spelling of a deadline the caller already writes with + that signal, and giving up after one is what the default does. A bind the SMSC refuses is + retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`, + which would have the initial attempts and a rebind disagree about what a refused bind means, and + gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the + rate goal 4 cares about. The attempts before the first link report nothing, because the session + running one has not reached the application: `disconnected` would have no listener and `close` + would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason + `LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is + `client()` would exit unbound. + +- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.** + Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below + 16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the + link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh + framer. Every other codec failure honoured `command_length`, so the stream is still in sync and + the next PDU starts where it says — tearing the link down there cost one peer half its receipts + and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting + for answers it was owed. `sessionError` carries every failure of either kind, never coalesced or + suppressed, so a peer that only ever sends garbage is visible in the log rather than silent. + +- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()` + wait on the send window rather than the pending map — the map misses a segment still queued behind + a full window, and finishing a half-sent multipart message is the point. The window counts slots, + never outcomes, and empties on a drop too, where `teardown()` settles everything the link was + carrying, which is why `drain()` reads `closed` before it reads the count. A stream the framer or + the codec cannot read takes `teardown()` instead, and `close({ signal })` on an aborted signal and + a peer's own `unbind` take `end()`: nothing on a dead link can answer, an abort means stop now, and + a peer that has declared itself finished will not answer what it still owes, so draining any of the + three would only hold a socket open for the timeout. `unbind()` sends its own PDU through + `request()` past both the window and the drain gate, because it must go out either way. + `shutdownTimeout` stays a session option rather than a `close()` argument: `server()` builds + sessions on the caller's behalf, so the option is the only composition point. `SmppServer.close()` + reports each session's unfinished drain through `serverError`, because its own result says nothing + but that the listener stopped. + +- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the + application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the + Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will + not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole + deadlocked every multi-segment message against a production gateway + ([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). Goal 1 has the answer + a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is + generated when it opens and each segment is answered `-`, the notation `sms-id.ts` owns + and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId` + or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent + no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the + discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is + untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for + an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` + answers every segment it will not carry rather than leaving it unanswered, which is the same stall + in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL` + where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: + answering every segment but the one that completes the group, which leaves the peer holding some + segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour + a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment, + which asks the application to name a message it cannot read yet — what it wants is `sms.smsId` + afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only + distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or + dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` + as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the + `PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the + application could act on, and goal 7 does not buy a second exported class to make a count + distinguishable. Accepted: a completing segment whose own answer the socket would not carry still + reaches the application, because the message is whole and correct and the failed answer is on + `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message + in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can + never precede the acceptance of the message it reports on. + +- **`server()` composes the application's `onRequest` after its own bind handling, and offers it + every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review + of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch + the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up + bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is + what goal 7 means by beating "the application can do this itself". What the library verifies is the + ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind + request on a session already bound, so no bind — a second one on a live session included — and + nothing a peer sends before one can be intercepted however the hook is written. One + `OnRequest` type on both option bags, because a second contract under one name is two spellings of + one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need + not be `async`. Nothing of ours is written for a request whose hook failed, the same on both + surfaces: the library cannot tell one that failed before answering from one that failed after, so + goal 2 reports the outcome as undetermined rather than guessing, and the peer's own + `responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that + throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all, + and is visible only as the request that was never answered. That takes the keepalive with it, since + a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the + back-pressure wanted, because an application that cannot serve a link should not hold one. + `sessionError` rather than `serverError` because the + failure belongs to one session's request, and that channel already carries every failure of one. + The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound + peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`. + Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the + hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where + `AuthenticateInput` is exported, because that hook's argument is a shape this library invents and + this one's are two types already published. Rejected: consulting the hook first, which puts + bind and authentication inside the application's reach for nothing. Rejected: a narrower hook + returning a status for the library to write, which makes the answer verifiable but pays a second + contract under a second name for it, and could not express what the session-level hook already + does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only + half the surface can do. Rejected: falling the request through to the built-in handling on a + failure, which reads as the answer the peer would have had with no hook — true only of a hook + that failed before answering, where one that failed after put a second response on the peer's own + sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the + fall-through could be gated on it, which buys a fail-open path with state and an internal contract + no other collaborator needs. + +- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done + with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session + down while the application was still answering a `submit_sm`, so the peer timed out and re-sent — + the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was + added to the `sms` event: `sendResp()` is what an application already calls when it is done with a + message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected — + an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every + close — and a message no listener took is released at once, since nothing is going to answer it. + A listener that failed before answering gives it up the same way, but only once every listener has: + a throw stops `emit()` where it stands, while a rejection leaves the others running, so the release + waits for the last of them rather than answering on their behalf. What ends the wait is the response + reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not + carry, leaves the message held, so `close()` still reports the one the peer is owed. Where the + segments were answered as they arrived there is no response left to write, so the call itself ends + the wait, an argument the library refuses excepted. `teardown()` + drops what is still held for the same reason it drops inbound segments. The release is one turn + late, so a listener that sends its receipt straight after the response is still holding when the + drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, and only while the + message is still held — past that it is an ordinary send, because the drain it would slip past is + no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half: + waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the + caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is + what you reach for when the application is stuck, so it may not block on the application coming + unstuck. That half falls back to `responseTimeout`, the same answer the link gate's hold already + takes — and to that option's default where it is 0 as well, since neither option is an answer about + the application. What is held is capped and expiring like every other inbound store, on constants + rather than options, because a bound the application cannot raise is the point: an application that + answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message + that falls out of the bound is one the drain stops waiting for, so `close()` can report fewer + unanswered than there were — accepted, because the alternative is holding what nothing will answer, + and both exits are logged. + +- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.** + `onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()` + runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the + merges there loses receipts no peer has a reason to send again. They are cleared where the session + is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the + peer's own counter, so a half-arrived group kept across a drop would take a later message's + segments as readily as the rest of its own, and goal 2 will not hand the application a message + assembled that way. What goes there is traffic already answered, which is why each group reaches + `sessionError` like every other one given up on. + +- **A message id base is merged at most once.** A receipt carries nothing but `-`, so a + straggler for a message whose group is gone cannot be told from a receipt for a later message the + peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic + case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the + groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an + earlier one whose receipts are still arriving is dropped rather than left to collect the later + one's. Every segment still reaches the application as a `dlr`. `expect()` ignores a lone id, so a + single-part message never claims a base. + +- **A send that never reached the socket waits for the next link; one that did is counted, not + resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a + `submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed twice, + while a request that never left this process can be lost for free. `attempt()` therefore wraps all + three ways a written request can fail in `UnansweredError`; counting only the dropped-link case, as + the first cut did, would have called the commonest one safe to resend. A count rather than a + boolean because `sendSms()` aggregates segments into one `err` slot, and required rather than + optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is + the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an + option of its own — that is already the answer to how long one request may wait — and its clock + starts when the send is issued rather than when it first finds the gate shut, so one budget covers + every hold a single call makes. That timer is the one here that is not `unref()`'d: a held request + is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining + work is that send exit without settling it. + +- **A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.** + Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the + `acquire()` on the next line did not, so a caller that aborted while the window was full waited for + a slot it no longer wanted — at `responseTimeout: 0` for as long as the peer stayed quiet, which is + the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an + unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in + time because capping it would refuse a send the application asked for, and nothing in it keeps the + peer waiting — which is what separates it from the inbound stores capped on constants. Rejected: + having `release()` skip a waiter whose signal already fired, which leaves the departed waiter in + the queue where `unfinished()` still counts it and the drain waits on it; the waiter leaves as it + settles instead. Rejected: bounding this wait by `responseTimeout` as the hold is bounded — a full + window is this end's own concurrency draining as the peer answers rather than a link going nowhere, + and that bound would fail a message with more segments than `maxOutstanding` partway through + against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer + an abort at the gate already gives. The drain half needs nothing: `close({ signal })` already hands the signal to + `window.idle()`, and `unbind()` taking none is the shape README states. + +- **The gate decides whether a link can carry a request, and a bind is what makes it one.** + Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one + round trip before the bind is answered, so gating on `closed` let a send arriving in that window go + out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)` + on every teardown, `open()` only once `comeBackUp()` has a bound link — and + `OutgoingRequests.linkDown()` reads it rather than `closed`. The bind itself cannot wait for what it + creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door + `unbind()` takes through `now()`. The gate is told what happened and never reads back into the + session: a collaborator that has to ask does not own its decision, which is how the first cut ended + up answering the same question two different ways at admit and at release. For the same reason the + retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a + condition that loops on something the gate does not gate on spins against a gate that admits it + straight back. `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true + only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and + `end()` are the only callers of `stop()`. A third caller has to shut the gate itself. + + +## Internals and tests + +- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both + emitters construct with `captureRejections: true` and implement + `[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError` + or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from + `emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4 + forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both + handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land + on a bare `process.nextTick` with nothing to catch it. + +- **The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and + `SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach + `{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry + and what settling means — a FIFO handing over a slot, a set released together, a map keyed by + sequence number, a count recomputed at settle — so a shared `Waiters` fits two of the four and + is a shallower module than the copies. Extract it once a fifth appears. + +- **`SmppLog` is a five-method contract this library declares, not a dependency.** `debug`, `error`, + `info`, `verbose` and `warn` are what the code actually calls, so an application can satisfy it + with an object literal. `@larvit/log` implements it structurally and stays a devDependency, where + `test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled. + +- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of + adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image + `node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and + fail on every developer machine, and a committed key leaks in a public repository. Valid while the + dev image has no openssl. + +- **`src/` stays flat until a module has to move for another reason.** Architecture review, + 2026-09-06: the grouping the file map above already implies — `wire/` for `pdu*` and `defs`, + `link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`, + `dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to + `dist/index.js`, the one published entry. Valid while that map is what a reader navigates by. + +- **`test/` stays flat too, and a file there is named for the question it answers rather than for the + module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest + is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where + it came from, cutting across four modules, where filing it by module would enter each new operator + twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of + their own. The four files that are not tests are the exception the rule needs stated: + `dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are + named for what they hold. + +- **CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.** Maintainer's + call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there: + shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode. + That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts + run on Linux by goal 9. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea + host-mode runners on a Windows VM and a Mac.