# MHI Gateway Technical Proposal

## RC1-9 — persistent YAS SMPP runtime

`gateway:smpp:run yas` is the single operational transceiver process. It validates activation and configuration, acquires the database lease before opening a socket, and composes the existing outbox publisher, runtime/session executors, RC1-9A transitions, inbound orchestration, DLR correlation, status projection, and webhook flow.

Outbox publication occurs inside the runtime poll. Inbound readiness is checked without weakening exact-PDU deadlines. The runtime renews ownership, sends keepalives, reconnects with bounded delay, and conservatively recovers abandoned claims.

Synchronous SMPP exchanges retain one absolute response deadline while accepting interleaved provider traffic. A `deliver_sm` is durably recorded and acknowledged with its incoming sequence, and a provider `enquire_link` is answered with its incoming sequence, before the session continues waiting for the response type and sequence belonging to the outstanding command. An unresolved transmitted submission is never resent automatically. Reconnect creates a new session executor and coordinator bound only to the replacement socket.

Operational endpoint selection is deterministic ordered round-robin over the validated YAS host list. Each session creation selects `hosts[next_host_index % host_count]` and then increments the process-local index. Startup therefore selects the first configured host; each reconnect advances to the next configured host, wrapping to the first only after the complete list has been attempted. A successful connection does not reset the index, so a later disconnect continues with the following host. Process restart resets the non-persisted index to zero and begins again with the first configured host; no endpoint or reconnect position is stored in the database.

Host rotation changes endpoint selection only. The factory creates an unconnected session object, while the runtime acquires the provider lease before opening its socket. On failure the current transport is closed before the next session is created, and dispatch remains fenced by the same lease owner and generation. Rotation cannot authorize ownership, create an additional active session, alter submission recovery, or bypass RC1-9A transitions. The public API, OpenAPI contract, `RuntimeLoop`, acknowledgement handling, inbound/DLR processing, status projection, and webhook behavior remain unchanged.

The operational bind decoder accepts the provider-compatible successful `bind_transceiver_resp` form whose required C-octet field is present but contains an empty SMSC system identifier. This compatibility is bind-specific: a failed bind response must still carry no identifier, while `submit_sm_resp` retains its existing response-identifier invariant. The operational composition may log only the first 16 response-header bytes as lowercase hexadecimal at debug level to diagnose framing, command, status, and sequence; the bind body and all credential-bearing request bytes remain excluded.

## RC1-9A — durable SMPP submission lifecycle

The internal `smpp_submissions` handoff now has a closed, ownership-fenced lifecycle: `pending`, `claimed`, `accepted`, `rejected`, `failed`, and `acknowledgement_unknown`. Claim ownership is bound to the active provider lease owner and generation. A runtime records `submit_started_at` before network submission; only an expired claim proven not to have crossed that boundary may return to `pending`. An expired attempted claim becomes acknowledgement-unknown and is never automatically resent.

Successful `submit_sm_resp` evidence atomically records the provider message identifier, projects the owning message to submitted, and appends one lifecycle event. Provider rejection records its command status and projects rejected without conflating it with DLR delivery failure. Exact acknowledgement replay is idempotent; conflicting evidence and stale ownership fail closed. Provider acceptance remains distinct from delivery, and the existing DLR repository remains authoritative for delivered, failed, expired, and rejected delivery outcomes.

RC1-9A adds no persistent process, command, reconnect loop, public endpoint, or public-contract change. Those operational responsibilities remain in RC1-9.

## RC1-7 — release-candidate handover and acceptance boundary

RC1-7 adds documentation only. [RELEASE_CANDIDATE.md](RELEASE_CANDIDATE.md) summarizes the committed RC1 flow and public integration surface; [DEPLOYMENT.md](DEPLOYMENT.md), [OPERATIONS.md](OPERATIONS.md), [YAS_INTEGRATION_GUIDE.md](YAS_INTEGRATION_GUIDE.md), and [ACCEPTANCE_CHECKLIST.md](ACCEPTANCE_CHECKLIST.md) define deployment, operational, external-provider, evidence, security, and go/no-go responsibilities without changing runtime behavior.

Implementation completion and automated verification are distinct from staging integration, YAS external acceptance, and production approval. The repository has diagnostic bind/send commands and an outbox publisher command, but no persistent SMPP runtime start/restart command; RC1-7 does not invent one. External endpoint, credential, sender, TON/NPI agreement, coding, DLR timing/format, throughput, firewall, test-data, support, and acceptance-window facts remain pending YAS confirmation. Milestone 6A remains deferred, not cancelled until RC1 acceptance closes.

## RC1-6 — tenant delivery webhook implementation

The authenticated tenant API exposes one safe webhook configuration through `GET /api/v1/webhook` and create-or-replace `PUT /api/v1/webhook`. A narrow application service and repository keep controllers free of persistence logic. Current and previous secrets are encrypted at rest; replacement activates the new secret immediately and retains the prior ciphertext with a database-timed 24-hour overlap. ORM records and secret values remain inside the persistence boundary, and GET never exposes either secret.

RC1-5 remains the normalized event source and `PublicMessageStatusProjector` remains the status authority. In the same transaction as an applied terminal receipt, the delivery repository appends one immutable tenant webhook event protected by a unique message identity. Publication occurs only after the outermost successful commit. A publisher then reserves one durable attempt before network I/O, signs the exact public-only JSON bytes with HMAC-SHA256, sends once with a 10-second timeout, and records success or sanitized failure. Duplicate DLRs cannot reserve a second attempt, and webhook failure cannot reverse delivery evidence.

No retry, retry schedule, backoff, dead-letter queue, dashboard, statistic, billing, wallet, schedule, campaign, template, SMPP runtime, `SessionExecutor`, or `RuntimeLoop` behavior is introduced.

## RC1-5A — tenant webhook contract amendment

RC1 supports exactly one delivery webhook configuration per tenant through `GET /api/v1/webhook` and create-or-replace `PUT /api/v1/webhook`. The configuration has only enabled state, one URL, and one write-only secret containing at least 32 cryptographically random bytes. Replacement activates the new secret immediately while receivers accept the prior secret for a 24-hour overlap. Multiple endpoints, priority, filtering, POST, and DELETE remain excluded.

Only the first normalized terminal public outcome—delivered, failed, expired, or rejected—may create one webhook event. The public body contains only message identity, status, optional client reference, optional delivery time, and event timestamp. It excludes provider, SMPP, database, persistence, stack-trace, and provider-metadata details. Duplicate DLRs cannot create duplicate events or deliveries.

Outbound requests use HMAC-SHA256 over the exact `<timestamp>.<raw-body>` bytes with `X-Webhook-Timestamp` and `X-Webhook-Signature`. Receiver replay tolerance is five minutes. Delivery has one 10-second attempt in RC1, no retry, and a 90-day attempt-record retention period. Event recording must be transactionally consistent with delivery evidence, network delivery occurs only after commit, and a webhook failure never reverses persisted message delivery. Retry scheduling is deferred to Phase 6A. This amendment changes documentation only; RC1-6 owns implementation.

## RC1-5 — delivery receipt correlation and projection evidence

The completed inbound envelope pipeline now invokes one application outcome consumer after the pure classifier, router, and handler. Non-DLR outcomes pass through without effect. A typed DLR reuses the classifier's receipt-evidence authority, retains inbound provider provenance, and calls a narrow delivery-receipt persistence contract before the envelope is terminally marked processed. Malformed usable evidence fails closed and quarantines only that envelope; unknown or ambiguous provider references are safely consumed without message mutation.

The database repository locks the acknowledged submitted attempt selected by `(provider_code, provider_reference)` and requires exactly one match. Within one transaction it appends immutable normalized receipt evidence, updates the owning message when the transition is legal, and appends one message event. An evidence fingerprint and attempt/state uniqueness make exact replay idempotent. Existing terminal delivery states are immutable; contradictory later evidence remains durable for reconciliation without rewriting public history. If any write fails, receipt, message state, and event all roll back.

