@onlineapps/mq-client-core 4.2.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,17 +36,21 @@ const BaseClient = require('@onlineapps/mq-client-core');
36
36
  const mqClient = new BaseClient({
37
37
  type: 'rabbitmq',
38
38
  host: 'amqp://localhost:5672',
39
+ serviceName: 'api_gateway', // REQUIRED unless ENV SERVICE_NAME is set
39
40
  logger // REQUIRED: info/warn/error/debug
40
41
  });
41
42
 
42
43
  await mqClient.connect();
43
44
  await mqClient.publish('workflow.init', { workflowId: '123', data: '...' });
44
- await mqClient.publish('workflow.control', { workflowId: '123', data: '...' });
45
+ await mqClient.publish('workflow.completed', { workflowId: '123', data: '...' });
45
46
  ```
46
47
 
47
48
  ### Configuration
48
49
 
49
- **Required:** `type`, `host`, `logger`. Every other key is optional — but it has to
50
+ **Required:** `type`, `host`, `logger`, and `serviceName` — the explicit key, or ENV
51
+ `SERVICE_NAME`. The library invents no name: a client with neither is refused at
52
+ construction (d.770), because a default would file every monitoring event, and name every
53
+ queue and connection, under a service that does not exist. Every other key is optional — but it has to
50
54
  be a key this client declares: a name nothing declares is **refused at construction**,
51
55
  named, together with the declared key it is closest to. There is no "anything else"
52
56
  door (`src/config/configSchema.js`, `additionalProperties: false`). A key the client
@@ -59,6 +63,7 @@ package.
59
63
  type: 'rabbitmq', // Required
60
64
  host: 'amqp://...', // Required
61
65
  logger, // Required: info/warn/error/debug
66
+ serviceName: 'billing', // Required unless ENV SERVICE_NAME; names the connection and every event
62
67
  exchange: '', // Optional exchange
63
68
  durable: true, // Optional (default: true)
64
69
  prefetch: 1, // Optional (default: 1)
@@ -81,11 +86,18 @@ says" — and every connection on the platform ran on the broker's number rather
81
86
  the configured one (measured on the dev broker: 39 live connections, `timeout: 60`
82
87
  on all of them).
83
88
 
84
- `brokerAnswerTimeout` bounds **both** things this client waits for the broker to
85
- answer: the confirm of one publish attempt, and the `close-ok` of each handle
86
- `disconnect()` closes. It was called `publishConfirmationTimeout` until
87
- 2026-09-14 — the name of one of its two consumers — and writing that name now is
88
- refused, with the new one named.
89
+ `brokerAnswerTimeout` bounds **every** wait on the broker's answer — the confirm of
90
+ one publish attempt, the `close-ok` of each handle `disconnect()` closes, and every
91
+ request a channel sends and waits on: `assertQueue`, `checkQueue` (also the check before a
92
+ queue publish), `bindQueue`, `assertExchange`, `purgeQueue`, `deleteQueue`, `consume`,
93
+ `cancelConsumer`, the prefetch, and every channel open (conf `mq-broker-answer-budget` 001).
94
+ A request left unanswered for that long discards the channel that carried it (the next
95
+ operation opens a fresh one) and ends in `ConnectionError` naming the operation, its target
96
+ and the budget; on the publish path it is `TransientPublishError`, so retry, the buffer and
97
+ `bufferOnFailure: false` apply as to any transient failure. This is what ends the wait on a
98
+ broker in a memory alarm, which keeps the connection and its heartbeats alive while it
99
+ answers nothing. It was called `publishConfirmationTimeout` until 2026-09-14 — the name of
100
+ one of its consumers — and writing that name now is refused, with the new one named.
89
101
 
90
102
  `maxReconnectAttempts` and `maxReconnectCycles` resolve the usual way — explicit
91
103
  config → env (`RABBITMQ_MAX_RECONNECT_ATTEMPTS`, `RABBITMQ_MAX_RECONNECT_CYCLES`)
@@ -114,7 +126,7 @@ The library owns NO logger. `logger` is required and validated in the
114
126
  logger fails the constructor, it does not fall back to `console`.
115
127
 
116
128
  The same object is forwarded to everything the client builds — transport,
117
- publish layer, recovery worker, buffers — so one service configures one
129
+ publish layer, buffers — so one service configures one
118
130
  channel and the whole client writes there. Levels: `info` for lifecycle
119
131
  (connected, consumer registered, channel recovered), `debug` for
120
132
  per-message and setup detail, `warn`/`error` for the rest. An error
@@ -129,7 +141,15 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
129
141
 
130
142
  - `connect(options?)` - Connect to RabbitMQ
131
143
  - `disconnect()` - Disconnect from RabbitMQ
132
- - `publish(queue, message, options?)` - Publish message to queue
144
+ - `publish(queue, message, options?)` - Publish message to queue. `options.bufferOnFailure`
145
+ (boolean, default `true`): `false` leaves the publish buffer for this one call — a
146
+ transiently failed message is NOT held and no reconnect replays it; the thrown
147
+ `PublishError` then carries `buffered: false` (see § The publish buffer).
148
+ - `publishTelemetry(queue, message, options?)` - Publish the client's OWN event (the
149
+ monitoring helpers below use it) on a confirm channel of its own, opened with the
150
+ first such publish. A refusal there closes that channel only, never the one a
151
+ business message is waiting on (d.724). The channel is chosen by this call, never by
152
+ the queue name.
133
153
  - `consume(queue, handler, options?)` - Consume messages from queue; resolves with
134
154
  the broker's consumer tag. A failing handler is answered by the delivery policy,
135
155
  never by a bare requeue — see § `consume()` — the delivery contract.
@@ -184,7 +204,10 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
184
204
  `flushed`, plus a per-queue breakdown; the second renders the same counters as
185
205
  Prometheus text.
186
206
  - `getChannelState()` / `getConsumerState()` / `getBufferState()` - Liveness of the
187
- three channels, the consumers this client holds, and what is waiting in the
207
+ channels (`publisher`, `queue`, `consumer` and `telemetry`, each `{ exists, closed,
208
+ ready }`; `telemetry` opens with the first telemetry publish and reads
209
+ `{ exists: false, closed: true, ready: false }` until then — d.1007), the consumers
210
+ this client holds, and what is waiting in the
188
211
  publish buffer (`{ size, inMemory }` — there is one buffer). All five refuse
189
212
  before `connect()` has built a transport, rather than answering with an invented
190
213
  zero. `getConsumerState()` answers `{ tracked, active }`: `tracked` counts the
@@ -193,7 +216,9 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
193
216
  this client takes no message off some queue it registered.
194
217
  - `disconnectAll()` (static, on the class and on the package export) - Close every
195
218
  client this module holds; see § Shutdown.
196
- - `onError(callback)` - Register error handler
219
+ - `onError(callback)` - Register error handler for connection and channel faults. A
220
+ failed publish is NOT reported here: its verdict is the rejection of `publish()`
221
+ (d.811b).
197
222
  - `onFatal(callback)` - Register handler for the permanent loss of the
198
223
  connection (see § Connection loss)
199
224
 
@@ -299,6 +324,13 @@ const { deliveryPolicy } = require('@onlineapps/mq-client-core');
299
324
  deliveryPolicy.ATTEMPTS_HEADER; // 'x-oa-delivery-attempts' — read it, never retype it
300
325
  ```
