# todo.md Remaining work for `@larvit/smpp`. Read [README.md](README.md)'s goals and [AGENTS.md](AGENTS.md)'s hard rules first — they constrain every item below. This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not govern it, and nothing here is a source anything else may cite. ## Status The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18 to 26, and 0.5.0 is on npm. 0.6.0 is next, and it is a quality cut rather than a feature one — the comprehension gate and the defects under [0.6.0](#060) come first, then the gaps a comparison with other SMPP libraries found. ## The agreed API Settled with the maintainer before implementation. Do not change any of it without asking. The public surface is documented in [README.md](README.md); this is the short form. ```ts import { client, server } from '@larvit/smpp'; const { err, session } = await client({ host, password, port, username }); const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to }); await session.unbind(); const { err: serverErr, server: smpp } = await server({ authenticate, port }); smpp.on('session', session => { session.on('sms', async sms => { await sms.sendResp(); if (sms.dlr) await sms.sendDlr('DELIVERED'); }); }); await smpp.close(); ``` Rules the API follows: - **Never throws.** Everything fallible resolves to `{ err?, … }`. See AGENTS.md rule 1. - **Named exports only**, no default export. `defs` is exported as a group alongside the individual tables. - **The PDU codec is synchronous** and returns `{ err?, pduObj? }` / `{ err?, buffer? }`. - **Low-level surface stays public**, including `session.sock`, `session.send()` and `session.sendReturn()`. ## Done | | Covered by | | --- | --- | | Definition tables: constants, errors, encodings, wire types, TLVs, commands | `test/encodings.test.ts`, `test/types.test.ts`, `test/commands.test.ts` | | Message helpers: splitting, bit counting, SMPP dates and times | `test/message.test.ts` | | PDU codec: parse, build, respond, per-command typing, bounds checks | `test/pdu.test.ts` | | Stream framing | `test/pdu-framer.test.ts` | | Delivery receipt parsing, TLV and text | `test/dlr.test.ts` | | Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` | | Merged multipart DLRs including across a reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` | | `smsIdFormat`: a peer's `submit_sm_resp` and receipt ids read into one notation before they are compared | `test/dlr.test.ts`, `test/session-extras.test.ts` | | A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.test.ts` | | A drain that also waits out the messages the application has not answered, with `sendDlr()` the one send that passes it | `test/session-extras.test.ts` | | `OutgoingRequests`: the gate, the window, the pending map and the retry under one owner, told when a link comes up or goes down | `test/session-extras.test.ts`, `test/session.test.ts` | | Held messages capped and expiring, so an application that answers nothing cannot grow them | `test/session-extras.test.ts` | | A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` | | A message whose link dropped refused an answer, with its receipt still allowed out | `test/session-extras.test.ts` | | The hold released exactly when the peer was answered: a refused `sendResp()` keeps it, a listener that rejected drops it | `test/session-extras.test.ts` | | Every runnable README example | `test/readme.test.ts` | | Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` | | An intermediate delivery notification read as a report marked `intermediate`, as is a receipt reporting `ENROUTE` or `SCHEDULED`, and never counted into a merge | `test/dlr.test.ts`, `test/session.test.ts`, `test/session-extras.test.ts` | | A transient state sent under the marker the spec gives it, off the same list the reader uses | `test/session-extras.test.ts` | | A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` | | Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` | | CI on Node 18 to 26, Renovate, tag-triggered publish | `.gitea/workflows/` | Every defect listed in the AGENTS.md table has a regression test naming the behaviour. ## Move the repository to Gitea `gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` mirrors it and is the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14. - [x] **Move `main` and `v0.4.0` to `larvit/smpp-js`.** It holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base` and the `renovate/*` branches stayed behind. - [x] **Protect `main` with fast-forward merges and required checks.** Fast-forward is the only merge style. `main` takes no pushes, requires `Test / lint (pull_request)` and `Test / test (*) (pull_request)`, blocks an outdated branch, and gives admins no override. Every other branch takes force pushes. - [x] **Run the workflows from `.gitea/workflows/`.** Tests run on pull requests only, the event the gate reads; Renovate runs as a scheduled workflow, as on adf-codec. - [x] **Publish the release without provenance.** npm generates it only on GitHub Actions and GitLab CI/CD. - [x] **Name Gitea in `package.json`, and the GitHub mirror's issues as `bugs`.** The README links absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no workflow runs on `main`. - **Add a github.com token for Renovate only if its lookups hit the rate limit.** `RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and cannot stand in for it. ## Before publishing 0.5.0 - [x] **Publish the rewrite as 0.5.0, not 1.0.0, while usage is this low.** Maintainer's call, 2026-09-14. - [x] **Hold `NPM_TOKEN` as a Gitea organization secret.** `.gitea/workflows/release.yaml` needs it. - [x] **Tag `v0.5.0` on Gitea to publish.** The first publish creates `@larvit/smpp` on npm, provided the token can publish under `@larvit`. - [x] **Deprecate `larvitsmpp` on npm, pointing at `@larvit/smpp`.** `npm deprecate larvitsmpp` is the maintainer's call to run; not something CI should do. ## Retire the GitHub repository Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order: deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub refuses to delete its default branch. - [x] **Close the backlog below.** - [x] **Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.** - [x] **Rename `larvit/larvitsmpp` to `larvit/smpp-js`.** GitHub redirects the old URLs, and the `bugs` URL in `package.json` resolves from then on. - [x] **Push `main` and make it GitHub's default branch.** - [x] **Set Renovate to Silent for this repository in the Mend Developer Portal.** It then opens nothing on GitHub while the organization-wide installation stays. CodeRabbit stays installed. Maintainer's call, 2026-09-14. - [x] **Mirror to GitHub from `.gitea/workflows/mirror.yaml` and `mirror-delete.yaml`, with `MIRROR_GITHUB_TOKEN`.** A push of a commit carrying the workflow, and the nightly run, send all of Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14. - [x] **Strip GitHub's repository to a mirror, and give both forges the package's About.** GitHub's wiki, projects and Actions are off, the Travis app and webhook are gone, and its About matches the package. Gitea carries the same description, website and topics, and sends issues to GitHub as its external tracker. ## Close the GitHub backlog **Answer and close as fixed by 0.5.0**, the reply naming what fixed it: - [x] **Close [#2](https://github.com/larvit/larvitsmpp/issues/2), tests for the README examples.** Fixed by `test/readme.test.ts`. - [x] **Close [#3](https://github.com/larvit/larvitsmpp/issues/3), tests for flash messages.** Fixed by `test/session.test.ts`. - [x] **Close [#4](https://github.com/larvit/larvitsmpp/issues/4), DLR errors with `message_state` missing.** `dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent. - [x] **Close [#13](https://github.com/larvit/larvitsmpp/issues/13), limit a long SMS to fewer segments.** Fixed by the `maxSegments` send option. - [x] **Close [#16](https://github.com/larvit/larvitsmpp/issues/16), support all three bind types.** Bound and enforced in both directions. - [x] **Close [#17](https://github.com/larvit/larvitsmpp/issues/17), `addr_ton`/`addr_npi` should be settable.** `sendSms()` takes all four, documented and tested. - [x] **Close [#20](https://github.com/larvit/larvitsmpp/issues/20), tests fail on current dependency versions.** The mocha suite is gone; `node:test` on Node 18 to 26. - [x] **Close [#33](https://github.com/larvit/larvitsmpp/issues/33), large inbound text arrives as raw `Buffer` segments.** `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms` event. - [x] **Close [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request for `message_id` in `submit_sm_resp` and spec DLR codes.** All four hold: `sendResp()` always answers a `message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real defects. **Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`, `coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`: - [x] **Close the twelve dependency pull requests as superseded.** [#40](https://github.com/larvit/larvitsmpp/pull/40), [#41](https://github.com/larvit/larvitsmpp/pull/41), [#42](https://github.com/larvit/larvitsmpp/pull/42), [#45](https://github.com/larvit/larvitsmpp/pull/45), [#46](https://github.com/larvit/larvitsmpp/pull/46), [#47](https://github.com/larvit/larvitsmpp/pull/47), [#59](https://github.com/larvit/larvitsmpp/pull/59), [#63](https://github.com/larvit/larvitsmpp/pull/63), [#64](https://github.com/larvit/larvitsmpp/pull/64), [#67](https://github.com/larvit/larvitsmpp/pull/67), [#70](https://github.com/larvit/larvitsmpp/pull/70), [#77](https://github.com/larvit/larvitsmpp/pull/77). [#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports on the default branch; it disappears with the runtime dependencies rather than being fixed. [#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard and stays open after the rewrite, for a dependency added later. Maintainer's call, 2026-09-14. **Close as tracked here**, the reply saying it will be implemented on Gitea: - [x] **Close [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and port on log messages, as tracked here.** It sits under Worth doing, not blocking. Maintainer's call, 2026-09-14. ## 0.6.0 A nine-reader comprehension panel read the whole project on 2026-09-20 and scored it 7 overall, mean 6.8. Navigation (7–8) capped nobody. **Locality capped every unit reader at 5–6 and Shape capped both architects at 6**, and those two are what this release lifts. The gate is 7 on all four dimensions, higher where it is cheap. Maintainer's call, 2026-09-20. A systems-architect review the same day returned ALIGN with one blocking-severity finding, `resolveBody`'s `settles` boolean, which the panel also ranked hardest; it is now a named `CodingSource`. A four-seat scoring run on 2026-09-27 read #30 at 6, 6, 7 and 6, every seat capped by Locality in the held-message and shutdown code; #30 merged under the floor on condition that Locality is the next work ([decision](docs/decisions.md#internals-and-tests)). ### Locality — the plan-3 rewrite, next and ahead of everything below; 6.25 today, and the gate is 7 The scoring run after #49 read 6, 6, 7 and 6, Locality 5, 5, 6 and 6. Three rounds of redesign drafts (A–F) each took the hardest unit off the panel's list and exposed the next, and none moved the mean past 6.25. A four-seat board then read three from-scratch plans. It ranked plan 3 first unanimously and predicted 6, 7, 7 and 7, and every seat judged the ceiling to be the code's structure, not SMPP's difficulty. Maintainer's call, 2026-09-30: build plan 3. The background is in [docs/comprehension-rewrite/](docs/comprehension-rewrite/): the plans, the board, every panel report, the lessons and the six drafts as patches. Delete that directory once this milestone ships. Every chunk below ships through `/larv-review`, and before it merges it also runs the comprehension-panel scoring run over the whole project. Each round: - fix what the seats name inside the chunk's own area; - ask the seats what would lift the bar further, and file what lies outside the chunk here; - let no dimension drop, and record the four scores and the overall in this paragraph. Each chunk also closes the items further down that it absorbs, and its PR names them: `hold` and `refusing`, `ASCII`, and the link that dropped mid-rebind. Every chunk that changes the public API updates README's examples, MIGRATION.md, CHANGELOG.md, docs/decisions.md and the AGENTS.md map in its own PR, and deletes what it replaces; no old and new file stand side by side. The architecture review of 2026-09-30 (ALIGN) set this order; its amendments to the plan are [plan 3 §8](docs/comprehension-rewrite/plan-3.md#8-architecture-review-2026-09-30). - [ ] **Build `protocol/`: every multi-fact octet read once into a named plain type.** - `vocabulary.ts`: the glossary as types with one-line TSDoc. - `data-coding.ts`: the `data_coding` table as rows, with a test that it equals today's function on all 256 octets. - `esm_class`, segments, receipts, message ids, time and bind; no state, so the segment reference counter goes to `SmppClient` in the lifecycle split. - The `gsm7` rename, A6 included: `'ASCII'` becomes `'GSM7'` in every export. - A test that fails on a spec citation without its sentence. - [ ] **Build `messages/` on a `BoundedStore` that enforces its own bounds.** Reassembly refuses at its bound instead of evicting, recorded against goals 2 and 4; the receipt merge follows Q8 and its spent set expires by age. README's bound text is updated. - [ ] **List every test by name, mapped to its survivor or a reason for deleting it, before the lifecycle split.** Every defect-table regression test survives by name. - [ ] **Split the lifecycle (A1, A5): a one-socket `Session`, reconnect above it in `client/`.** - `SmppClient`, `connect.ts`, `backoff.ts`, and `client/next-link.ts` holding the wait for a bound session and the retry of only what never reached the socket, invariant at the top, so `client.ts` only composes. - `sendResp()` and `HeldMessages` are ported as they are. - Absorbs registering a multipart send's receipt merge before its segments go out. - The interop suite and goal 6's benchmarks run before it merges. - [ ] **Answer on return (A2, A3, A4).** `handlers.ts`, `requests-in.ts` returning a `Reply`, `onRequest` returning one, `server/` ported, `HeldMessages` deleted. The interop suite and the benchmarks run before it merges. - [ ] **Confirm Locality at 7 with a final scoring run.** A run reading 7.0 or above retires the Locality-first decision. ### Correctness - [ ] **Refuse to open a link that dropped while its rebind was answered.** A peer sending `bind_resp` and FIN together can tear the link down before `comeBackUp()` resumes; it then calls `link.open()` on a `down` link, `resetTimers()` skips, and `attempt()` reports success, so the session is `up` on a destroyed socket with nothing to reconnect it until `close()`. `open()` accepting only `binding`, and `comeBackUp()` returning an err when the link is no longer attached, lets the loop retry. Unreproduced; from the stability review of #48. - [ ] **Register a multipart send's receipt merge before its segments go out.** `Session.sendSms()` calls `dlrMerger.expect()` only once `submitSms()` resolves, after the last segment's response, so a receipt for an early segment that arrives first is logged at `debug` as naming no merge, and the group then waits out `dlrMergeTimeout` with no `messageDlr`. Likeliest with a fast SMSC or more segments than `maxOutstanding`. Goal 2. From the 2026-09-28 scoring run on #48. - [ ] **Settle what a repeated tag not marked `multiple` reads as, and pin it in a test.** A vendor tag or a known single-value tag a peer sends twice keeps the last occurrence and drops the rest silently, which goal 3 argues against; listing it would change every such tag's shape. From the architecture review of #25. - [ ] **Test that a multipart send which errors never fires `messageDlr`.** Goal 2 now says so and README promises it; `session-extras.test.ts` covers a drop *after* the send, not one during it. - [ ] **Return an `err` where `message` is not a string, rather than throwing.** `sendSms({ message: undefined })` — a forgotten property — reaches `value.replace()` in `codec/encodings.ts` through the alphabet detection `checkOptions()` runs, and the `TypeError` escapes `submitSms()` into the caller's process; `NaN` and `12345` do the same. README promises "Never throws. Every fallible call resolves to `{ err?, … }`" and AGENTS.md hard rule 1 says it again, so the docs are false for the likeliest caller mistake there is. From the stability review of #18. - [ ] **Derive `sm_length` for a numeric body, or refuse one.** `resolveBody()` in `pdu.ts` reads the length only where the body is a Buffer or a string, so `objToPdu({ cmdName: 'submit_sm', params: { short_message: 12345 } })` writes `sm_length: 0`, then five octets after it, and reports success — and this library's own parser refuses what it built, as "TLV 12594 runs past the end of the PDU". Goals 1 and 2. From the stability review of #18. - [ ] **Settle which numbers may spell a text field, refuse the rest, and say so where a consumer reads it.** `wantText()` takes every finite number through `String()`, so `message_id: 1e21` writes `1e+21`, `from: 0.1 + 0.2` writes `0.30000000000000004` and `source_addr: -5` writes `-5` — none of them is the id or the address the caller meant, and all three are reported as sent. The numeric branch exists for a digit sequence (`message_id: 123`); the product-owner review of #18 recommends `Number.isSafeInteger(value) && value >= 0` with the refusal naming the fix, since a 64-bit SMSC id loses digits to a JS number before this library ever sees it. Goals 2 then 3: `from: 1e21` is reported as sent to an address that reaches nobody, which is the wrong answer about what happened before it is laxness in what we send. That a number is accepted at all reaches a consumer in no sentence either: only the type comment at `codec/commands.ts`, and one CHANGELOG line that stops being visible when 0.7.0 is cut, while README's Building bullet reads as the whole rule for a text field. Whether this is a supported spelling or 0.4.0 tolerance decides whether that sentence lands in README.md or in MIGRATION.md — write it in the same change as the rule, so it is worded once. From the stability and product-owner reviews of #18. - [ ] **Build every error from a thrown value through `errorFrom()`.** `reconnect-loop.ts` (in `run()`'s catch and in `bringUp()`) and `client.ts`'s connect still call `String(thrown)`, which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run. - [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before the lost-link check and before the write, so a failed answer still renames the message and a later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run. - [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds `responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only, and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run. - [ ] **Keep a bare ESC out of GSM detection.** `gsmRegex` in `codec/encodings.ts` admits `\x1B`, so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the 2026-09-28 scoring run. - [ ] **Count an `sms` listener that throws as one giving up, as a rejection already is.** A synchronous throw makes `Session.emit` return false, and `HeldMessages.offer()` then releases the hold at once, so a drain stops waiting on an async listener still answering beside it. Goal 2. From the stability review of #45. ### Throughput — goal 6, and the default window is where we are slowest - [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink, 100,000 messages each: this library 25,358/s, jsmpp 30,771/s, Cloudhopper 27,945/s — we are last at the one window most callers will ever run, while leading Cloudhopper and trailing jsmpp by only 5% at 50 and 200. So the cost is not the codec, which the higher windows exercise just as hard; it is something per-request that the window hides once enough requests overlap. `benchmarks/` reproduces all three. Goal 6. ### Shape — 6 today, and the gate is 7 - [ ] **Answer "is this a bind command" in one place.** `bindCommands` (read by `session/requests-in.ts`, `session/outgoing-requests.ts` and `test/session.test.ts`) and `bindTypeFromCommand()` (read by `server.ts` and `checkedBind()`) each list the three bind commands, so a fourth added to one is missed by the other. Derive the list from the function, or the reverse. From the stability review of #42. - [ ] **Split `test/session-extras.test.ts` by the question each block answers.** 3,010 lines, 19 unrelated `describe` blocks whose names are already the file names they should be. With `session.test.ts` it is 54% of all test code and 84% the size of `src/`. "extras" names neither a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly that. `max-lines` covers `src/**` only, so nothing has stopped it growing. - [ ] **Give `hold` one meaning, and rename `IncomingRequests.refusing` for what it does.** `LinkLife.hold()` is a request's budget waiting for a link, `HeldMessages.hold()` a message the application owes an answer; `refusing` decides no refusal — `held.full()` does — and only makes the warn and info lines fire once each way. From the 2026-09-29 scoring run. - [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.** It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII` for exactly this reason and left the option's own vocabulary carrying it. Pre-1.0 the minor is the breaking unit, so this is as cheap as it will ever be, and `todo.md` already requires the `consts.ENCODING` names settled before the custom-encoding registry — this is the other half. - [ ] **Name the base-versus-segment distinction in the message id types.** `Sms.smsId` is a base, `sendSms().smsIds[]` are segment ids, `Dlr.smsId` is a segment id and `MessageDlr.smsId` is a base again — four fields, one type, `string`. The whole multipart receipt mechanism turns on telling them apart and only `parseSegmentId()` knows. ### Self-sufficiency — 6–7 today, and the gate is 7 - [ ] **Move the fixture-copy reasoning out of AGENTS.md's Conventions, and split the longest decision entries.** The fixtures bullet holds four justifications for tolerated copies — decisions, so they belong in `docs/decisions.md` under Internals and tests; the entries under the wire's alphabet and body rules run 30–40 lines with their `Rejected:` clauses inline, where a reader who knows the answer still hunts for it. From the 2026-09-29 prose pass. - [ ] **Move the one-line facts out of the decision log and back to the code.** Five of nine readers independently reported being sent to `docs/decisions.md` for a question they hit while reading, with no link from the code; one counted roughly fifty index redirects. The three left to inline as one line each: that `segmentUnits`' three numbers are in two units (septets and octets), that a receipt's body is read as octets whatever its `data_coding` says, and the `-` id notation. The reasoning stays in the log; the definition belongs at the code. - [ ] **Document the two delivery-receipt merge bounds.** `maxDlrMerges` (1000) and `dlrMergeTimeout` (24 h) are hardcoded, are not options, and appear in no README and no test — while README states the equivalent held-message bounds explicitly ("Neither bound is an option"). A sender with more than 1000 concurrent multipart `dlr: true` messages silently evicts the oldest at `warn`. The inherited architect hit this on the 3am walk. - [ ] **State in README that `SmppServer.close()` reports each session's unfinished drain as `serverError`.** Only `docs/decisions.md` says so; README's Shutdown section covers the session's own result alone. - [ ] **Add a ten-line SMPP glossary to the README.** Both juniors and the no-domain mid reported the same largest cost: nothing in the repo says what a PDU, `esm_class`, `data_coding`, TON/NPI or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without stating it. One of them put it at a third of their reading time. Four commands, three octets, one sentence each. ### Doc claims this review falsified - [ ] **Log-cap every interop peer and probe Kannel by protocol, as `interop-tests/AGENTS.md` says every peer is.** `compose.kannel.yaml` and `compose.smscsim.yaml` carry no `logging:` block, and Kannel's healthcheck is the bare TCP probe that file warns against. From the prose sweep of #46. - [ ] **Cut what the prose sweep of #46 found restated or misplaced.** `docs/decisions.md` entries of 30–45 lines carrying pre-fix history (133–160, 217–255, 362–402, 404–440, 442–472, 593–624, 626–662); AGENTS.md's 14-line shared-fixtures bullet, whose tolerated-copies reasoning is a decision; the defect table rows MIGRATION.md already carries; README's `error`-event reason (hard rule 3 owns it) and the Audience bullets restating goal 8 and Install; the `'use strict'` clause in both MIGRATION.md and CHANGELOG.md; the node-smpp cross-check in MIGRATION.md; the planned work in `interop-tests/AGENTS.md` (an expected malformed count per peer) and `benchmarks/README.md`. The prose sweep of #48 adds: the smppload note in both `benchmarks/README.md` and `interop-tests/README.md`; the summary after the `AGENTS.md` link in `interop-tests/README.md`; the `run.py` foreground rule tacked onto rule 5 in `interop-tests/AGENTS.md`, which wants its own number. - [ ] **Give this library one figure at window 50 in `benchmarks/README.md`.** Its "same sink, same host" table reads 37,125/s where the table below it reads 38,675/s; re-measure or cite one run. - [ ] **Name the goal and the premise of every `docs/decisions.md` entry.** The prose sweep of #48 counted 30 of 58 entries naming no goal and 52 with no "valid while" premise. - [ ] **Make `LinkLife` start unbound, or its decision's title true.** A link attached but not yet bound cannot carry a request, while `phase` starts `up`, so the first link and a server session are up before any bind. The `LinkLife` decision in `docs/decisions.md` makes the same claim in its title, and carries the same fix. From the comprehension panel of #25. - [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads. From the comprehension panel of #25. - [ ] **Log why `DlrMerger.expect()` registered no merge.** Ids with no common `-` numbering return silently, the likeliest cause of a `messageDlr` that never fires and the one that leaves no trace. From the comprehension panel of #25. - [ ] **Make "every README example is executed by the suite" true, or stop claiming it.** Goal 10 and the Done table both promise it; `test/readme.test.ts` transcribes the examples by hand and has drifted — 15 fenced `javascript` blocks in the README against 10 tests, and the test named "the documented sending options" passes none of the five options the README's example passes. Read the fenced blocks at test time and assert each appears verbatim in the executed source, so an edit to either fails the gate. - [ ] **Narrow the `src/codec/` table lint exemption to the four table files.** Its stated reason — "the spec tables are data: their length tracks the specification, not any complexity" — is false for `codec/field-types.ts`, which is 595 lines of wire codec with 25 functions and is the file that parses hostile input from the network. It carries more over-budget methods than any other file in the repo, under a suppression written for something else. - [ ] **Run the interop suite before cutting a minor, and date the claim.** README states "Interoperable. Tested as a client against Jasmin and SMPPSim, and as a server against Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp" in the present tense; `interop-tests/README.md` is honest that the run was 2026-09-08. Nothing runs the peers on a schedule or before a tag, so the claim rots silently. Goals 1 and 9. ## Worth doing, not blocking - [ ] **Move the decisions out of AGENTS.md's fixtures paragraph and the two tooling READMEs.** The one dummy SMSC, `smscPeer()` staying separate and which copied helpers are tolerated (AGENTS.md Conventions), and why Kannel is absent from `benchmarks/README.md`, go to `docs/decisions.md` with index lines. Move the planned work written into `interop-tests/AGENTS.md` (an expected count per peer) and `benchmarks/README.md` (the default window gap) to this file. Give the `run.py`-in-background footgun in `interop-tests/AGENTS.md` a rule of its own. Delete AGENTS.md's "message_id values … are UUID v7" line. From the prose pass of #30. - [ ] **Make the dumbclient soak's memory sample evidence of no library leak again.** Its rss ends at its maximum (298 MiB, heapUsed 81 MiB after 173,820 messages), which the harness's own per-id `Set` and `answerOrder` explain but cannot separate from a leak in `src/`: sample the heap after the bookkeeping is cleared. From the stability review of #29. - [ ] **Decide whether `alert_notification` reaches the application as more than `incomingPduObj`.** It is the SMSC saying a handset it could not reach is reachable again (`esme_addr`, `ms_availability_status`); a client has no `onRequest`, so the raw PDU event is the only way in. Raised by the stability review of #21. - [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment, and the `net.Server.close()` sentence restates `closeListenerAfter`'s. Keep the registered-at- creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" — that number lives in `.gitea/workflows/test.yaml`. Raised by the prose pass, 2026-09-20. - [ ] **Leave AGENTS.md hard rule 1 the rule, and the decision log its reasoning.** Rule 1's fourth sentence — "a function whose argument types are a closed set is guarded by the compiler and stays total, which is why the encoding helpers return plainly, and the check belongs at whichever boundary the argument arrives untyped at" — is the reasoning of the `bitCount()`/`encodeMessage()`/`splitMessage()` entry in `docs/decisions.md`, which AGENTS.md's own Documentation section makes a defect: it scopes AGENTS.md to an index of the decisions. It also reads two ways — "wherever the types admit one" as an exemption for a typed field, "the check belongs at whichever boundary the argument arrives untyped at" as a requirement at `sendSms()` — and the `message` `TypeError` item sits exactly between them, so one rewrite settles both. Maintainer's call, since it changes what a hard rule asks. From the prose pass of #18. - [ ] **Refuse a delay Node's timers cannot hold, in `checkLimits`.** `idleTimeout`, `reassemblyTimeout`, `responseTimeout` and `shutdownTimeout` take any integer, and `setTimeout` fires after 1 ms for anything above 2147483647 — so a value in the wrong unit gets the inverse of what it asked for, explained only by a warning on stderr. `connectTimeout` refuses one already, which is the asymmetry to close. The same four print an untyped value bare, so `idleTimeout: '5000'` is refused with `got 5000` — a value the reader reads as correct — where `connectTimeout` quotes it. `namedValue()`'s four sites — `messagingMode`, `encoding`, the time options and `smsIdFormat` — are the same defect once more: there `true` and `'true'` both print as `true`. One fix closes all three, and `valueText()` in `codec/field-types.ts` is the quoted spelling to take it from. Raised by review, 2026-09-20. - [ ] **Refuse a send the codec cannot build before it waits for a link and a window slot.** Today it waits first. `refuse()` in `outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on every attempt is the same case, and #98 made it a common one. On a down link the caller waits `responseTimeout` and is told the link failed rather than that the body could not be built — goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is the open half. Raised by the architecture review of [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09. - [ ] **Split the SMPP time format out of `message.ts` once the file has to move anyway.** It answers two questions: message coding and the SMPP time format (`smppDate`, `smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is the split. Raised by the architecture review of [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09. - [ ] **Add a gate that refuses a floating version anywhere in the repo.** Maintainer's ask on [#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml` pinning thread. Pinning every action and runner by hand is what the ask followed; the gate is what keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the full patch version — so one grep for `latest` is not it. - [ ] **Add a gate that catches the test matrix missing the current Node.** Maintainer's ask on [#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node 26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the Node release schedule is published as JSON — and a decision on whether a new Current fails the build or opens a PR, which is what Renovate already does for everything else here. - [ ] **Have CodeRabbit review Gitea pull requests through the GitHub mirror.** CodeRabbit does not support Gitea, so mirror each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not started until asked. - [ ] **Count what is left of a budget one way in `leftOf()` and `LinkLife`.** Today they are one concept counted twice. `idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0 means "forever" there; `link-life.ts` runs the same subtraction and calls `<= 0` expired. Neither is reachable from the other, so nothing can disagree today, but a reader who learns one and applies it to the other is wrong. A budget type both take would close it. Raised by review, 2026-09-01. - [ ] **Write a non-zero `err:` on a receipt only for a state that failed.** `receiptText()` now writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the SMSC never had. Which of those are failures is the open half. Raised by review, 2026-09-03; needs a decision. - [ ] **Share one `once()` across the test files, one that gives up.** Today it is copied into four test files, and two copies never give up. `session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and `tls.test.ts` wait forever, so an event that never fires still hangs the run the way an unclosed listener used to. One shared, guarded copy closes the rest of that class. - [ ] **Carry the peer's address and bind on every session log message.** `remoteAddress` and `remotePort` reach only `server - incoming connection`, and `systemId` only the bind messages, so with several peers connected one session's lines cannot be told apart, and a reconnect leaves nothing stable to filter on. Carrying them in every session message's metadata is a change to every call site. From [#8](https://github.com/larvit/larvitsmpp/issues/8), closed there as tracked here; maintainer's call, 2026-09-14. - [ ] **Settle how a peer whose message ids share one base logs its refused merges.** Today it logs one on every send. `smsc01-000123` and `smsc01-000124` carry the same base, so `DlrMerger` merges the first message and refuses every one after it, one log line per send. Left at `info` — nothing the operator can fix is wrong — but a rate guard or silence may suit it better. Raised by review, 2026-08-30. - [ ] **Exercise `submit_multi` and the broadcast commands end to end.** They encode and decode, but nothing exercises them end to end. The interop suite is the natural place. - [ ] **Move to TypeScript 7 once `typescript-eslint` supports it.** `renovate.json` pins TypeScript below 6.1 for exactly that reason. - [ ] **Add an `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it, but `dlrFromPdu()` is wired into `IncomingRequests` with no seam of its own: an application facing a format we do not parse has to take the whole PDU on `onRequest` and reimplement the dispatch, which owns the response as well. Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through to the built-in parser. ## Gaps against other SMPP libraries From comparing 0.5.0 with `smpp`, `@semyonf/smpp`, `@leissner/node-red-smpp`, `node-smpp-next`, `smpp-js-sdk`, `smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14. Each lands under goal 7: an option or a hook, with the call that passes none unchanged. ### Sending - [ ] **Add a limiter hook, with a messages-per-second cap built on it.** Maintainer's call, 2026-09-14, reversing the earlier decline: Kannel, Jasmin, go-smpp and `smpp-js-sdk` all limit throughput. Count PDUs, not `sendSms()` calls — a long message is one `submit_sm` per segment and the operator counts those, which an application wrapping `sendSms()` cannot see. The hook takes a limiter the application already runs; the built-in cap counts per session until the store at the bottom lets it span sessions and processes. The default stays uncapped. Open: the hook's shape (a wait that resolves when a PDU may go, cut short by the send's `signal`), which requests it gates — messages, never `enquire_link`, `unbind` or a response — and whether its wait counts against `responseTimeout`. - [ ] **Back off and resend on `ESME_RTHROTTLED`.** The SMSC refused the PDU, so resending cannot duplicate it and goal 2 holds, and the retry needs nothing wider than the session. Needs a decision: on by default with a bounded budget, as goal 5 suggests, and whether `ESME_RMSGQFUL` counts too. A throttled answer is also what a limiter hook wants to hear about. - [ ] **Let `sendSms()` take the rest of `submit_sm`.** `service_type`, `priority_flag`, `protocol_id`, `replace_if_present_flag` and TLVs on every segment, and `registered_delivery` beyond final receipts: on failure only, and intermediate notifications. Today each needs `send()`, which gives up splitting, the alphabet checks and receipt merging. TLVs are the common case: India's DLT rules put `PE_ID` (0x1400) and `TEMPLATE_ID` (0x1401) on every `submit_sm`, and USSD rides on `ussd_service_op`. Refuse a TLV the send composes itself (`sar_*`, `message_payload`). Open: one spelling for receipts, since `dlr: true` and a raw `registered_delivery` could disagree, and whether goal 4's rule on optional parameters binds a TLV the caller named. - [ ] **Choose how a long message is spelled on the wire.** Only an 8-bit UDH reference goes out (`message.ts`), though the reader takes a 16-bit UDH, `sar_*` and `message_payload` alike. Some SMSCs take only `sar_*` or `message_payload`; php-smpp offers all three. A 16-bit reference also makes a collision rarer: the 8-bit one wraps every 255 sends on a session. - [ ] **Fail over across SMSC hosts.** Maintainer's call, 2026-09-14. `client()` takes one `host` and `port`; Kannel, Jasmin and php-smpp take several. A list the reconnect loop walks holds only which host the one session is on, so it needs no store. Two options, both maintainer's calls: - **Order**, `fixed` or round robin, default `fixed`. Fixed starts every reconnect at the first host; round robin at the host after the one the link was last on. - **Starting over**, a boolean, default on: once the last host has been tried, go back to the first. Off ends the session once the last host fails, as a drop does under `reconnect: false`. Open: how the list and today's `host` and `port` share one spelling; whether the backoff grows per host or per pass; and whether round robin with starting over off still tries the hosts before the one it started at. ### The server - [ ] **Send `deliver_sm` from `sendSms()` on a `server()` session.** Today it sends `submit_sm` toward the ESME. Kannel answers `ESME_RINVCMDID` (`interop-tests/kannel.test.ts`, "MO to Kannel"), and goal 1 says that PDU never goes out. A server has no other way to send an MO message either: `sendMo()` in that test builds one from `submitSmParams()` and `ConcatReference`, neither exported. Choosing `deliver_sm` by `linkEnd` gives MO messages the splitting and checks, keeps one method for one goal, and refuses the options 3.4 has `deliver_sm` leave empty (`scheduleDeliveryTime`, `validityPeriod`). - [ ] **Let a response this library builds carry error TLVs.** `buildBody()` in `pdu.ts` writes no body for any non-zero status, so a server cannot answer a `data_sm` with `delivery_failure_reason`, `network_error_code` or `additional_status_info_text`, and a 5.0 peer gets none of its error TLVs. 3.4 omits the body on error for `submit_sm_resp` by name; read each response's section before widening it. Reading needs nothing: an error response carrying a body already parses. - [ ] **Accept the PROXY protocol on `server()`.** Behind HAProxy or an AWS NLB every session's remote address is the balancer's, so `authenticate` cannot allow-list by IP and logs name the wrong peer. v1 is text; v2 is binary and the only one an NLB sends. `smpp` accepts v1 from anyone; accept either only from addresses the option names. - [ ] **Handle `outbind`.** It is in the command table, handled nowhere: a client cannot take an SMSC's `outbind` and bind back, and `server()` cannot send one. Rare; take it on with a peer that uses it. - [ ] **Register vendor-specific commands.** 3.4 reserves `command_id` `0x00010200`–`0x000102FF` for SMSC vendors; today one arrives as a `PduRefusedError`. `smpp` has `addCommand()`. The same shape question as registering an encoding. ### Encodings - [ ] **Register a custom encoding.** Maintainer's ask, 2026-09-14. `EncodingName` is a closed union of three (`codec/encodings.ts`). An entry needs a name, a `data_coding`, `encode`, `decode`, `match`, whether `detect()` may pick it, and enough for `splitMessage()` to budget a segment without halving a character. Take encodings as a client or server option rather than mutating a module table as `smpp` does, so two sessions in one process cannot disagree about a name. A taken name is an `err`. Settle `consts.ENCODING`'s names first. An entry may claim a `data_coding` a built-in already uses, maintainer's call, 2026-09-14: an SMSC whose default alphabet, 0x00, is Latin-1 needs Latin-1 written and read under it ([#23](https://github.com/larvit/larvitsmpp/issues/23)). The entry then owns that coding on its session both ways: `encodingByDataCoding()` resolves to it, `detect()` tries it in the built-in's place, and naming the displaced built-in is an `err` naming the entry. Two entries on one coding are an `err`. Settle whether a claim on 0x00 reaches the class groups `messageClassEncoding()` reads GSM 7-bit from, which `flash` writes under. - [ ] **Carry the alphabets SMPP 3.4 names that no encoding carries.** `consts.ENCODING` lists the `data_coding` ids (5.2.19); only `ASCII`, `LATIN1` and `UCS2` can be sent. Those with a published definition, and what each costs: - 0x01 IA5 (ITU-T T.50, ASCII in practice): trivial. - 0x06 ISO-8859-5 (Cyrillic) and 0x07 ISO-8859-8 (Hebrew): 96-entry tables. - 0x05 JIS X 0208, 0x0D JIS X 0212, 0x0A ISO-2022-JP and 0x0E KS C 5601: two-octet sets. `TextDecoder` reads them through ICU — EUC-JP and EUC-KR once each octet's high bit is set, `iso-2022-jp` as is; checked for JIS X 0208 and KS C 5601 on Node 24.18.0. Nothing built in encodes them, so ship tables or build the reverse map on first use by decoding the 94×94 grid. A Node without full ICU throws from `new TextDecoder()`, which hard rule 1 wraps into an `err`. - 0x09 pictogram has no published definition; leave it out. - [ ] **Read and send the GSM 7-bit national language shift tables.** 3GPP TS 23.038 defines them for Turkish, Spanish (single shift only), Portuguese and ten Indian languages — Bengali, Gujarati, Hindi, Kannada, Malayalam, Oriya, Punjabi, Tamil, Telugu and Urdu — selected per message by UDH elements 0x25 (locking) and 0x24 (single). They keep that text near GSM's segment size instead of UCS2's 67 characters. Reading means honouring those elements in `decodeMessage()`; sending means `detect()` picking a table, with each element's 3 octets off the segment budget. `smpp` has Turkish, Spanish and Portuguese, used only when the caller writes the UDH. - [ ] **Offer packed GSM 7-bit, opt-in.** Everything goes out unpacked, SMPP's convention (AGENTS.md, "GSM 7-bit is sent unpacked"); go-smpp carries a packed codec for SMSCs that want septets. Find an SMSC that needs it before building it. - [ ] **Give each alphabet in `consts.ENCODING` one name.** Five are spelled twice: `CYRILLIC`/`ISO_8859_5`, `HEBREW`/`ISO_8859_8`, `JIS`/`X_0208_1990`, `EXTENDED_KANJI_JIS`/`X_0212_1990` and `LATIN1`/`ISO_8859_1`; `FLASH` is a message class, not an alphabet. One name each before registration starts taking names. A breaking change to an export. ### Observability - [ ] **Add metrics: outbound PDU events and counts of requests waiting.** Inbound traffic has `data`, `incomingPdu` and `incomingPduObj`; outbound has no event, and nothing counts requests in flight, queued for a window slot, waiting for a link, or unanswered. `smpp` and `@semyonf/smpp` emit `metrics`; cloudhopper keeps per-session counters. An `outgoingPdu`/`outgoingPduObj` pair mirrors the inbound events; the counters can be one read-only snapshot, read from the owner of each count rather than a second tally that can drift. ### Packaging, tests and CI - [ ] **Ship `src`, so the source maps lead somewhere.** Maintainer's call, 2026-09-14. `sourceMap` and `declarationMap` write maps whose `sources` are `../src/*.ts`, but `files` publishes only `dist`, so 82 of the package's 167 files, 225 KB of its 555 KB unpacked, point at nothing. Adding `src` (41 files, 209 KB) makes go to definition land in the TypeScript, and lets a debugger or `--enable-source-maps` show it. - [ ] **Add a coverage report and a floor to the gate.** `node --test --experimental-test-coverage --test-coverage-include='src/**' test/*.test.ts` on Node 24.18.0, 2026-09-14: 98.77% lines, 93.85% branches, 98.28% functions. Gate at 98, 93 and 98 with `--test-coverage-lines`, `--test-coverage-branches` and `--test-coverage-functions`, which Node 22 and later take — a job of its own on 24, since the matrix runs compiled JavaScript — and add it to `main`'s required checks. Raise the floor as coverage rises; never lower it. - [ ] **Add mutation testing.** `@stryker-mutator/tap-runner` runs `node:test` suites and measures whether a test notices a change, which coverage cannot; `@semyonf/smpp` runs Stryker in CI. The session suites are timer-heavy, so start with the codec and the encodings. - [ ] **Build a Node-RED node, as a package of its own.** `@leissner/node-red-smpp` is the only SMPP node in the Node-RED library, and by a read of its source it never parses a receipt and never answers the SMSC's `enquire_link`. Its UI is a fair list of what operators set. It builds on this package, never inside it. ## Declined - **A check that warns before `MIRROR_GITHUB_TOKEN` expires.** Gitea mails no one about a failed scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after expiry, which mails whoever pushed. Maintainer's call, 2026-09-14. - **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both. `require()` of an ES module works unflagged from Node 20.19 and 22.12. ## An optional store - [ ] **Add pooling, and state that survives a restart, through an optional store.** Maintainer's call, 2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions — and goal 9 was rewritten for it. Big: design before code. - **What it holds.** Receipts still awaited and the groups `DlrMerger` collects. Segments of a message already answered but not yet whole, which the peer will not send again (goal 2). The concatenation reference, so a restart does not reuse one. For a pool, the ids every session sent, since an SMSC may deliver a receipt on any bind of the account, and the messages-per-second budget the sessions share. - **What it cannot hold.** A response belongs to the link its request arrived on, so a message left unanswered at a restart stays unanswerable; the peer's own timeout settles it. - **Pooling.** Several sessions, in one process or many, behind one send: a message goes to a bound session with a free window slot, all its segments on that one. In one process the in-memory store is enough; across processes the application supplies one. - **The interface.** Narrow, with keys and records of the library's own making, versioned, with expiry: a record from an older version is read or refused, never misread, and `DlrMerger`'s group shape is never published (goal 8). What processes share needs an atomic operation — compare-and-set or increment — since get-then-set races. - **Adapters live elsewhere.** Redis, Postgres or SQLite stores are packages of their own; this one ships the interface and the in-memory store, and no runtime dependency (goal 10). - **When the store fails.** Open: a send whose awaited receipt cannot be recorded is refused, or sent and reported as undetermined (goal 2); a pool whose store is down stops, or falls back to memory. Either way, an application that supplied no store never waits on one. - **One spelling.** A store-backed cap and the limiter hook both reach a limit shared between processes; settle which owns that case before building the second. - **Restore the store operator to README's personas, and drop "which has not shipped" from the Audience bullet, when the store ships.** Removed 2026-09-28, maintainer's call.