The existing `PublicMessageStatusProjector` remains the only public status authority. No new API endpoint, DLR-specific public field, callback, webhook, retry, schedule, campaign, template, report, billing, wallet, reconnect, SessionExecutor, or RuntimeLoop responsibility is introduced.

## RC1-4 — public message status query

The existing `GET /api/v1/messages/{message_id}` endpoint now composes validated public identity, one tenant- and application-scoped aggregate query, a pure closed status projector, and an exact `MessageDetails` representation. The query returns only the owning message and its SMS address representation; cross-scope and absent identities are indistinguishable. Controllers contain no projection or persistence logic.

The projector reads existing message and delivery enums without changing them. Authoritative terminal delivery evidence takes precedence; pending delivery maps existing received-through-processing states to `queued`, submitted to `submitted`, failed or cancelled to `failed`, expired to `expired`, and unconfirmed completion to `unknown`. It cannot create transitions, DLR evidence, or provider state. The response and safe log use the current request correlation while persisted message correlation remains unchanged.

No dedicated status subresource, DLR receiver or projection write, callback, webhook, retry, schedule, campaign, template, batch retrieval, statistic, report, billing, wallet, SMPP, session, or runtime responsibility is introduced.

## RC1-3 — bulk message submission implementation

The bulk endpoint is a thin HTTP and application composition boundary. Top-level request validation establishes the mandatory tenant-scoped idempotency key and 1–100 item bound. Item validation then produces ordered accepted or sanitized rejected results independently; every accepted item delegates to the existing RC1-2 `MessageCreationService` and retains its transaction boundary.

An infrastructure repository owns internal durable replay metadata keyed by tenant and a one-way hash of the request key. It persists the canonical ordered fingerprint, replay-stable UUIDv4 `batch_id`, and completed public result. MySQL advisory locking serializes contenders for one tenant/key while deterministic per-item RC1-2 keys make interrupted completion resumable without duplicate messages. This metadata is not a public batch entity and has no route or lifecycle. The current HTTP request correlation remains middleware-owned and is never stored in or returned as part of the batch body.

No public batch retrieval, cancellation, retry, scheduling, template, campaign, DLR, status projection, webhook, billing, wallet, SMPP, session, inbound, or runtime responsibility is introduced.

## RC1-1A — bulk API contract amendment

The approved RC1-3 bulk boundary is `POST /api/v1/messages/bulk`, authenticated and tenant-isolated by the existing API v1 authorities. A valid top-level request contains an optional batch `client_reference` and 1–100 ordered single-message inputs. Invalid top-level structure, batch bounds, authentication, idempotency, rate limiting, or pre-result internal failure rejects the request as a whole. After that boundary, items are evaluated independently: one item rejection never rolls back an accepted sibling.

One HTTP `202` result preserves input order and contains a replay-stable UUIDv4 `batch_id`, optional batch reference, `accepted`, `rejected`, and exactly one accepted or rejected result per zero-based index. Accepted items expose only message identity, `queued`, and optional client reference. Rejections expose only compact sanitized API errors. A valid batch may return `202` with zero accepted items.

Tenant-scoped durable idempotency covers the canonical ordered request and complete stable batch result. Equivalent concurrent requests converge on the original identities, counters, errors, ordering, and `batch_id`; conflicts fail closed. `X-Correlation-ID` always identifies the current HTTP request, while `batch_id` is tracking identity rather than correlation. Replay metadata may persist internally, but no public batch model, lifecycle, table requirement, GET endpoint, cancellation, retry endpoint, schedule, template, campaign, DLR, status projection, webhook, MO, wallet/billing, or SMPP/runtime behavior is approved.

## RC1-2 — send API hardening

`POST /api/v1/messages` requires `recipient`, `message`, and `sender_id`; `client_reference` remains optional. The Sender ID must exactly match an active tenant-owned identity assigned to the authenticated application. There is no default or fallback. Failures use `INVALID_SENDER_ID`. The response is the closed public acceptance representation with `queued`, never internal message, delivery, or billing enums.

One tenant-scoped replay record stores only a SHA-256 idempotency-key hash, a canonical semantic request fingerprint, and the created message reference. A transaction-local reservation is inserted before message-side writes and completed with the message identity before commit. This order lets the MySQL unique constraint serialize equivalent and conflicting contenders without gap-lock deadlocks. Identical requests return the original stored message result; conflicting fingerprints fail closed. Historical application-scoped message keys and indexes remain unchanged.

Request context generates UUIDv4 correlation when absent. `X-Correlation-ID` always identifies the current HTTP request, including a replay, while the response-body `correlation_id` and explicit message/business submission log retain the original message correlation. Authentication and rate limiting are unchanged. Non-send API resources retain their legacy envelopes. No bulk, read/status projection, DLR, webhook, retry, queue, billing, SMPP, session, inbound, or runtime behavior is added.

## RC1-1 — proposed API contract and lifecycle freeze

RC1-1 is documentation-only and does not activate API behavior. `docs/api/API_V1_CONTRACT.md` records the audit and rationale; its OpenAPI 3.1 companion, `docs/api/openapi.yaml`, is the normative implementation source of truth.

Repository audit found that `POST /api/v1/messages` and `GET /api/v1/messages/{message_id}` exist but require compatibility hardening. Bulk submission, a dedicated status resource, and delivery-webhook configuration do not exist. The current single-message surface uses legacy fields and response envelopes, exposes internal status dimensions, and treats repeated idempotency keys as conflicts rather than replaying identical durable results. Those differences require an independently approved compatibility design; the freeze does not silently replace them.

The proposed public lifecycle is the closed vocabulary `queued`, `submitted`, `delivered`, `failed`, `expired`, `rejected`, and `unknown`. Internal message, delivery, dispatch, attempt, and SMPP receipt states remain authoritative evidence, while one future projection boundary owns normalization. Terminal states are immutable; identical DLR evidence is idempotent, contradictory late evidence is retained for reconciliation, and uncorrelated receipts cannot mutate public state. Provider message identifiers remain internal and are correlated only with provider/connection authority.

The API remains bearer-authenticated and tenant/application-isolated. Durable tenant-scoped request fingerprinting is required for exact idempotent replay. Request correlation uses `X-Correlation-ID`. Delivery webhook configuration is one safe tenant resource with a write-only secret; outbound events are immutable, HMAC-SHA256 signed over timestamp plus exact body bytes, and deduplicated by event identity. Numeric retention, batch, timeout, retry, replay, secret, rotation, and legacy-deprecation policies remain explicit approval blockers.

No route, controller, request, resource, service, repository, model, migration, provider binding, queue, webhook dispatcher, SMPP component, or runtime behavior is introduced by RC1-1. Milestone 6A is deferred, not cancelled, until RC1-1 through RC1-7 close.

## Milestone 5C-6 — inbound processing orchestration

`InboundEnvelopeProcessingOrchestrator` is the application composition boundary for one persisted inbound envelope. Its fixed single-item flow is: lifecycle repository claim, ownership-authorized read/decryption, authoritative PDU decode, immutable processing-input construction, one `DeliverSmClassifier` invocation, one inbound router invocation, one inbound handler invocation, and one lifecycle terminal mutation. The orchestrator receives its repository, decoder, and processor contracts explicitly; it performs no service location, loop, retry, reconnect, acknowledgement, or ambient transaction.

The protocol processing adapter reuses the stable classifier, `ProtocolReceiveResult`, router, and handler authorities without changing their rules. Mobile-originated, delivery-receipt, and unknown `deliver_sm` categories retain their existing typed handling results and become `PROCESSED`. A rejected processing-contract result also becomes `PROCESSED`; a quarantined result becomes `QUARANTINED`. Malformed decrypted content, read/decryption failure with retained ownership, or processing failure is finalized as `QUARANTINED`. Missing, stale, or mismatched ownership and lifecycle-write failure return sanitized typed failures and never report false success.