301
326
 
327
+ **The option rules have one owner, and it is exported.** `consume()` validates its options
328
+ through `deliveryPolicy.validateConsumeOptions(options, resolveDefaultMaxAttempts)`, which
329
+ returns `{ maxAttempts, classify }` or throws the `ValidationError` `consume()` throws, in
330
+ the same words. A consumer's test double of `consume()` calls it instead of copying the
331
+ rules. `resolveDefaultMaxAttempts` is a function, called only when the options leave the
332
+ budget open (d.1005).
333
+
302
334
  **The `message_dlq` event** goes to `monitoring.workflow` and carries `status: 'rejected'`
303
335
  — the broker's own word for this death, what it stamps into `x-death[0].reason`. It is
304
336
  published **only when the envelope carries a `workflow_id`**. Without one nothing is
@@ -308,6 +340,11 @@ queue through the DLQ dashboard. `monitoring.workflow` keys every trace on that
308
340
  event naming no workflow is either refused as faulty telemetry or — with a marker like
309
341
  `'unknown'` in its place — written as a row claiming a workflow by that name.
310
342
 
343
+ It also names WHAT died: `operation`, written **only when the envelope carried one** — the
344
+ invocation envelope has the field, the workflow message never does — and left out entirely
345
+ otherwise, with no `unknown` and no `null` standing in for it. The run's identity stays one
346
+ key, `workflow_id`; no event on `monitoring.workflow` carries a `correlation_id`.
347
+
311
348
  **`x-message-ttl` and the budget do not cooperate.** The service queues carry a TTL