Immutable orchestration results preserve the envelope identity, opaque claim owner, safe transport provenance, processing outcome where available, and durable lifecycle result. The repository remains the sole owner of claiming, authorized decryption, database time, fencing, and terminal idempotency. Existing recording, encryption, persist-before-acknowledgement ordering, `SessionExecutor`, and `RuntimeLoop` are unchanged. Phase 5C adds no tenant resolution, MO/DLR persistence or projection, provider-message correlation, callback, webhook, queue, retry worker, pricing, wallet, billing, or other business behavior.

## Milestone 5C-5 — inbound envelope lifecycle repository

The existing `SmppInboundEnvelopeRepositoryInterface` and `DatabaseSmppInboundEnvelopeRepository` now own the complete persistence lifecycle for the single `smpp_inbound_envelopes` store. An ordered `AVAILABLE` row is claimed under an InnoDB write lock with an opaque UUID owner and a bounded lease duration. Claim timestamps and expiry derive from database UTC time. A row is eligible when it has never been claimed or when `claim_expires_at` is exactly equal to or earlier than the observed database time; active and terminal rows are excluded.

Authorized payload read/decryption and terminal mutation require the envelope identity and matching, unexpired claim owner. A valid owner may set `PROCESSED` or `QUARANTINED` once. Repeating the same terminal operation with the winning owner returns the original terminal timestamp; a conflicting terminal outcome, missing row, mismatched owner, or stale active claim fails with a sanitized lifecycle exception and no mutation. The winning owner is retained on terminal rows as the idempotency fence, and terminal rows can never be reclaimed.

Lifecycle operations reject ambient transactions and execute in short repository-owned transactions. Database checks enforce valid states, coherent all-or-none claim fields, positive claim windows, and terminal timestamp/owner requirements. Ciphertext, transport provenance, envelope identity, initial recording, and persist-before-ack ordering remain unchanged. No classification, routing, handling, orchestration, tenant/business projection, queue, retry worker, callback, webhook, session, or `RuntimeLoop` behavior is introduced.

## Milestone 5C-4 — inbound envelope processing contract

Processing one existing `AVAILABLE` inbound envelope is represented by a framework-free immutable input. The input retains the established envelope identity and provider/session/sequence provenance authorities together with one decoded `DeliverSm`; the PDU sequence must match the provenance sequence. It neither reads nor exposes encrypted persistence directly.

`InboundEnvelopeProcessor` is the single narrow contract from that input to the closed `InboundEnvelopeProcessingOutcome` hierarchy. A handled outcome retains the exact handling result for the input PDU. Rejected and quarantined outcomes retain a processing-boundary failure whose disposition and input must match the outcome. Invalid cross-input or cross-disposition construction fails with stable processing-boundary invariant errors.

These contracts are deterministic and effect-free. They contain no repository, database, framework, encryption, transport, queue, callback, webhook, lifecycle mutation, tenant resolution, business projection, orchestration, session, or `RuntimeLoop` behavior. Durable recording, encryption, provenance ownership, the `AVAILABLE` state, and persist-before-ack ordering remain owned by completed Milestone 5B-7; lifecycle claiming and persistence remain deferred to 5C-5.

## Architecture correction — missing durable inbound-envelope foundation

Repository-wide Git verification found that the inbound-envelope implementation had never existed; only the reserved stub `SmppInboundEnvelopeRepositoryInterface` was present. Corrective Milestone 5B-7 now supplies that missing prerequisite; Milestone 5C-4 remains dependent on its independent approval.

## Corrective Milestone 5B-7 — durable inbound-envelope foundation

Each successfully decoded `deliver_sm` is recorded before its `deliver_sm_resp` is encoded or written. `SessionExecutor::receiveOne()` receives an immutable provider/session transport context, combines it with the authoritative PDU sequence, and submits the exact received PDU bytes to the narrow inbound-envelope repository. Repository failure returns a sanitized `InboundPersistenceFailure`, invalidates the session, disconnects transport, and sends no successful acknowledgement. Existing classification, routing, handling, and `RuntimeLoop` behavior is unchanged.

`smpp_inbound_envelopes` stores an immutable database identity, provider, positive session generation, positive SMPP sequence, encrypted exact PDU bytes, the sole lifecycle state `available`, and database-authoritative microsecond timestamps. `(provider, session_generation, sequence_number)` is unique. An exact duplicate is idempotent and retains its original ciphertext and identity; conflicting content for the same transport identity fails closed. The encrypted content value exposes plaintext only to an explicit closure and omits it from debug, JSON, and serialized representations.

This foundation records transport evidence only. It adds no claim, ownership fence, processed/quarantined transition, recovery, tenant resolution, MO persistence, DLR correlation or projection, queue, retry, callback, webhook, pricing, wallet, billing, or later inbound processing behavior.

## Milestone 5C-3 — inbound handler boundary

After the pure 5C-2 routing step, each immutable inbound route is passed once to `InboundMessageHandler` and converted to an immutable `MobileOriginatedHandled`, `DeliveryReceiptHandled`, or `UnknownDeliverSmHandled` result. Each handled result retains the exact routing result and therefore the original protocol result. Non-routable protocol results, including failures, bypass handling and remain unchanged.

The handler is deterministic, side-effect free, and protocol-only. It adds no persistence, queue, callback, event, tenant lookup, application routing, business processing, retry, reconnect, metric, HTTP call, or state transition. The existing acknowledgement and cleanup behavior is unchanged, and `RuntimeLoop` remains unchanged.

## Milestone 5C-2 — inbound routing foundation

`SessionExecutor::receiveOne()` now passes its single immutable `ProtocolReceiveResult` through one pure `InboundMessageRouter` invocation before returning to its caller. Mobile-originated, delivery-receipt, and unknown `deliver_sm` results become immutable `MobileOriginatedRoute`, `DeliveryReceiptRoute`, and `UnknownDeliverSmRoute` values that retain the exact protocol result. All other protocol results, including failures, pass through unchanged by identity, adding no failure semantics.

The receive path remains one transport read, one protocol decode, one dispatch, one optional acknowledgement, and one routing invocation. Routing is deterministic and side-effect free. It performs no application routing, tenant lookup, persistence, repository access, database write, queueing, callback, event, HTTP call, metric, retry, reconnect, or business processing. `RuntimeLoop` remains unchanged.

## Milestone 5C-1 — `deliver_sm` reception and classification

The bound-session unsolicited receive path now decodes one `deliver_sm` into an immutable protocol model containing every mandatory field and all optional TLVs. The dedicated decoder validates command length, CString termination and limits, field boundaries, `sm_length`, truncation, and TLV boundaries. Message octets are retained without character-set conversion, and unknown TLVs remain raw tag/value pairs.

Classification is protocol-only: mobile-originated message, delivery receipt, or unknown `deliver_sm`, based on `esm_class`, receipt TLVs, and receipt-text indicators. The dispatcher creates one header-only, sequence-matched `ESME_ROK` response. Typed immutable results expose the original header, decoded PDU, classification, and response.

The path remains one read, one decode, one dispatch, and at most one encoded response write. Valid input remains `Bound`; malformed input and write failure use existing fatal cleanup. `RuntimeLoop` is unchanged. No persistence, DLR state projection, inbound routing, tenant or business processing, repositories, queues, callbacks, events, observers, retries, reconnects, scheduling, throttling, windowing, daemon, supervision, metrics, diagnostics, webhooks, HTTP endpoints, or database writes are added.

## Milestone 5B-7 — SMPP runtime protocol event processing

Status: implemented for review; not complete until approval.

There is one synchronous, single-step receive path: `SessionExecutor::receiveOne()` requires a live `Bound` session, calls `SmppTransport::readPdu()` exactly once, calls the protocol decoder exactly once, and dispatches the resulting PDU or safely decoded unknown common header exactly once through `ProtocolEventDispatcher`. It returns one immutable `ProtocolReceiveResult` and never loops or reads a second frame.

At the 5B-7 baseline, an incoming `enquire_link` was handled by constructing one header-only `enquire_link_resp` with `ESME_ROK`, the original sequence number, and the protocol-owned bodyless command length, then encoding and writing it once. `generic_nack` preserved its command status and sequence as a typed fatal protocol result without a response. Known unsupported requests at that milestone included raw `deliver_sm` and `unbind`. Milestone 5C-1 subsequently adds protocol-level `deliver_sm` support; `unbind` remains unsupported in the unsolicited path. Responses received outside their synchronous exchange return `UnexpectedResponse`; structurally valid unregistered command IDs return `UnknownCommand` with common-header metadata. Decoder failures return `MalformedPdu`, while transport read/write failures remain distinct as `TransportFailure`.

Receive is legal only in `Bound`. `Connected`, `Disconnected`, and `Failed` return `IllegalSessionState` without transport I/O or state changes. Existing fatal policy is retained: transport failures, malformed PDUs, `generic_nack`, and unexpected responses invalidate and disconnect the session; supported-but-unhandled requests and unknown commands do not unnecessarily destroy it. `RuntimeLoop` and its actions remain unchanged.

Milestone 5B-7 itself performed no `deliver_sm` body or receipt interpretation. Milestone 5C-1 supersedes that limitation only for protocol decoding, classification, and acknowledgement; DLR persistence, inbound SMS routing, database writes, retry, reconnect, throttling, windowing, heartbeat scheduling, daemon or background processing, supervision, metrics, and diagnostics remain excluded.

## Milestone 5B-6 — SMPP outbound execution

Status: implemented for review; not complete until approval.

Outbound execution is separated into explicit boundaries. `RuntimeExecutor` executes the immutable lease, claim, mapping, offline encoding, release, and sleep intents produced by the 5B-5 planner and converts execution results or sanitized failures into immutable `RuntimeOutcome` values. Sleep is executor-only: success has no outcome and a sanitized `RuntimeExecutionException` is surfaced outside `RuntimeLoop`. `RuntimeLoop` remains unchanged as a pure, deterministic intent planner and has no repository, mapper, codec, timer, session, or transport dependency.

There is one outbound sequence: `ClaimSubmissions` → `MapSubmission` → immutable `SubmitSm` → lease-validated immutable `OutboundSubmission` → `OutboundCoordinator` → `SessionExecutor::submitSm()` → typed accepted, rejected, or failed outcome. `RuntimeExecutor` is the mapper's only caller. It retains the mapped handoff until the existing `EncodePdu` action, then authoritatively fences provider, owner, generation, and unexpired ownership against database time immediately before calling the coordinator. A failed fence returns a typed `lease_lost` action failure before encoding or transport I/O. The coordinator never maps, rebuilds a PDU, or reads repository state. The session encoder performs the single wire encoding. The existing `PduEncoded` runtime outcome carries the typed outbound result, while fatal submission failure remains an `EncodePdu` action failure so the unchanged planner does not advance its sequence. Submission ID, provider, opaque lease owner, lease generation, and sequence remain in `OutboundSubmission`. Acceptance contains the SMSC message ID; rejection contains only the legal SMPP command status. No raw serialized bytes are placed in runtime metadata.

`SessionExecutor` owns the synchronous SMPP client lifecycle over `SmppTransport`: connect, `bind_transceiver`, bound operation, `enquire_link`, `submit_sm`, `submit_sm_resp`, `unbind`, and disconnect. Protocol-owned factories calculate command lengths, and the 5B-4 encoder/decoder remain the serialization boundary. Expected response class, sequence, `generic_nack`, malformed response, and command status are validated. States are `Disconnected`, `Connected`, `Binding`, `Bound`, `Unbinding`, `Closed`, and `Failed`; fatal transport, malformed protocol, response-type, sequence, and generic-nack failures invalidate the session and disconnect transport. Valid SMSC submission rejection leaves a bound session usable, while bind rejection never enters `Bound` and unbind rejection invalidates the session.

Outbound runtime failures also carry a closed, sanitized diagnostic category independently of public message state: `submit_response_timeout`, `submit_response_type_mismatch`, `submit_response_sequence_mismatch`, `submit_generic_nack`, `submit_transport_disconnected`, `submit_response_malformed`, `interleaved_deliver_sm_persistence_failed`, `interleaved_deliver_sm_response_failed`, `interleaved_enquire_link_response_failed`, `submit_acceptance_persistence_failed`, `submit_rejection_persistence_failed`, and bounded internal fallback categories. Runtime warning context is allow-listed to provider, session generation, outbound and expected/actual command categories, expected/actual sequence numbers, bounded command status, diagnostic category, possible wire exposure, acknowledgement ambiguity, and reconnect requirement. It excludes message bodies, source and destination addresses, raw or encrypted PDUs, System ID, passwords, hosts and connection credentials, API/authentication secrets, keys, connection strings, and model serialization.

Diagnostic command evidence uses a closed SMPP command enum and sequence/status evidence is validated against SMPP uint32 bounds. Category-specific factories derive exposure, ambiguity, and reconnect flags; callers cannot supply contradictory flags or attach arbitrary context. A matching-sequence `generic_nack` retains its bounded status as `submit_generic_nack`; a NACK with another sequence is retained conservatively as `submit_response_sequence_mismatch`. Interleaved `deliver_sm` and provider `enquire_link` failures retain only their fixed command category and bounded incoming sequence. Unknown failures at a boundary where wire exposure cannot be disproved become the typed, ambiguous `submit_failure_unclassified`; failures proven to occur before session handoff use the same category with factory-derived pre-wire flags. Runtime reconnect diagnostics map known typed exceptions and use `runtime_failure_unclassified` for unknown Throwables, never their free-form messages.

The diagnostic cause does not establish delivery truth. After possible `submit_sm` wire exposure, lack of an authoritative durable acknowledgement remains `acknowledgement_unknown`, including when a matching accepted response was decoded but acceptance persistence failed. Such claims are not automatically resent, duplicate-send protections and public status projection remain unchanged, and provider reconciliation is required before manual retry. Diagnostics describe the Gateway observation and cannot always prove the provider's root cause.

A provider rejection becomes the ordinary durable rejected outcome only after `markRejected` succeeds. If rejection persistence fails, `submit_rejection_persistence_failed` records possible wire exposure, local acknowledgement ambiguity, and required reconnect without automatically resending or fabricating public rejection state.

| Session state | Legal next states |
| --- | --- |
| `Disconnected` | `Connected`, `Closed` |
| `Connected` | `Binding`, `Closed` |
| `Binding` | `Bound`, `Connected` after local/rejected bind, `Failed`, `Closed` |
| `Bound` | `Unbinding`, `Failed`, `Closed` |
| `Unbinding` | `Closed`, `Failed` |
| `Failed` | `Closed` |
| `Closed` | `Closed` |

A nominally bound session whose transport is no longer connected is immediately failed and cleaned up. Bind credential or factory construction failure returns to `Connected`; it never leaves the session in `Binding`.

`SmppTransport` is the narrow binary transport contract. Immutable configuration validates DNS, IPv4, and IPv6 hosts, port, TCP/TLS mode, connect/read/write timeouts, and a bounded maximum PDU size. TLS always enables peer and peer-name verification, SNI, a validated peer name, and TLS 1.2 or newer; verification cannot be disabled. Secure operation depends on a correctly configured system/PHP CA store and a PHP/OpenSSL build supporting TLS 1.2 or newer. `TcpTransport` uses injectable stream boundaries, complete bounded writes, exact fragmented reads under one monotonic header-and-body deadline, a four-byte length prefix check before body reads, categorized timeout/EOF/connection/I/O failures, cleanup on fatal failure, and repeatable disconnect.

The mapper uses an immutable policy for TON/NPI, service type, data coding, registered delivery, priority, schedule, validity, and replacement defaults. `short_message` is currently passed as its existing byte string with data coding zero and is limited to 254 bytes by the offline protocol engine. Segmentation and `message_payload` long-message support are not implemented; oversized payloads fail deterministically.

Runtime, session, and stream tests use deterministic repository, sleeper, session-port, connector, and stream fakes; no real SMSC is required. This milestone adds outbound execution only: it adds no reconnect or retry policy, DLR processing, `deliver_sm` or inbound routing, throttling, multi-request windowing, metrics, diagnostics, daemon command, process supervision, or persistent runtime process.

## Milestone 5B-5 — Deterministic SMPP runtime skeleton