312
349
  (`<service>.queue` 30 s, `<service>.workflow` 5 min) and each attempt re-publishes the
313
350
  message, which restarts that clock. So the TTL bounds ONE attempt, never the whole budget,
@@ -380,7 +417,8 @@ exponential backoff, up to `maxReconnectAttempts` times. That whole budget is on
380
417
  where the socket is already back.** "Connected" means the client can be used, and a
381
418
  re-established connection whose channels are still being recreated cannot publish or
382
419
  consume. The two answers therefore agree at every instant: the moment `isConnected()`
383
- turns `true`, `getChannelState()` reports all three channels ready.
420
+ turns `true`, `getChannelState()` reports the publisher, queue and consumer channels ready
421
+ (the telemetry channel is opened on demand, so it may still read as not opened).
384
422
 
385
423
  **Standing down is not dying, and the difference is the point.** An outage the
386
424
  broker will come back from must not turn a client into a corpse
@@ -507,9 +545,12 @@ A missing queue is answered by who owns the name, not by the client's scope:
507
545
  Publishing must never create an owned queue: `sendToQueue()`/`assertQueue()` would
508
546
  declare it with default arguments — no TTL, no DLQ — and the owner's later
509
547
  `setupServiceQueues()` then fails with 406 `PRECONDITION_FAILED` because the
510
- arguments no longer match. The rule is enforced in two places, and both are
511
- required: the 404 branch of `_publishOnce()` refuses the publish, and
512
- `RecoveryWorker.handleQueueNotFound()` refuses to create the queue behind it.
548
+ arguments no longer match. The rule is enforced in ONE place: the 404 branch of
549
+ `_publishOnce()` refuses the publish and declares nothing, and the caller gets the
550
+ verdict as the rejection — `PublishError` with the `QueueNotFoundError` as its
551
+ `cause` (`queueName`, `kind`, and per class the owner and the Fix). Nothing else
552
+ answers a missing queue: the worker that once did, from the client's `error`
553
+ channel, is gone (d.971).
513
554
 
514
555
  Which of the three sentences the refusal carries is decided by the ownership class
515
556
  in the error (`kind`), which the central declaration answers — never by how the
@@ -530,15 +571,49 @@ configured publishers declared one exchange two ways, and a client whose
530
571
  that was itself faultless. Measured against the live broker: `inequivalent arg
531
572
  'type' … received 'direct' but current is 'fanout'`. An exchange nobody declared
532
573
  is now a broker-side 404 on the publish, not an exchange invented by whoever
533
- published first — the same rule as for queues, one object up.
574
+ published first — the same rule as for queues, one object up. The publish answers it
575
+ as a `PermanentPublishError` with the broker's frame in `cause`: one attempt, nothing
576
+ buffered, because no retry and no reconnect declares the exchange (d.693).
534
577
 
535
578
  ### The publish buffer — one buffer, every priority
536
579
 
537
580
  A publish that fails transiently (the connection is closing, a channel is being
538
581
  recreated, the broker did not confirm in time) is held in memory — bounded by
539
582
  `publishBufferMaxSize` messages and `publishBufferTtlMs` each — and replayed after