Status: implemented for review; not complete until approval.

The runtime skeleton is a framework-free, single-iteration intent planner under `App\Infrastructure\Smpp\Runtime\Skeleton`. It consumes an immutable context plus at most one immutable outcome from a previously executed action, then returns the validated next context and ordered immutable intent actions. It never calls a lease repository, submission repository, mapper, encoder, timer, or transport. An external future executor may execute `AcquireLease`, `RenewLease`, `ClaimSubmissions`, `MapSubmission`, `EncodePdu`, `ReleaseLease`, `Sleep`, and `Stop` intents and return their typed outcomes. Actions carry only the minimum immutable inputs; encoded binary is never retained by the runtime.

`RuntimeConfiguration` validates provider, lease duration in milliseconds, claim batch size, poll interval in milliseconds, and idle interval in milliseconds. Completed work emits the poll delay. Empty queues and unavailable or lost leases emit the idle delay. `Sleep` remains intent data only and invokes no timer. The injected `RuntimeClock` is the only runtime time source.

Owned transitions validate that the lease provider and opaque owner match the configured runtime, that renewal retains the expected generation, and that the lease has not expired at the injected observation time. Claimed snapshots must carry the same provider, owner, generation, and claimed status. Mapping and encoding failures return a typed `RuntimeFailure`, retain valid ownership, discard only the failed item from the in-memory work list, and do not consume its sequence number. The skeleton neither retries nor mutates durable recovery state; recovery of already-claimed submissions belongs to a later execution/recovery milestone.

Legal lifecycle transitions are:

| Current state | Legal next states |
| --- | --- |
| `AcquiringLease` | `AcquiringLease`, `Owned`, `Stopping` |
| `Owned` | `Owned`, `LeaseLost`, `Stopping` |
| `LeaseLost` | `LeaseLost`, `AcquiringLease`, `Stopping` |
| `Stopping` | `Stopping`, `Stopped` |
| `Stopped` | `Stopped` |

Lease loss is returned as an observable `LeaseLost` context before reacquisition. A stop request first creates `Stopping`; held ownership emits `ReleaseLease`, and either release success or a typed release failure then emits `Stop` and enters `Stopped`. Missing, out-of-order, mismatched, expired, or unexpected outcomes throw `RuntimeTransitionException` rather than assembling an invalid context.

This milestone is orchestration structure only. It opens no socket, performs no TCP or TLS operation, executes no bind or `submit_sm`, and adds no transport write, reconnect, keepalive, retry, DLR handling, inbound routing, diagnostic, metric, service command, persistent process, or Laravel facade dependency.

## Milestone 5B-4 — Offline SMPP protocol engine

The offline protocol engine is a framework-free serialization boundary under `App\Infrastructure\Smpp\Protocol`. Immutable headers, TLVs, and command objects represent `bind_transceiver`, `bind_transceiver_resp`, `submit_sm`, `submit_sm_resp`, `enquire_link`, `enquire_link_resp`, `unbind`, `unbind_resp`, and `generic_nack`. A command registry owns command-ID-to-class resolution, keeping encoder and decoder dispatch table-driven rather than dependent on large command switches.

`PduEncoder` serializes network-order unsigned integers, null-terminated C-Octet strings, TLVs, command bodies, and the sixteen-byte SMPP header into binary strings. Field-specific C-Octet limits include the terminating NULL and are enforced using byte counts. `submit_sm.sm_length` is always derived from the binary payload and accepts 0–254 octets. `PduDecoder` validates the complete declared packet length, supported command, bounded field terminators, response status/body combinations, and remaining bytes before reconstructing immutable protocol objects. Truncation, malformed or overlong C-Octet strings, malformed TLVs, invalid lengths, unsupported commands, invalid registry construction, and integer overflow produce protocol-specific exceptions.

The closed command registry rejects duplicate or mismatched registrations and admits only PDU classes with both encoder and decoder support. Immutable PDU construction validates the command header, shared body-derived command length, octet ranges, field limits, TLV collection, and success/error response identifier rules. Unknown valid TLVs and duplicate TLV tags remain uninterpreted and are preserved byte-for-byte in wire order; provider or tag semantics belong to a later layer. The deterministic in-memory sequence generator covers the full positive `uint32` range, starts at one, and wraps from `UINT32_MAX` to one without persistence, shared-concurrency guarantees, or runtime dependencies.

The engine requires a 64-bit PHP runtime with `PHP_INT_SIZE >= 8` so every unsigned 32-bit SMPP header and sequence value through `UINT32_MAX` is representable as a PHP integer.

This boundary performs serialization only. It creates no socket, initiates no TCP or TLS connection, executes no bind or submission, and introduces no runtime, dispatcher, lease behavior, retry, keepalive, delivery receipt, inbound routing, metric, or diagnostic behavior. A future runtime may consume these protocol objects through a transport boundary, but transport ownership and network I/O remain separate.

## Milestone 5B-3 — Durable SMPP submission handoff

The transactional outbox publisher now validates accepted outbound SMS messages and enqueues immutable, encrypted-at-rest submission records instead of opening a provider connection or dispatching SMPP traffic. The database repository is the ownership boundary: publishers only enqueue `pending` records, while a future lease-owning runtime may atomically claim ordered batches as `claimed`, recording its opaque owner token and lease generation.

Claims use authoritative database UTC time. In one transaction and connection, the repository first locks the provider lease row and requires an unexpired exact owner-and-generation match; missing, stale, or expired ownership returns an empty rejection result without touching submissions. Only after lease validation does it lock pending rows in deterministic descending-priority, ascending-availability, ascending-identifier order. This lease-row-then-submission-row lock order fences stale runtimes while avoiding inverted ownership locks. This milestone contains no socket, bind, SMPP PDU, runtime loop, recovery, timeout, retry, delivery receipt, or terminal submission state.

## Milestone 5B-2 — YAS SMPP session lease

Milestone 5B-2 adds only logical lease ownership for the future persistent YAS session. It does not yet guarantee exactly one live SMPP socket; physical session fencing requires the later runtime to close on ownership loss and verify the generation around operational work. One `smpp_session_leases` row is permitted per logical provider. Acquisition, renewal, release, and expired takeover execute in short database transactions and lock the provider row with `SELECT ... FOR UPDATE`. A unique provider constraint closes the initial-row race, while an opaque owner token and monotonically increasing generation fence stale database mutations.

MySQL `UTC_TIMESTAMP(6)` is the authoritative clock inside each locked transaction. Callers provide only a validated duration; they cannot supply observation or expiry timestamps. Release expires the existing row instead of deleting it, preserving its generation for the next takeover. Renewal and release require both the exact owner token and generation; renewal also rejects an expired lease, and repeated release returns false. Immutable snapshots expose provider, generation, and lifecycle times while retaining the lease-owner token protections established in 5B-1. `current()` is observational only and grants no ownership. This phase adds no runtime process, socket, protocol engine, event loop, keepalive, reconnect, publisher, submission, inbound, DLR, or diagnostic behavior.

## Milestone 5B-1 — YAS SMPP runtime foundation

Milestone 5B-1 defines only the inert type and configuration foundation for the future persistent YAS runtime. `YasSmppRuntimeConfiguration` is immutable, rejects missing, coerced, out-of-range, or internally inconsistent runtime timing values with the fixed `yas_runtime_configuration_invalid` category, and prohibits cloning or serialized reconstruction. The enabled environment flag accepts only actual booleans or exact lowercase `true` and `false`; aliases, numeric forms, whitespace, and mixed casing fail closed. Provider-dependent timing values have no application defaults and must be supplied explicitly after operational confirmation with YAS. Resolving the configuration always requires every timing value, including when runtime activation is disabled; Laravel registers it lazily so normal disabled application boot does not resolve it.

The foundation also defines transport-lifecycle enums, validated framework-free identifiers and counters, an exact-read/exact-write duplex transport contract, and deliberately unbound repository boundaries. It adds no database schema, repository implementation, lease behavior, process, command, socket, receive loop, submission handoff, inbound persistence, keepalive, reconnect, or diagnostic behavior. DLR interpretation and message-state projection remain outside Milestone 5B.