540
- the reconnect, critical priority first. `getBufferState()` reports it and
541
- `publish:buffered` / `buffer:flush:failed` announce it.
583
+ the reconnect or after the broker unblocks the connection (below), critical priority first. `getBufferState()` reports it and
584
+ `publish:buffered` / `buffer:flush:failed` announce it. That includes a publish whose
585
+ confirm was still pending when the channel or the connection died under it (the broker
586
+ closed it, restarted or stopped): the broker said nothing about that message, so it is
587
+ transient, never a refusal — only a `nack` or a numbered close frame naming this send
588
+ is permanent (d.1134).
589
+
590
+ **One call can leave the buffer: `publish(queue, message, { bufferOnFailure: false })`.**
591
+ It is for a caller that keeps the message durably elsewhere and must not have a second
592
+ copy replayed behind its back — the orchestrator writing a `publish_pending` record
593
+ (`docs/governance/confirmations/step-publish-failure.md` 001). On each of the three
594
+ transient paths (retries exhausted, `publishRetryEnabled: false`, the reconnect a retry
595
+ waited for failed) the error `publish()` throws carries `buffered` (boolean): `true` only
596
+ when the buffer holds the message for the next reconnect, `false` when nothing will
597
+ replay it. A permanent refusal never reaches the buffer and carries no `buffered`. A
598
+ value that is not a boolean is refused before any attempt (`ValidationError`). Retry
599
+ stays the library's either way — the option decides only what happens after it.
600
+
601
+ **Every failed publish says whether its frame left the client: `error.sent`** (boolean, on
602
+ the `PublishError` from `publish()` and on its cause; conf
603
+ `docs/governance/confirmations/mq-publish-buffer.md` 001). `true` when any attempt wrote the
604
+ frame to the channel — the broker may hold it, and after a memory alarm deliver it, although
605
+ the call failed; `false` when no attempt got that far. It says nothing about whether the
606
+ broker stored the message. The buffer entry records the same fact, and it decides what
607
+ happens when the outage ends:
608
+
609
+ | The outage ends with | A held message whose frame was never sent | … whose frame was sent |
610
+ |---|---|---|
611
+ | a reconnect (`reconnected`) | re-sent | re-sent — the old socket and what the broker had not read are gone |
612
+ | the broker unblocking the connection (`connection.unblocked`, end of a memory or disk alarm) | re-sent | dropped with a `warn` — the broker delivers the frames it held by itself |
613
+
614
+ `connection.blocked` is logged as a `warn` with the broker's reason. A held message that
615
+ outlives `publishBufferTtlMs` is logged as an `error` naming its queue or exchange, the
616
+ count, the age and `sent` — `sent=false` is a message lost for good.
542
617
 
543
618
  **There is no persistent buffer, and `persistentBufferEnabled` /
544
619
  `persistentRedisClient` are refused by name.** They declared one until
@@ -605,6 +680,26 @@ it did — every refusal wore the sentence "Expected: the queue to exist before
605
680
  attaches to it", which for a queue with no declared route is false in every clause and
606
681
  sent the reader to fix something that was not broken.
607
682
 
683
+ **The monitoring publish helper asks the same question.**
684
+ `publishToMonitoringResilient()` never throws: a broker that is down, or a monitoring
685
+ queue that does not exist yet, is reported through the logger and answered `false`.
686
+ Which of its two sentences the operator reads — "queue … not available" or "failed to
687
+ publish" — is decided by `isQueueUnavailableError(error, queueName)`, and since d.676
688
+ that decision walks the error's `cause` chain and reads **classes and codes only**:
689
+ `QueueNotFoundError`, `ConnectionError`, `TransientPublishError`, amqplib's numeric
690
+ `code === 404`, and the socket codes `ECONNREFUSED` / `ENOTFOUND` / `ETIMEDOUT`. It
691
+ walks the chain because `BaseClient.publish()` wraps every transport failure in a
692
+ `PublishError` and puts the real class in `cause` — so the classes were never the object
693
+ the function was handed, and until d.676 only its message substrings ever fired. Measured
694
+ consequence of that: a `QueueNotFoundError` with `kind: 'unowned'` answered "not a queue
695
+ problem", and an error whose text merely mentioned `404` answered that it was one.
696
+
697
+ The helper publishes through `mqClient.publishTelemetry()` (d.724), so a refused
698
+ monitoring event cannot close the channel a business message is waiting on. A client
699
+ without that method — anything that is not a `BaseClient` or a subclass of it — is
700
+ reported with a `warn` and answered `false`; the event is not sent on the business
701
+ channel instead.
702
+
608
703
  ### queueConfig — the classification, as a declared export
609
704
 
610
705
  The table above is not a copy: the names, their arguments and the two predicates
@@ -667,9 +762,10 @@ topology.telemetryLogsQueue = 'mine'; // TypeError — the object is froz
667
762
  | `monitoringInfrastructureHealthEventsQueue` | `monitoring.infrastructure.health.events` | The monitoring consumer's queue on that fanout (`api/infra/api_monitoring/src/consumer/topology.js`, `MONITORING_INFRA_EVENT_QUEUE`). |