## Milestone 5A — YAS SMPP Operational Hardening

All YAS entry points resolve one immutable validated connection configuration before constructing a transport. Operational dispatch, the diagnostic SMS command, and the diagnostic bind command therefore share the exact `transceiver`, `TR`, SMPP 3.4 (`0x34`), source TON/NPI `5/0`, destination TON/NPI `2/1`, endpoint, credential, timeout, and single-session requirements. Invalid configuration produces only the fixed `yas_configuration_invalid` category and cannot reach a socket. `registered_delivery` is explicitly encoded as `0x00` or `0x01` for each newly constructed short-lived client.

The configuration cannot be cloned or reconstructed from serialized state, and a missing secret-vault entry fails with a fixed sanitized category. Its raw client-settings accessor remains public only because PHP has no package-private visibility across the YAS configuration and SMPP adapter namespaces; it is marked internal and is consumed only by `Php8SmppClientAdapter` during immediate vendor-client construction. Provider-neutral capability redesign remains deferred.

Read and write timeouts are bounded and applied to the vendor socket transport. `connect_timeout` is retained and validated only as a deferred compatibility setting: the current vendor builder does not provide a distinct enforceable TCP connection timeout, and Milestone 5A does not claim otherwise. A future non-vendor wrapper may implement that boundary without editing vendor code.

YAS permits exactly one simultaneous SMPP session per System ID. Configuration fixes `max_sessions` at `1`, but Milestone 5A intentionally adds no pooling, distributed lock, or session coordinator. Operators must not run manual diagnostics concurrently with operational publication for the same System ID. Operational activation remains disabled by default.

The current adapter still opens, binds, submits once, unbinds, and closes. The transceiver session has no receive loop and cannot receive later DLRs. Long-lived sessions, `readSMS()`, background listeners, DLR parsing/persistence, callbacks, webhooks, and message-state updates remain deferred to Milestone 5B.

## Milestone 4 — Internal Prepaid Wallet and Ledger Foundation

Milestone 4 introduces one tenant-owned wallet per currency, a message-linked reservation lifecycle, and an immutable balanced four-account subledger. `available_balance_minor` is spendable prepaid value and `reserved_balance_minor` holds active message reservations; both are non-negative integer projections maintained in the same transaction as the immutable ledger. The ledger remains the accounting source of truth. Its fixed accounts are `funding`, `available`, `reserved`, and `consumed`; credit, reserve, capture, and release each create exactly two non-zero entries whose signed deltas sum to zero.

`WalletAccountingService` is the only application mutation boundary. Every operation rejects an unsafe surrounding transaction and uses one short transaction with the universal lock order wallet, reservation, message, then ledger/idempotency records. Credit and reserve require an active wallet. A frozen wallet rejects new credits and reservations but permits capture or release of an existing active reservation; a closed wallet permits no financial mutation. The reservation is the sole financial bridge to a message, and ledger transactions refer to that reservation rather than directly to messages.

Every operation requires a dedicated wallet idempotency HMAC key. The raw key is never persisted or logged. A hash of the operation and key provides operation-aware uniqueness, while a separate HMAC fingerprint of immutable request facts distinguishes an identical retry from conflicting reuse. Financial failures roll back the wallet projection, reservation, ledger, message billing projection, message event, and minimal audit evidence together.

Wallet request DTOs keep the raw idempotency key inside a private closure-backed reader and expose only explicit key-free JSON, array, and debug representations. A unique-key exception rolls back the attempted operation before a tenant/wallet/operation/key-hash recovery query loads the committed winner; an identical fingerprint returns that winner, a different fingerprint raises a sanitized conflict, and a unique violation without a matching winner becomes a sanitized accounting invariant failure. Reserve validates both available sufficiency and reserved-balance overflow before inserting any reservation or ledger row.

`WalletReconciliationService` is tenant-scoped and read-only. It derives available and reserved balances from their signed ledger accounts, total credits as the negated funding sum, total consumption from the consumed sum, and active holds from active reservations. It also verifies exactly two balanced entries and the approved account pair and signs for every transaction. It performs no repair. Wallet and reservation models reject direct Eloquent changes to protected financial fields; the accounting service uses tenant-qualified internal query updates while privileged SQL remains an operational database-permission boundary.

Milestone 4 does not activate charging in the dispatcher. It does not change the dispatcher, provider, SMPP, outbox, route, controller, or public API behavior. Existing messages remain billing `pending` unless an internal accounting operation is explicitly invoked. The future boundary remains: approved price, reserve, provider dispatch outside transactions, capture confirmed acceptance, release definitive pre-transmission failure, and retain the reservation for an ambiguous transmitted outcome.

## Milestone 3C — Dispatch Persistence and YAS Activation

Operational dispatch is split into three phases and rejects entry whenever the active database transaction level is non-zero. `DispatchPersistenceService::beginDispatch()` uses a short row-locked transaction to revalidate ownership and dispatch eligibility, reject an existing pending/submitting attempt, allocate the next attempt number, create a token-owned submitting attempt, move the message to processing, and append `message_dispatch_started`. The YAS callback runs only after that transaction commits and a second absolute-zero transaction-level assertion passes. `finalizeDispatch()` uses a separate row-locked transaction and requires matching tenant, message internal/public IDs, attempt internal/public IDs, submitting status, and dispatch token before atomically updating the attempt, message, and append-only event.

The dispatcher remains channel-neutral. A channel request factory owns transient payload construction, the resolver selects an explicitly registered application provider, and `YasMessagingProvider` maps the SMS request to the existing transport-only `YasSmppProviderAdapter`. The manual SMPP command continues to use the adapter's existing response contract. Provider outcomes map to accepted/submitted/pending, rejected/failed/rejected, unknown/submitted/unknown, or unavailable/failed/unknown. Billing is unchanged. Accepted, rejected, and unknown are completed outbox callbacks; unavailable fails the outbox callback. No automatic retry or stale-attempt recovery is introduced.

The manual validation command and operational YAS wrapper share `SmsAddress::originator()` and `SmsAddress::internationalRecipient()` at the SMPP boundary. Senders are boundary-trimmed, case-sensitive, limited to eleven ASCII letters, digits, spaces, dots, or hyphens, and retain configured TON/NPI. International recipients require canonical E.164-style `+` input with 8–15 digits and a non-zero country-code start; the business value remains unchanged in persistence, while the outbound SMPP destination removes the leading `+`.

Runtime activation fails closed. `YAS_OPERATIONAL_DISPATCH_ENABLED` defaults to false; YAS is registered and the dispatcher outbox bridge replaces `DisabledOutboxPublisher` only when the flag is true and the YAS name, hosts, port, credentials, transceiver bind mode, SMPP 3.4 interface version, `TR` system type, required TON/NPI values, and positive timeouts are valid. Provider codes are normalized lowercase tokens and remain immutable per attempt. Persisted provider references and response codes are bounded protocol-safe tokens; failure categories and connection states use fixed allowlists. Source paths, line numbers, raw exception text, recipient, and message body are excluded from results, normal logs, attempts, events, and outbox records.

All four provider submission outcomes are terminal for the attempt submission lifecycle and set `completed_at`. Confirmed acceptance also sets both `submitted_at` and `accepted_at`; later delivery reporting remains a separate future DLR lifecycle.

## Milestone 3B — Message Dispatcher

Milestone 3B adds a channel-neutral, persistence-free dispatch boundary. `MessageDispatchOutboxPublisher` is an opt-in `OutboxPublisherInterface` bridge for canonical `Message` / `MessageAccepted` events. It treats the outbox row's `tenant_id` as authoritative and reads only `message_public_id` and `application_public_id` from the safe payload. The loader scopes message lookup by all three identifiers and returns the same not-found outcome for absent and ownership-mismatched records.

`MessageDispatcher` revalidates the current message rather than trusting historical acceptance: the message must remain outbound, non-deleted, accepted, supported, and accompanied by its channel payload. For SMS, randomized encrypted recipient and body columns are decrypted only while constructing a transient `SmsDispatchPayloadDTO`. That DTO is not serializable and must never be logged, persisted, placed in an outbox payload, or included in dispatcher results.