668
763
  | `deliveryHealthEventsQueue` | `delivery.health.events` | The delivery endpoint binds it to the fanout and consumes it for WS push (`api/infra/api_delivery_endpoint/src/index.js`, `bindQueueToFanout(config.healthEventsQueue, …)`). |
669
764
  | `deliveryResourceEventsQueue` | `delivery.resource.events` | Bound to the `monitoring.resource` exchange by `resource.changed.#`; published by the monitoring watcher `resourceChangedPublisher.js`, drained by `ResourceChangedConsumer.js` in the delivery endpoint. |
670
- | `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange`, which every service publishes to with its own name as the routing key; drained by the monitoring consumer (`api/infra/api_monitoring/src/consumer/index.js`, `topology.TELEMETRY_LOGS_QUEUE`). |
765
+ | `telemetryLogsQueue` | `telemetry.logs.queue` | Bound to `telemetry.exchange` by `telemetry.logs.*`; every telemetry producer publishes there with a routing key of the shape `telemetry.<signal>.<service_name>` (logs: `telemetry.logs.<service_name>`); drained by the monitoring consumer (`api/infra/api_monitoring/src/consumer/index.js`, `topology.TELEMETRY_LOGS_QUEUE`). |
671
766
  | `telemetryMetricsQueue` | `telemetry.metrics.queue` | The same exchange, the same consumer — metric samples. |
672
767
  | `telemetryTracesQueue` | `telemetry.traces.queue` | The same exchange, the same consumer — spans. |
768
+ | `telemetryExchange` | `telemetry.exchange` | Topic exchange the three queues above are bound to; every telemetry producer publishes to it, and the monitoring consumer asserts it (d.708b). |
673
769
 
674
770
  - **Frozen on purpose.** An importer must not be able to rewrite platform topology: a
675
771
  name overridden on one side of a fanout loses the events silently, which is why batch
@@ -795,6 +891,12 @@ requestedBy })` take. One declaration record, and nothing else:
795
891
  is a missing record of work that happened; a lost registration is work that will not happen.
796
892
  - `scheduler.requestQueue` is the queue name, composed from the declaration in `queueConfig`
797
893
  — a caller never types `scheduler.request`.
894
+ - **A request that cannot be built is told apart by code, never by wording.** Every refusal of
895
+ a builder — the port's key check, the declaration rules, tenancy, the key, the requester, the
896
+ event — carries `error.code === scheduler.SCHEDULER_REQUEST_INVALID`; a failed send (no
897
+ connection, a publish the broker refused) never does. The wrapper's boot uses exactly this to
898
+ isolate one bad record as a failed source while a broker that is gone still stops the boot
899
+ ([`scheduler` 020](/docs/governance/confirmations/scheduler.md)).
798
900
 
799
901
  Two more things a service says on that queue, and one send for all of them:
800
902
 
@@ -847,6 +949,34 @@ await port.cancel({ id });
847
949
  under the wrapper, which fills it from the envelope of the run, and an operator script that does
848
950
  not boot the wrapper at all and works row by row.
849
951
 
952
+ ### decodeMessage — a delivery, decoded by one rail
953
+
954
+ `consume()` hands its handler the RAW amqplib delivery. `decodeMessage(raw)` is the one
955
+ public way from that delivery to the message it carries (d.789), so a consumer does not
956
+ keep its own copy of the translation.
957
+
958
+ ```javascript
959
+ const { decodeMessage } = require('@onlineapps/mq-client-core');
960
+
961
+ await client.consume(queue, async (raw) => {
962
+ const { body, headers, properties, fields } = decodeMessage(raw);
963
+ // …
964
+ });
965
+ ```
966
+
967
+ - It returns the decoded body, and `headers`, `properties` and `fields` exactly as they
968
+ arrived.
969
+ - It refuses with `ValidationError`, naming what arrived: a delivery that is not an
970
+ object, `content` that is neither a `Buffer` nor a string, an empty body, or a
971
+ DECLARED content type other than `application/json`. No declaration is fine — the
972
+ platform envelope does not require one.
973
+ - A body that is not JSON is the `SerializationError` the serializer throws, passed on
974
+ unchanged — the refused bytes in `payload`, the parser's reason in `cause`.
975
+ - It carries no `retryable` flag: what happens to the message is decided by the
976
+ caller's `classify(error)` (§ `consume()` — the delivery contract), on the error's
977
+ class.
978
+ - It is additive: `consume()` still hands over the raw delivery.
979
+
850
980
  ### redactUrl — a connection URL that is safe to log
851
981
 
852
982
  `RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "4.2.0",