`ConfiguredProviderResolver` uses explicit channel-keyed application-provider registrations. It performs no discovery, routing, pricing, health, billing, network, or retry decisions and fails closed when registration is absent, conflicting, or channel-mismatched. The new dispatcher-facing provider contract remains channel-neutral and returns one typed outcome: `accepted`, `rejected`, `unknown`, or `unavailable`. Only confirmed `accepted` permits the outbox callback to succeed; every other result becomes a sanitized outbox failure.

The operational `OutboxPublisherInterface` binding remains `DisabledOutboxPublisher`, and the provider resolver has no runtime providers. YAS, SMPP, Airtel, Twilio, and placeholder adapters are not registered or activated. Dispatcher execution creates no attempts, status transitions, events, queue jobs, billing records, retries, or webhooks. A crash after external acceptance but before outbox finalization remains ambiguous, so this milestone makes no exactly-once claim.

## Milestone 3A — Transactional Outbox Publisher

`OutboxPublisherService` processes eligible pending outbox records one at a time in stable `created_at, id` order. It rejects invocation when the active database connection already has an open transaction, preserving an absolute zero-transaction callback invariant. A short claim transaction selects a record with a database row lock where supported, marks it `publishing`, assigns a unique claim token and lock timestamp, increments `attempts` exactly once for that callback invocation, and commits immediately.

The callback runs only after the claim transaction commits and while the active database transaction level is zero. A separate finalization transaction requires the event ID, `publishing` status, and matching claim token before it can mark the event published or failed. A token mismatch is reported separately as ownership loss, makes no database mutation, and causes the command to fail. Failure persistence is limited to the fixed `publisher_callback_failed` or `outbox_publisher_not_configured` category; exception messages and stack traces are never stored.

`gateway:publish-outbox` supports bounded processing with `--once` and `--limit`, plus read-only selection with `--dry-run`; `OUTBOX_PUBLISHER_COMMAND_MAX_LIMIT` configures its maximum and defaults to 100. Milestone 3A binds `DisabledOutboxPublisher` by default. It fails closed, marks only the first claimed event failed with `outbox_publisher_not_configured`, and makes the command report the missing operational publisher. Failed records are terminal and are not selected automatically. A `publishing` record abandoned by a crashed process is not reclaimed. Retry and stale-claim recovery require a later approved milestone.

SQLite tests verify state transitions and selection rules but cannot prove `SELECT ... FOR UPDATE` behavior. Dedicated MySQL integration tests use two independent connections and must be explicitly enabled against an already migrated database whose name clearly identifies it as test-only. They never migrate, truncate, or reset that database. Required environment variables are `OUTBOX_MYSQL_INTEGRATION_ENABLED=true`, `OUTBOX_MYSQL_INTEGRATION_HOST`, `OUTBOX_MYSQL_INTEGRATION_PORT`, `OUTBOX_MYSQL_INTEGRATION_DATABASE`, `OUTBOX_MYSQL_INTEGRATION_USERNAME`, and `OUTBOX_MYSQL_INTEGRATION_PASSWORD`; run them with `php artisan test --group=mysql-integration`.

No queue dispatch, provider selection, SMS sending, billing, retry, scheduling, DLR, webhook, worker, or API behavior is present; the callback boundary is preparation for Milestone 3B only.

## Milestone 2B — Message Submission API

The public SMS API is registered through Laravel 12's `bootstrap/app.php` routing configuration. Its enforced flow is `RequestContextMiddleware → TenantMiddleware → authenticated rate limiter → MessageController`. Request tracing never resolves ownership; tenant, application, and credential ownership comes only from the authenticated API key.

Thin controllers map validated requests into `CreateMessageDTO`/`CreateSmsMessageDTO` and typed `MessageListFiltersDTO` objects. `MessageCreationService` retains transactional creation responsibility. `MessageQueryService` performs explicit tenant-and-application retrieval and stable cursor listing without a repository abstraction. POST persistence creates an unpublished outbox record but dispatches no queue job and calls no provider.

Request correlation (`X-Correlation-ID`) and message/business correlation (`correlation_id`) are separate concepts. Public serialization uses explicit accepted and retrieval resources; message bodies, raw metadata, full recipients, hashes, ciphertext, ownership IDs, and provider data are excluded.

## 1. Overview

MHI Gateway is a multi-tenant communications platform for Micro Health Initiative. It will expose a single API for sending and tracking messages across multiple channels, including SMPP, WhatsApp, email, push notifications, USSD, and voice. The platform must be reliable, tenant-safe, auditable, billing-aware, and privacy-conscious from day one.

## 2. Architectural Principles

The implementation should follow the guidance in the repository while incorporating the approved architecture decisions:

- Use Clean Architecture with thin controllers.
- Keep business rules in services, DTOs, domain events, and jobs.
- Use repositories only where there is a meaningful abstraction or a reusable complex query; do not force repositories for every model.
- Derive tenant context from authenticated credentials, not from a caller-supplied tenant header.
- Separate provider definitions from concrete provider connections.
- Keep the main message entity channel-neutral and store channel-specific fields in separate records.
- Maintain separate message, delivery and billing statuses.
- Store money as integer minor units rather than floating-point values.
- Never expose secrets in logs, traces, or error payloads.

## 3. Architecture Summary

The system will be organized into four layers:

1. Presentation Layer
   - REST controllers
   - API middleware
   - request validation
   - response formatting
   - idempotency enforcement

2. Application Layer
   - message submission and orchestration use cases
   - wallet reservation and billing services
   - pricing and routing services
   - provider orchestration services
   - audit and notification services

3. Domain Layer
   - Tenant
   - Application
   - API Credential
   - Provider
   - ProviderConnection
   - Wallet
   - LedgerTransaction
   - PricingRule
   - RoutingRule
   - Message
   - DeliveryEvent
   - BillingEvent

4. Infrastructure Layer
   - Eloquent models and persistence adapters
   - queue workers
   - persistent SMPP workers
   - provider adapters
   - Redis cache and queue backend
   - MySQL persistence
   - transactional outbox publisher

## 4. Tenant, Application and Credential Hierarchy

The platform will use a clear hierarchy:

- Tenant: the top-level business or client account.
- Application: a logical integration within a tenant, such as a portal, mobile app, or partner integration.
- API Credential: the authenticated identity that resolves the tenant and application context at runtime.

Tenant context must be derived from the authenticated credential. The system must not trust a caller-supplied tenant header for authorization or scoping. This prevents privilege escalation and enforces strict multi-tenant isolation.

## 5. Provider and ProviderConnection Model

Providers describe vendors or adapters. They define the transport family and the capabilities of the downstream integration.

ProviderConnections contain the actual account configuration and operational details. A provider connection may be platform-owned or tenant-owned. This separation allows one provider definition to be reused by many tenants or business units while keeping credentials and routing specifics isolated.

Provider responsibilities:
- define the provider type and capabilities
- expose normalized interfaces for send and status operations
- report health and availability

ProviderConnection responsibilities:
- hold encrypted credentials and endpoint configuration
- represent tenant-specific or platform-specific account settings
- support connection management, health state and failover preferences

## 6. Message Model and Lifecycle

The main Message entity will remain channel-neutral. It will describe core message intent such as tenant ownership, sender context, recipient, content reference, correlation identifier and lifecycle state.

Channel-specific attributes will live in separate records, for example:
- SMS-specific payload and metadata
- WhatsApp-specific template or media fields
- email subject and attachments
- push notification payloads

The platform will use separate statuses for message, delivery and billing:

- Message status: received, accepted, queued, processing, sent, failed, cancelled, expired
- Delivery status: pending, submitted, delivered, failed, rejected, expired, refunded
- Billing status: pending, reserved, captured, released, reversed, refunded

A complete lifecycle will be tracked from received through delivered, failed or refunded. Every transition should produce an event to support auditing and reconciliation.

## 7. Wallet and Ledger Model