3
+ "version": "5.1.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
package/src/BaseClient.js CHANGED
@@ -318,6 +318,12 @@ class BaseClient {
318
318
  // Apply resolved runtime config
319
319
  this._config.host = resolved.host;
320
320
  this._config.heartbeat = this._config.heartbeat ?? resolved.heartbeat;
321
+ // The resolved identity is WRITTEN BACK, so the config carries what the client runs
322
+ // under whichever leg produced it — the explicit key or ENV SERVICE_NAME. Until d.770
323
+ // only the explicit key reached `_config.serviceName`, and everything downstream that
324
+ // needed the name asked the runtime config again; two readers of one fact, and the
325
+ // transport's copy answered the module default nobody wrote.
326
+ this._config.serviceName = resolved.serviceName;
321
327
  this._config.connectionName =
322
328
  this._config.connectionName ||
323
329
  `${resolved.serviceName}:${process.pid}`;
@@ -381,20 +387,36 @@ class BaseClient {
381
387
  });
382
388
  this._config.host = resolved.host;
383
389
  this._config.heartbeat = this._config.heartbeat ?? resolved.heartbeat;
390
+ this._config.serviceName = resolved.serviceName;
384
391
  this._config.connectionName =
385
392
  this._config.connectionName ||
386
393
  `${resolved.serviceName}:${process.pid}`;
387
394
 