Wallets will be ledger-based and use immutable transactions. Each debit or credit is recorded as a ledger entry rather than updated in-place. The wallet model supports explicit fund operations:

- reservation: hold funds for a pending send
- capture: finalize funds when a message is accepted or sent
- release: return funds when a message is abandoned or no longer dispatchable
- reversal: undo a previously captured charge when a message fails or is refunded

This design ensures that billing remains deterministic, auditable and resistant to double-spending.

## 8. Routing Domain

The platform will include a routing domain that evaluates tenant rules, destination and network routing, provider priority, cost, health and failover before message dispatch.

Routing capabilities include:
- tenant-specific routing rules and channel restrictions
- destination and network-based routing decisions
- provider priority and cost-based selection
- health-based routing and failover logic
- deterministic provider selection while preserving retries and contingency paths

## 9. API Design and Idempotency

The API should remain RESTful and versioned under /api/v1. Every submission endpoint must support idempotency to prevent duplicate message sends during retries, reconnects or client retries.

Requirements:
- clients provide an idempotency key with each send request
- the platform stores a deduplication record for the key and message fingerprint
- duplicate submissions return the original result rather than creating a second message
- idempotency must be enforced across retries and queue replays

## 10. Queue Architecture

The platform should use Laravel queues backed by Redis for asynchronous processing. Queues must be separated by priority and workload:

- transactional: critical application writes and message acceptance
- critical: high-priority dispatches and urgent retries
- bulk: non-urgent batched sends
- DLR: delivery receipt processing
- webhooks: provider callback handling
- billing: ledger updates and reconciliation
- health checks: provider monitoring and connectivity probes

The system should also implement the transactional outbox pattern so that database changes and queue publication are reliably coordinated. This prevents message acceptance from being committed without the corresponding outbox event being published.

## 11. Persistent SMPP Worker Design

SMPP integration should use persistent worker processes rather than creating a new bind for every outgoing SMS.

Worker responsibilities:
- maintain a long-lived SMPP connection
- bind once and keep the session alive
- submit_sm for outbound messages
- deliver_sm for inbound traffic
- enquire_link to keep the connection healthy
- reconnect automatically after connection loss
- shut down gracefully on termination or deployment events

For Windows development, the confirmed working configuration is BlockingReadStrategy. This should be the documented development runtime setting for local SMPP workers.

## 12. Folder Structure

A recommended Laravel folder structure is:

```text
app/
  Console/
    Commands/
  Domains/
    Messaging/
      DTOs/
      Services/
      Events/
      Jobs/
      Contracts/
    Billing/
      Services/
      DTOs/
      Events/
    Tenancy/
      Middleware/
      Services/
    Routing/
      Services/
      Rules/
  Http/
    Controllers/
    Middleware/
    Requests/
  Models/
  Providers/
  Support/
config/
database/
  migrations/
  seeders/
resources/
  views/
routes/
storage/
tests/
```

This structure keeps domain logic separate from HTTP controllers while allowing targeted use of repositories only when they add meaningful abstraction.

## 13. Observability and Operations

The platform must provide strong operational visibility:

- request IDs for every API request
- correlation IDs across message submission, queue processing and provider callbacks
- provider health metrics, latency, errors and connection state
- structured logs with tenant and message correlation context
- alerting for failed dispatches, wallet imbalance, provider outage and retry storms

## 14. Privacy, Retention and Compliance

The platform must meet privacy and compliance expectations:

- mask sensitive recipient and credential data in logs and responses
- enforce message retention policies based on tenant and regulatory requirements
- support opt-out handling for communication preferences
- preserve audit trails for billing, routing decisions, provider interactions and message state changes
- avoid retaining sensitive data longer than necessary

## 15. Security Architecture

Security is built around tenant isolation, resource scoping and secret handling:

- API authentication through bearer tokens or signed API keys
- role-based permissions per tenant and application
- tenant-aware service and data access rules that enforce strict scoping
- encrypted storage of provider credentials and connection secrets
- no secrets in logs or exceptions
- TLS for external traffic and signed webhook verification for callbacks

## 16. Deployment Model

A production deployment should use:

- Nginx as the web server in front of PHP-FPM
- PHP-FPM for the Laravel application runtime
- Redis for cache, session handling and queue backend
- MySQL for transactional data
- queue workers for the separated workload queues
- persistent SMPP workers for long-lived gateway connections
- scheduler for recurring jobs and maintenance tasks
- object storage for attachments, exports and long-lived payloads

php artisan serve is development-only and should not be used as the production runtime.

## 17. Testing Strategy

The documentation proposal assumes a layered testing strategy:

- unit tests for pricing, wallet reservation, routing and provider normalization
- feature tests for API flows, authentication and tenant isolation
- integration tests for provider adapters, queue workflows and SMPP workers
- contract tests for downstream provider responses and webhook handling
- smoke tests for deployment readiness and operational health

## 18. Expected Outcome

The resulting MHI Gateway platform will provide a secure, scalable, tenant-aware messaging backbone that supports multiple channels through one consistent API, while preserving clear separation between transport logic, billing, routing and tenant operations.

## 19. Milestone 0 Live SMS Submit Validation

The manual `gateway:sms:send-test yas` command validates one live, short GSM-compatible SMS through the confirmed YAS transceiver connection. The command depends explicitly on the YAS provider adapter; YAS is not globally bound as the default messaging provider. The adapter binds and submits through `SmppConnectionManager`, maps the transport-level result to `ProviderResponseDTO`, and closes the session after each manual validation.

The installed `php8-smpp/php8-smpp` v0.1.1 package supports concatenation for messages beyond its single-message limits and supports Unicode when UCS-2 coding is selected explicitly. It does not automatically select Unicode coding. This validation deliberately accepts only one GSM 03.38-compatible message of at most 160 encoded octets, uses no custom splitting, and does not exercise the package's Unicode or concatenation paths.

The validation preserves transceiver binding, system type `TR`, interface version `0x34`, Windows `BlockingReadStrategy`, YAS source TON/NPI `5/0`, and destination TON/NPI `2/1`. It introduces no persistence, queue, API, route, controller, billing, tenant authentication, or delivery-receipt receiver.

## 20. Milestone 1 Tenant Context

Public tenant context is derived only from a verified API credential. Public middleware parses a Bearer API key, performs a unique prefix lookup, verifies the secret hash, validates credential, application, and tenant state, and binds an immutable request-scoped `TenantContext`. The context is cleared in a `finally` block.

Public middleware depends directly on `ApiKeyTenantResolver`. `HeaderTenantResolver` is a separate internal-only component, is disabled by default, requires a trusted authenticated guard, and cannot be selected by caller input.

Tenant-owned access uses explicit local scopes and service-layer tenant identifiers. No automatic global tenant scope is installed. Milestone 1 authorization uses fixed role and permission enums with tenant membership assignments and an explicit global Platform Admin role; runtime-editable roles and a permissions UI remain out of scope.

## 21. Milestone 2A Messaging Domain Foundation

Milestone 2A introduces persistence for a channel-neutral `Message` aggregate and its SMS payload, submission attempts, append-only lifecycle events, and transactional outbox record. `MessageCreationService` verifies tenant/application/credential ownership and creates the message, SMS record, initial `message_accepted` event, and `MessageAccepted` outbox event in one database transaction. It does not dispatch a queue job or select a provider.

Message, delivery, billing, and attempt states are separate PHP string-backed enums persisted in VARCHAR columns. Attempt state describes submission only; delivery state contains no `submitted` state. Provider selection remains a future concern, so `message_attempts.provider_connection_id` is nullable, is not assigned in this milestone, and has no foreign key until provider-connection persistence is introduced.

Explicit `forTenant()` local scopes remain mandatory; no hidden global tenant scope is used. Tenant IDs on SMS records, attempts, and message events are intentional denormalization backed by composite foreign keys to `(messages.tenant_id, messages.id)`.

The creation transaction uses concrete Eloquent persistence because no interchangeable repository or reusable complex query currently justifies a repository abstraction. Recorder contracts provide clean transaction-bound collaboration and allow failure testing without production test branches.