388
- try {
389
- this._logger.debug('[BaseClient] Creating transport');
390
- // Instantiate appropriate transport: RabbitMQClient. `this._config` carries
391
- // the validated logger, so the transport logs through the same channel —
392
- // it builds none of its own.
393
- this._transport = transportFactory.create(this._config);
394
- this._logger.debug('[BaseClient] Transport created', {
395
- transport: this._transport.constructor.name,
396
- });
395
+ this._logger.debug('[BaseClient] Creating transport');
396
+ // Instantiate appropriate transport: RabbitMQClient. `this._config` carries
397
+ // the validated logger, so the transport logs through the same channel —
398
+ // it builds none of its own.
399
+ //
400
+ // OUTSIDE THE `try` ON PURPOSE (d.736b). What this line can throw is a
401
+ // refusal of the CONFIGURATION — a target that is not a non-empty string, a
402
+ // URL declaring its own heartbeat, an `onFatal` that is not a function, a
403
+ // `type` no transport implements. Nothing has been asked of the broker yet,
404
+ // and nothing about the answer will change on a second attempt. Until this
405
+ // batch the `catch` below turned every one of them into `ConnectionError`
406
+ // ("a reachable broker … check the MQ url, credentials and network"), which
407
+ // is a transient fault by name — and `ServiceWrapper._initializeMQ()` reads
408
+ // it as one: it retries `connect()` `wrapper.mq.connectionRetries` times and
409
+ // stands down only for `code: 'MQ_CONNECTION_FATAL'` (10 attempts, 2 s base,
410
+ // 30 s cap — `service-wrapper/config/runtime-defaults.json`), so a typo in a
411
+ // URL cost ~200 s of backoff and then sent the operator to look at the
412
+ // network. The refusal already names the key and the fix
413
+ // (`architecture-principles.md` §5); it is passed on as it is.
414
+ this._transport = transportFactory.create(this._config);
415
+ this._logger.debug('[BaseClient] Transport created', {
416
+ transport: this._transport.constructor.name,
417
+ });
397
418
 
419
+ try {
398
420
  // Register internal error propagation
399
421
  this._transport.on('error', (err) => this._handleError(err));
400
422
 
@@ -472,9 +494,14 @@ class BaseClient {
472
494
  * infrastructure or business queue is created by its owner before anything
473
495
  * publishes to it, and an ownerless name is declared with the arguments the
474
496
  * central `queueConfig` resolves (`transports/rabbitmqClient.js`, the 404 branch).
497
+ * @param {boolean} [options.bufferOnFailure=true] - `false` = a transiently failed message
498
+ * is NOT held in the library's buffer, so no reconnect replays it; for a caller that keeps
499
+ * the message durably elsewhere. Owned by `layers/PublishLayer.js`; a non-boolean is refused.
475
500
  * @returns {Promise<void>}
476
501
  * @throws {ConnectionError} If not connected.
477
- * @throws {PublishError} If publish fails.
502
+ * @throws {PublishError} If publish fails. After a transient failure (retries exhausted,
503
+ * retry disabled, the awaited reconnect failed) it carries `buffered` (boolean): `true`
504
+ * only when the buffer holds the message for the next reconnect.
478
505
  */
479
506
  async publish(queue, message, options = {}) {
480
507
  if (!this._connected || !this._transport) {
@@ -517,7 +544,7 @@ class BaseClient {
517
544
  // answer as d.297 one call over (`_consumerRefusal()`): name no likeliest
518
545
  // cause, carry the one actually caught. `cause` is unchanged, so a caller
519
546
  // deciding on the class still reads the object.
520
- throw new PublishError(
547
+ const failure = new PublishError(
521
548
  `[BaseClient] Failed to publish to queue "${queue}": ${describeThrown(err)} - `
522
549
  + 'Expected: publish() to hand the message to the broker. '
523
550
  + 'Fix: act on the reason above — this layer does not know which failure it was, and naming the '
@@ -526,6 +553,20 @@ class BaseClient {
526
553
  queue,
527
554
  err
528
555
  );
556
+ // Whether the library's buffer holds the message is decided and written by
557
+ // `PublishLayer` (options.bufferOnFailure, d.1122); it is carried up here unchanged so
558
+ // the caller reads it on the error it catches. Absent when the failure never reached
559
+ // the buffer decision — "not asked" is not "false".
560
+ if (err && typeof err.buffered === 'boolean') {
561
+ failure.buffered = err.buffered;
562
+ }
563
+ // Whether any attempt wrote the frame (d.1145, conf `mq-publish-buffer` 001): carried
564
+ // like `buffered`, so the caller — the orchestrator's `publish_pending` record
565
+ // (`step-publish-failure` 003) — reads it on the error it catches.
566
+ if (err && typeof err.sent === 'boolean') {
567
+ failure.sent = err.sent;
568
+ }
569
+ throw failure;
529
570
  }
530
571
  }
531
572
 
@@ -736,6 +777,18 @@ class BaseClient {
736
777
  * DLQ, as the monitoring intake requires (`api/shared/TODO.md`, INFRA-monitoring
737
778
  * 2026-09-11); an event with no `status` is refused there under its own name.
738
779
  *
780
+ * `operation` says WHAT died, and it is written ONLY when the envelope carried
781
+ * one: the invocation envelope has the field
782
+ * (`docs/biz/10-invocation/mq-envelope.md` § Inbound envelope), the workflow
783
+ * message of `docs/biz/70-contracts/workflow-message.md` §3.2 never does, and
784
+ * this event reports what it found rather than a marker. It has a reader
785
+ * already — `readReported(data.operation)` in the monitoring consumer
786
+ * (`infra/api_monitoring/src/consumer/apiflow-extension.js`), from where the
787
+ * value reaches the Loki line as `operation`; until it was written here, every
788
+ * dead letter arrived there as `null`. The run's identity stays ONE key,
789
+ * `workflow_id`: no event on this queue carries a `correlation_id`
790
+ * (`docs/biz/70-contracts/monitoring-events.md` § Rules).
791
+ *
739
792
  * @param {Object} event - As the transport reports it.
740
793
  * @returns {Promise<void>}
741
794
  * @private
@@ -745,16 +798,22 @@ class BaseClient {
745
798
  const serviceName = runtimeCfg.get('serviceName', this._config.serviceName);
746
799
 
747
800
  let workflowId = null;
801
+ let operation = null;
748
802
  try {
749
803
  const envelope = serializer.deserialize(content);
750
- if (envelope !== null && typeof envelope === 'object' && typeof envelope.workflow_id === 'string'
751
- && envelope.workflow_id !== '') {
752
- workflowId = envelope.workflow_id;
804
+ if (envelope !== null && typeof envelope === 'object') {
805
+ if (typeof envelope.workflow_id === 'string' && envelope.workflow_id !== '') {
806
+ workflowId = envelope.workflow_id;
807
+ }
808
+ if (typeof envelope.operation === 'string' && envelope.operation !== '') {
809
+ operation = envelope.operation;
810
+ }
753
811
  }
754
812
  } catch (parseErr) {
755
813
  // A payload that is not JSON carries no envelope, so it carries no
756
814
  // workflow_id either. Same outcome as an envelope without one.
757
815
  workflowId = null;
816
+ operation = null;
758
817
  }
759
818
 
760
819
  if (workflowId === null) {
@@ -775,29 +834,64 @@ class BaseClient {
775
834
  return;
776
835
  }
777
836
 
837
+ const payload = {
838
+ event_type: 'message_dlq',
839
+ workflow_id: workflowId,
840
+ service_name: serviceName,
841
+ // The broker's word for this death; see the method comment.
842
+ status: 'rejected',
843
+ queue,
844
+ attempts,
845
+ max_attempts: maxAttempts,
846
+ classification,
847
+ error: {
848
+ message: error && error.message,
849
+ name: error && error.name
850
+ },
851
+ timestamp: new Date().toISOString()
852
+ };
853
+
854
+ // …and WHAT died, when the envelope says so. The key is written only when
855
+ // the envelope carried one, in the one spelling the invocation envelope
856
+ // uses; an envelope without it leaves the event without the key rather than
857
+ // with an invented marker, exactly as workflow_id is handled above.
858
+ if (operation !== null) {
859
+ payload.operation = operation;
860
+ }
861
+
778
862
  await publishToMonitoringWorkflow(
779
863
  this,
780
- {
781
- event_type: 'message_dlq',
782
- workflow_id: workflowId,
783
- service_name: serviceName,
784
- // The broker's word for this death; see the method comment.
785
- status: 'rejected',
786
- queue,
787
- attempts,
788
- max_attempts: maxAttempts,
789
- classification,
790
- error: {
791
- message: error && error.message,
792
- name: error && error.name
793
- },
794
- timestamp: new Date().toISOString()
795
- },
864
+ payload,
796
865
  this._logger,
797
866
  { queue }
798
867
  );
799
868
  }
800
869
 
870
+ /**
871
+ * Publish one of the CLIENT'S OWN events — the messages nobody asked it to send.
872
+ *
873
+ * Same path as `publish()` in every respect but one: the send goes out on a channel of
874
+ * its own (`transports/rabbitmqClient.js` § _ensurePublisherChannel, role
875
+ * `'telemetry'`), so a refusal of an event can never close the channel a caller's
876
+ * message is waiting for a confirm on.
877
+ *
878
+ * WHY IT IS A SEPARATE METHOD and not a flag on `publish()`: the caller of `publish()`
879
+ * is sending its own message and has no business choosing a channel; the only code that
880
+ * may say "this is the client talking about itself" is the code that IS the client
881
+ * talking about itself (`src/monitoring-publish.js`). Measured cost of the shared
882
+ * channel, d.711: in a vhost where `monitoring.workflow.fanout` was not declared, the
883
+ * `404` for a dead-letter event took the business publish beside it down with it — 3 of
884
+ * 7 runs of a suite that had nothing to do with telemetry.
885
+ *
886
+ * @param {string} queue - Target queue name.
887
+ * @param {Object|Buffer|string} message - Payload to send.
888
+ * @param {Object} [options] - The same overrides `publish()` takes.
889
+ * @returns {Promise<void>}
890
+ */
891
+ async publishTelemetry(queue, message, options = {}) {
892
+ return this.publish(queue, message, { ...options, channelRole: 'telemetry' });
893
+ }
894
+
801
895
  /**
802
896
  * Assert queue existence (delegates to transport).
803
897
  * @param {string} queue - Queue name
@@ -959,7 +1053,8 @@ class BaseClient {
959
1053
  *
960
1054
  * Settling is idempotent: the transport marks the delivery via `msg._mqProcessed`
961
1055
  * and `consume()` skips its own auto-ack for a message the handler already
962
- * settled (`transports/rabbitmqClient.js:1769` and `:2268`). Calling this from
1056
+ * settled (`RabbitMQClient.consume()` and `_applyDeliveryPolicy()` in
1057
+ * `transports/rabbitmqClient.js`). Calling this from
963
1058
  * inside a consume handler is therefore supported, not a double-settle.
964
1059
  *
965
1060
  * Until 2026-08-28 this method was declared TWICE in this class; the second,