@onlineapps/mq-client-core 3.2.0 → 4.0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,304 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [4.0.0] — 2026-09-16
8
+
9
+ ### Changed — značku odmítnutí nese i deklarační rail, jednou funkcí pro obě cesty (d.582)
10
+
11
+ d.577 dala verdikt cestě `consume()`. `assertQueue()` — veřejný rail, kterým jde
12
+ **každá** deklarace a po kterém jde FÁZE 0.8 `@onlineapps/service-wrapper`, když
13
+ si služba zakládá vlastní business fronty — dál propouštěl syrovou chybu
14
+ z amqplib. Odmítnutá deklarace tak odcházela s `code` = **číslo** `406`
15
+ a bez jakékoli klasifikace: `@onlineapps/conn-orch-registry` přenáší, co
16
+ rozhodlo jádro (d.579), a neměl co přenést; wrapper, který končí fázi natrvalo na
17
+ `code: 'MQ_CONNECTION_FATAL'`, označil odmítnutí za přechodné.
18
+
19
+ Teď obě cesty odpovídají **jednou funkcí** `_asRefusal(kind, queue, err)`: `kind`
20
+ volí větu a třídu obalu, verdikt se píše jednou a v pravopisu, který už používá
21
+ `_failFatally()`. Odmítnutá deklarace vychází jako chyba s
22
+ `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, jménem fronty v `queue`
23
+ a původní chybou brokeru — včetně reply kódu — v `cause`. Dvě kopie jedné
24
+ klasifikace by se mohly rozejít (`change-discipline.md` § One rail per concern).
25
+
26
+ `_failFatally()` se ani tady nevolá: odmítnuta je fronta, ne spojení — klient
27
+ zůstává připojený a `isConnectionFatal()` je dál `false`.
28
+
29
+ Kdo z toho co má: po re-pinu na tuto verzi doručí `ensureQueues()`
30
+ v `@onlineapps/conn-orch-registry` (d.579) značku wrapperu, takže 406 z FÁZE 0.8
31
+ skončí boot natrvalo místo aby ho opakoval proti frontě, kterou služba nikdy
32
+ nesmí založit.
33
+
34
+ Chování, které se **nemění**: 404, „channel ended", 503 i odmítnutí volajícího
35
+ (`Queue lifetime is not the caller's to set`) odcházejí přesně tak, jak přišly;
36
+ přijatá deklarace dál vrací `queue.declare-ok` brokeru. Kontrolní noha d.295
37
+ v `connect-refusal-fails-fast.integration.test.js` čte reply kód nově o jedno
38
+ `cause` níž — její vlastní tvrzení (spojení nebylo v sázce, klient se nestaví
39
+ mimo provoz) platí beze změny.
40
+
41
+ ### Changed — odmítnutí fronty brokerem nese stejný verdikt jako odmítnutí spojení (d.577)
42
+
43
+ Fronta, která na brokeru existuje s jinými argumenty, než jaké `queueConfig`
44
+ deklaruje, odpoví na `assertQueue()` kódem **406 PRECONDITION-FAILED**.
45
+ `_prepareQueueForConsume()` z toho stavěl `ConsumeError` **bez `code` a bez
46
+ `reason`** — kód brokeru přežil jen v `cause` (a o jedno obalení výš, v
47
+ `@onlineapps/conn-orch-registry`, až v `cause.cause`). `REFUSAL_REPLY_CODES` a
48
+ `_isBrokerRefusal()` se přitom ptaly jen v connect/reconnect.
49
+
50
+ Co to stálo: `@onlineapps/service-wrapper` ukončí FÁZI 0.7 trvale jen na
51
+ `code: 'MQ_CONNECTION_FATAL'`, takže 406 na frontě byl klasifikován jako
52
+ PŘECHODNÝ a služba opakovala boot proti frontě, kterou nikdy nesmí založit —
53
+ každý pokus dostane stejnou odpověď (změřeno sondou W532 proti živému brokeru).
54
+
55
+ Teď je to **jeden slovník pro jeden fakt**: každá chyba z cesty
56
+ `consume()` — klasifikace fronty, deklarace i registrace konzumenta — kterou
57
+ broker odmítl kódem z `REFUSAL_REPLY_CODES` (403/530/406), vychází jako
58
+ `ConsumeError` s `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'` a
59
+ původní chybou brokeru v `cause`. Klasifikuje se **kódem odpovědi**, ne zněním
60
+ hlášky (`_isBrokerRefusal()`, jediná kolej). Verdikt píše jedno místo — `catch`
61
+ v `consume()` transportu — a `BaseClient.consume()` takovou chybu přeposílá
62
+ **beze změny**: obalení by před přesnou větu brokeru postavilo obecnější a
63
+ značku zanořilo o jedno `cause` hlouběji.
64
+
65
+ Co se záměrně **nemění**: `_failFatally()` se tu nevolá. Odmítnuta je fronta, ne
66
+ spojení — klient zůstává připojený, publikační cesta nad týmž spojením nemá co
67
+ odpovídat a líné zotavení se nezastavuje. Chyba, kterou broker neodmítl, odchází
68
+ přesně tak, jak přišla: 404 dál nese `CONSUMER_QUEUE_MISSING`, chybějící
69
+ dead-letter route dál `CONSUMER_DEAD_LETTER_ROUTE_MISSING`, 503 a „channel
70
+ ended" dál žádnou značku.
71
+
72
+ ### Documentation — kadence heartbeatu se tady nedeklaruje (d.574)
73
+
74
+ Dva komentáře v `src/config/queueConfig.js` psaly kadenci heartbeatu číslem
75
+ („every 10s"). Balíček ji nevlastní: je to povinná volba konstruktoru
76
+ `RegistryClient` (`@onlineapps/conn-orch-registry`, `heartbeatInterval`), kterou
77
+ `@onlineapps/service-wrapper` plní z `BIZ_HEARTBEAT_INTERVAL_MS`. Ručně psané
78
+ číslo v komentáři je popisný fakt bez mechanismu, který ho drží pravdivý
79
+ (`.claude/rules/doc-code-binding.md` §1), takže obě věty teď ukazují na
80
+ vlastníka a číslo nenesou. Chování ani API se nemění.
81
+
82
+ ### Changed — odmítnutí brokerem na cestě bootu nese verdikt (d.295)
83
+
84
+ `connect()` přeposílal syrovou chybu z amqplib bez jakéhokoli verdiktu, takže
85
+ volající nad ním nemohl poznat, jestli broker **odpověděl a odmítl** (403
86
+ ACCESS-REFUSED, 530 NOT-ALLOWED, 406 PRECONDITION-FAILED), nebo jestli **nikdo
87
+ neodpověděl** (ECONNREFUSED, timeout). `_isBrokerRefusal()` ten rozdíl umí od
88
+ d.339, ale četla ho jediná cesta — `_reconnectWithBackoff()`, tedy spojení
89
+ ztracené za běhu. Teď ho čte i první `connect()`: odmítnutí z něj vychází jako
90
+ chyba s `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, původní chybou
91
+ v `cause` a hláškou §5, která jmenuje credentials, vhost a topologii jako fix.
92
+ `isConnectionFatal()` je od té chvíle `true`, takže se proti odmítajícímu brokeru
93
+ už nerozbíhá líné zotavení.
94
+
95
+ Verdikt se na této cestě **neohlašuje** na `connection:fatal` ani přes `onFatal`:
96
+ volající na `connect()` čeká, takže se ho dozví výjimkou, a druhé oznámení téže
97
+ události je přesně ta škoda, kterou popisuje `_reportRecoveryFailure()`. Výsledek
98
+ bootu patří volajícímu bootu (`@onlineapps/service-wrapper`, poznámka `BOOT
99
+ budget` v `_initializeMQ()`), ne kanálu pro asynchronní ztrátu.
100
+
101
+ `BaseClient.connect()` verdikt propouští beze změny. Dosud ho obalil do obecné
102
+ `ConnectionError` s fixem „read error.cause" — přesná odpověď degradovaná na
103
+ vágní, a značka, na kterou volající přestává opakovat, se ztratila úplně
104
+ (`ConnectionError` bere `code` jako argument konstruktoru a to volání žádný
105
+ nepředávalo).
106
+
107
+ Co se **nemění**: `REFUSAL_REPLY_CODES` zůstávají `[403, 530, 406]` — 503
108
+ COMMAND-INVALID se nepřidává, žádné měření si to nevyžádalo. Neodpovídající
109
+ broker odchází z `connect()` přesně jako dřív, syrový a opakovatelný, protože
110
+ „vzdát to navždy" nad výpadkem zakazuje konfirmace
111
+ `mq-client-lifecycle-contract` 001 bod 3. A 406 z `assertQueue()` nad živým
112
+ spojením zůstává chybou fronty, ne ztrátou spojení.
113
+
114
+ ### Changed — `publish()` cituje příčinu, nehádá ji (d.514)
115
+
116
+ `BaseClient.publish()` přepisoval všechno, co transport vyhodí, na jednu větu:
117
+ *„Failed to publish to queue "<q>" - Expected: the queue to exist and the channel
118
+ to be open. Fix: read error.cause for the broker reason."* Dvě ze tří jejích
119
+ klauzulí byly dohad. Transport odmítá publikaci i z důvodů, které s chybějící
120
+ frontou ani se zavřeným kanálem nemají nic společného — pravidlo vlastnictví
121
+ (`QueueNotFoundError`, `kind: 'unowned'`, d.419), vyčerpaný rozpočet opakování,
122
+ odmítnutí brokerem — a u každého z nich hláška posílala čtenáře prověřit frontu,
123
+ která existuje, a kanál, který je otevřený. Pravá věta ležela v `error.cause`,
124
+ kam se řádek logu nedívá.
125
+
126
+ Je to táž vada, kterou d.297 odstranila o volání vedle (`_consumerRefusal()`),
127
+ a odpovídá se stejně: vrstva **nejmenuje nejpravděpodobnější příčinu, cituje tu,
128
+ kterou skutečně chytila**. Nový tvar hlášky §5 je
129
+ `[BaseClient] Failed to publish to queue "<q>": <věta transportu> - Expected:
130
+ publish() to hand the message to the broker. Fix: act on the reason above …
131
+ error.cause carries the original error, error.queue names the target queue.`
132
+
133
+ Co se **nemění**: třída (`PublishError`), `error.queue`, `error.cause` ani objekt
134
+ v něm — volající, který se rozhoduje podle třídy, čte dál objekt. Nemění se ani
135
+ `ConnectionError` z nepřipojeného klienta a `SerializationError` z nepřevoditelné
136
+ zprávy; obě mají vlastní, pravdivou větu už teď. Odmítnutí, které vyhodí hodnotu
137
+ bez `message` (neErrorová rejekce), se do hlášky přenáší celé (`String(thrown)`),
138
+ protože „undefined" uprostřed odmítnutí je horší odpověď než řetězec, kterým
139
+ někdo odmítl.
140
+
141
+ ### Removed (BREAKING) — `recoveryScope`: klíč, který po d.419 nic nečetlo (d.514)
142
+
143
+ `recoveryScope` byl poslední zbytek dveří, kterými klient zakládal frontu bez
144
+ vlastníka. d.419 odebrala `queueCreationFilter` i `queueCreationCallback`
145
+ a `RecoveryWorker.createQueue()`; hodnota `recoveryScope` ale dál putovala ze
146
+ schématu přes `rabbitmqClient.js` do `RecoveryWorker`, kde se přiřadila do pole
147
+ `this._scope`, **které nečetlo nic v celém balíčku** (měřeno 2026-09-15:
148
+ `grep -rn "_scope" src/` → jediný výskyt je přiřazení a jedna zmínka v komentáři).
149
+ Deklarace, kterou nikdo nečte, tvrdí čtenáři, že na ní něco závisí.
150
+
151
+ Odchází celý řetěz: `configSchema.recoveryScope`, `defaultConfig.recoveryScope`,
152
+ argument `scope` konstruktoru `RecoveryWorker` i pole `this._scope`. Schéma je
153
+ ZAVŘENÉ, takže konfigurace, která klíč pořád nese, je odmítnuta **jménem** při
154
+ konstrukci — ne tiše ignorována.
155
+
156
+ **Čtyři otázky** (`.claude/rules/change-discipline.md` § Removing something
157
+ removes its declaration): **(1) proč vznikl** — `scope` říkal, jestli tenhle
158
+ klient smí zakládat fronty; v `business` scope je zakládal, v `infrastructure`
159
+ ne. Byla to volba provozovatele nad ad-hoc jmény z doby, kdy neexistovala
160
+ centrální šablona, ze které by se dala fronta deklarovat. **(2) která část
161
+ koncepce ho nesla** — hlavička `RecoveryWorker`, § Queue Creation: *„ostatní
162
+ jména … smí založit, pokud to scope a `queueCreationFilter` dovolují"*. Ta část
163
+ **neplatí**: nahradila ji konfirmace `mq-consumer-contract` 006 — *„queues come
164
+ into being only from declarations"* — a d.419 podle ní zavřela obě zbylé dveře.
165
+ **(3) proč ho dnes nikdo nečte** — ustoupil rozhodnutí: s dveřmi zmizel jediný
166
+ čtenář a klíč zůstal ležet. Vacuózní byl ale už předtím, a je to zapsané v kódu,
167
+ který se teď maže: `ConnectorMQClient` nastavoval `'business'` **každé** biz
168
+ službě, takže „smí zakládat" platilo plošně a nerozhodovalo nic. **(4) je náhrada
169
+ koncepčnější** — ano. O tvaru hlášení rozhoduje třída vlastnictví, kterou
170
+ odpověděla centrální deklarace (`error.kind` z `QueueNotFoundError`: `infrastructure`
171
+ / `business` / `unowned`). Ta plyne z `queueConfig`, ne z toho, kdo klienta
172
+ postavil, takže dva klienti namířené na tutéž frontu nemohou dostat různou
173
+ odpověď — a odpověď se nemůže rozejít s deklarací, protože je z ní odvozená.
174
+
175
+ **Pro volajícího:** `@onlineapps/conn-infra-mq` klíč jak je publikován pořád
176
+ posílá (`ConnectorMQClient.js`, `recoveryScope: config.recoveryScope || 'business'`;
177
+ pinuje `@onlineapps/mq-client-core` 2.0.1, kde klíč ještě existuje). Až se pin
178
+ pohne, bude odmítnut jménem při konstrukci. Úprava konektoru patří do dávky, která
179
+ pin posouvá, ne do téhle — stejně jako u `queueCreationCallback` v d.419.
180
+
181
+ ### Removed — výjimka pro `rpc.reply.*` v `consume()`, nedosažitelná od d.259 (d.514)
182
+
183
+ `_prepareQueueForConsume()` měla třetí třídu jména: co začínalo `rpc.reply.`,
184
+ přeskočilo klasifikaci, rozlišení argumentů z centrální konfigurace i otázku na
185
+ brokera (`checkQueue`/`assertQueue`). Mezi ty dvě výjimky ale d.259 postavila
186
+ bránu nedoručitelných zpráv, a ta takové jméno **odmítne dřív**: `queueConfig`
187
+ ho neklasifikuje jako infrastrukturní ani business (měřeno:
188
+ `isInfrastructureQueue('rpc.reply.abc123') === false`, `isBusinessQueue` totéž),
189
+ takže pro něj nedeklaruje žádnou cestu, a `getDeadLetterRoute()` vrací `null`
190
+ při `isDeadLetterTarget() === false`. Druhá polovina výjimky tak byla mrtvý kód
191
+ a první nerozhodovala nic (obě klasifikace jsou pro to jméno `false` i bez ní).
192
+
193
+ **Čtyři otázky**: **(1) proč vznikla** — RPC odpovědní cesta: volající si sám
194
+ založil dočasnou, nedurabilní frontu s vlastní životností a konzumoval z ní, takže
195
+ `consume()` nad ní nesměl nic deklarovat. **(2) která část koncepce ji nesla** —
196
+ právě ta cesta. **Neplatí**: `createTemporaryQueue()` byla zrušena
197
+ (`conn-infra-mq` d.275b) a konfirmace `mq-consumer-contract` 003 bod 3 to říká
198
+ větou — *„`rpc.reply.*` is not a topology question — `createTemporaryQueue()` has
199
+ no caller, it is a removal question"*. **(3) proč ji dnes nic nečte** — ustoupila
200
+ rozhodnutí d.259: konzument bez cesty pro nedoručitelnou zprávu se nepřipojí,
201
+ a fronta bez deklarace žádnou cestu nemá. **(4) je náhrada koncepčnější** — ano,
202
+ je to jedna kolej: fronta je konzumovatelná právě tehdy, když centrální deklarace
203
+ říká, kam s odmítnutou zprávou. Neexistuje druhá třída jmen, která klasifikaci
204
+ a deklaraci přeskočí na slovo volajícího. Výjimka slibovala podporu, kterou d.259
205
+ odebrala, takže kód teď říká, co systém dělá.
206
+
207
+ Chování se **nemění** a je změřené proti živému brokeru: `rpc.reply.*` fronta,
208
+ která na brokeru **skutečně je** (nedurabilní, jak ji zakládala zrušená cesta), je
209
+ odmítnuta touž větou jako každé jiné jméno bez deklarované cesty, nic se k ní
210
+ nepřipojí a nic se nepředeklaruje
211
+ (`tests/integration/dead-letter-route-required.integration.test.js`).
212
+
213
+ ### Changed — `amqplib` přišpendlen přesně na `0.10.9` (d.514)
214
+
215
+ `^0.10.3` je plovoucí rozsah, a tenhle balíček na konkrétním chování amqplib
216
+ **staví změřená tvrzení**: „ani `Connection`, ani `Channel` nenese vlastnost
217
+ `closed`" (d.260, amqplib 0.10.9, živý broker, 2026-09-12) je předpoklad
218
+ `isConnected()`, `getChannelState()`, všech tří `_ensure*Channel()`
219
+ a `_performHealthCheck()`. S plovoucím rozsahem může tentýž commit nainstalovat
220
+ jiný kód jiný den — přesně to, čemu pravidlo o pinech brání u `@onlineapps/*`
221
+ (`architecture-principles.md` § Version pinning: „a floating range makes the same
222
+ commit install different code on different days"). Hodnota `0.10.9` není nová,
223
+ je to co bylo v `package-lock.json` nainstalováno (měřeno:
224
+ `require('amqplib/package.json').version` → `0.10.9`); mění se jen to, že ji
225
+ `package.json` říká nahlas. `amqplib` není v SSOT `api/config/libraries.json`
226
+ (měřeno: grep bez shody), takže tenhle pin nevlastní publikační vlna.
227
+
228
+ ### Changed (testy) — sady `liveness-health-publish*` říkají, proč mají zotavení vypnuté (d.514)
229
+
230
+ Obě běží s `reconnectEnabled: false`. Důvod je pravdivý a teď je napsaný: co v nich
231
+ umírá, je KANÁL, a obnova spojení má vlastní sady (`reconnect-cycles-lazy-retry`,
232
+ `reconnect-exhaustion`, `disconnect-ends-reconnect`). Napsané je i to, co ten
233
+ příznak nese: **nenese nic** — měřeno 2026-09-15, obě sady jsou zelené i s
234
+ `reconnectEnabled: true` (integrace 4/4 třikrát po sobě, unit 11/11) — a právě
235
+ proto neviděly d.467, kde `performHealthCheck()` spouštěl obnovovací cyklus
236
+ bezpodmínečně. Případ s obnovou se do nich **nedoplňuje**: ta otázka má vlastní
237
+ kolej se zapnutou obnovou (`health-check-does-not-recover-a-live-client`, unit
238
+ i integrace), a druhá akceptační sada nad jedním pojmem je duplicita, kterou
239
+ `change-discipline.md` § One rail per concern označuje za vadu.
240
+
241
+ ### Fixed — pracující konzument s prefetchem 1 už není poplach (d.550)
242
+
243
+ Změřeno na BIZ-pdfgen a BIZ-converteru, dvakrát při každém bootu:
244
+
245
+ ⚠️ Prefetch utilization high: queue 'biz-pdfgen.registry' has 1/1 messages
246
+ in-flight (100%, threshold: 80%)
247
+
248
+ Registrační fronta má prefetch 1, takže jedna zpráva v letu je 100 % okna — a
249
+ zároveň naprosto normální stav konzumenta, který právě dělá tu jedinou práci,
250
+ kterou smí. Poplach se proto spustil při KAŽDÉM doručení do takové fronty a
251
+ žádný práh na tom nic nemění: `1/1 >= 0.8` platí stejně jako `1/1 >= 0.99`.
252
+ Varování, které nemůže nenastat, neříká nic — jen učí čtenáře přehlížet řádek,
253
+ na kterém jednou bude stát skutečné zahlcení.
254
+
255
+ `_checkPrefetchUtilization()` nově okno jedné zprávy neměří: jeho jediné dva
256
+ stavy jsou 0 % (nečinný) a 100 % (pracuje) a ani jeden není zjištění, kvůli
257
+ kterému by měl kdo zasáhnout. Pro okno větší než jedna zpráva se nemění nic —
258
+ `prefetchUtilizationThreshold` zůstává, jak ho deklaruje
259
+ `src/config/defaultConfig.js`, žádný nový práh ani default nevzniká, a plné okno
260
+ dvou i deseti zpráv se hlásí dál se stejným textem (kontrolní případy v obou
261
+ tierech: `tests/unit/prefetch-of-one-is-not-a-warning.test.js`,
262
+ `tests/integration/prefetch-of-one-is-not-a-warning.integration.test.js`).
263
+
264
+ ### Changed (testy) — kontrolní případ redakce jmenuje allow-list, ne jeho polovinu (d.514)
265
+
266
+ `tests/unit/redact-credentials.test.js` měl kontrolní případ nadepsaný *„URL bez
267
+ pověření se vrací nezměněná“*. Tvrzení zní jako obecné pravidlo, které od
268
+ `@onlineapps/logger-contract` 2.0.0 (d.476) neplatí: redakce je ALLOW-LIST —
269
+ zachová protokol, host, port a cestu, a všechno ostatní odejde bez ohledu na to,
270
+ jestli v tom pověření je. URL bez pověření, ale s dotazem (`?heartbeat=60`) se
271
+ nezměněná nevrací. Kontrolní případ, který jmenuje polovinu pravidla, zůstává
272
+ zelený i nad implementací, která dotaz propouští — tedy přesně nad vadou, kvůli
273
+ které allow-list vznikl.
274
+
275
+ Hodnota v assertu se **nemění** (`amqp://api_services_queuer:5672/oa_vhost` se
276
+ vrací identické); mění se věta, kterou test tvrdí, a důvod je u ní zapsaný.
277
+ Sousední případ, který zahození dotazu měří, zůstává jedinou kolejí pro to
278
+ tvrzení — nový se nepřidává. Žádná změna produkčního kódu.
279
+
280
+ ### Documentation — terminální frontu zakládá vlastník prefixu, který ji jmenuje (d.568)
281
+
282
+ Deklarace není pokyn k založení. Zápis sekce v `queueConfig` říká, **jak** fronta
283
+ vypadá; **kdo** ji na brokeru založí, rozhoduje `options.queues` toho, kdo volá
284
+ `initInfrastructureQueues()`. Odvození celého seznamu z prefixů je jen VÝCHOZÍ větev
285
+ té funkce (`shared/infrastructure-tools/src/orchestration/initInfrastructureQueues.js`)
286
+ a žádný dnešní volající jí nenechá rozhodnout: gateway jmenuje přesně pět front
287
+ `workflow.*`, které vlastní (`infra/api_gateway/index.js`). `monitoring.dlq`
288
+ a `telemetry.dlq` tedy patří do volání monitoringu, ne gateway.
289
+
290
+ INFRA-monitoring to změřilo 2026-09-16 na dev brokeru: 78 front, `.dlq` mezi nimi jen
291
+ `workflow.dlq` a `delivery.dlq`. Sedm front rodin `monitoring.*` a `telemetry.*` nese od
292
+ d.475 `x-dead-letter-routing-key: '<prefix>.dlq'`, takže `nack(requeue=false)` nad nimi
293
+ směřuje na routing key, pod kterým žádná fronta není — a broker zprávu zahodí.
294
+ Terminál založí vlastník prefixu u sebe (INFRA-monitoring, W9c).
295
+
296
+ Opraveny věty v živém textu: JSDoc `monitoring.dlq` a `telemetry.dlq`
297
+ (`src/config/queueConfig.js`), úroveň § terminální fronty v `README.md` a hlavička
298
+ testu `tests/unit/dead-letter-topology.test.js`, která tvrdila, že zápis sekce JE pokyn
299
+ k založení. Dobový záznam v `[3.2.0]` se nemění. Deklarace sama je v pořádku a nově to
300
+ měří test: `declarationOptions('monitoring.dlq')` a `('telemetry.dlq')` vrací přesně
301
+ `{ durable: true, arguments: { 'x-max-length': 50000 } }` — žádné TTL, žádná další
302
+ cesta — s kontrolním případem `workflow.dlq` (totéž) a `monitoring.audit` (routovaná
303
+ fronta též rodiny si TTL, strop i cestu drží). Žádná změna produkčního kódu.
304
+
7
305
  ## [3.2.0] — 2026-09-15
8
306
 
9
307
  ### Added — dead-letter cesty pro rodiny `monitoring.*` a `telemetry.*` (d.475)
package/README.md CHANGED
@@ -195,7 +195,12 @@ Owner decision: `api/docs/governance/confirmations/connector-logger-contract.md`
195
195
  connection (see § Connection loss)
196
196
 
197
197
  `publish()` and the workflow helpers built on it resolve to `undefined`; failure is
198
- signalled by a thrown `PublishError`, never by a falsy return.
198
+ signalled by a thrown `PublishError`, never by a falsy return. That `PublishError`
199
+ **quotes the reason it caught** — `[BaseClient] Failed to publish to queue "<q>":
200
+ <the transport's own sentence> - Expected: … Fix: …` — and keeps the object it came
201
+ from in `error.cause`, so a caller deciding on the class still reads the object
202
+ while a log line already carries the reason. The layer names no likeliest cause of
203
+ its own; the same rule `consume()` follows (§ `errors` and `errorCodes`).
199
204
 
200
205
  ### `consume()` — the delivery contract
201
206
 
@@ -235,6 +240,17 @@ policy exists to end) and it is never silent: the client logs it at error level,
235
240
  the queue, the attempts and the error, and the `message_dlq` event is published as for any
236
241
  other rejection.
237
242
 
243
+ **A terminal queue is declared here and CREATED by the owner of the prefix that names
244
+ it.** A section entry says what the queue looks like, never who brings it into being:
245
+ `initInfrastructureQueues()` derives the full list from the sections only when its caller
246
+ passes no `queues` list, and every caller passes one — the gateway's names the five
247
+ `workflow.*` queues it owns and nothing else (`infra/api_gateway/index.js`). So
248
+ `monitoring.dlq` and `telemetry.dlq` belong to the monitoring service's own call. Until a
249
+ terminal exists on the broker, the drop above happens one step earlier and without the
250
+ `x-death` trail: the rejection is routed to a name no queue carries (measured 2026-09-16
251
+ on the dev broker — 78 queues, `.dlq` among them only `workflow.dlq` and
252
+ `delivery.dlq`).
253
+
238
254
  ```javascript
239
255
  await client.consume(queue, async (msg, delivery) => {
240
256
  // delivery = { attempt, maxAttempts, isFinalAttempt }
@@ -459,9 +475,12 @@ arguments no longer match. The rule is enforced in two places, and both are
459
475
  required: the 404 branch of `_publishOnce()` refuses the publish, and
460
476
  `RecoveryWorker.handleQueueNotFound()` refuses to create the queue behind it.
461
477
 
462
- `recoveryScope: 'business'` (the default every `ConnectorMQClient` sets) says the
463
- client belongs to a business service. It has never meant "may create business
464
- queues".
478
+ Which of the three sentences the refusal carries is decided by the ownership class
479
+ in the error (`kind`), which the central declaration answers — never by how the
480
+ client was constructed. `recoveryScope` used to say which kind of service a client
481
+ belonged to; it never meant "may create business queues", and once d.419 closed the
482
+ door it had guarded, nothing read it. The key is retired (d.514) and a config still
483
+ carrying it is refused by name at construction.
465
484
 
466
485
  **An exchange is named by a publisher, never declared by one.** `publish(queue,
467
486
  message, { exchange, routingKey })` sends to the exchange the caller names and
@@ -521,12 +540,28 @@ for `'Connection closed unexpectedly'` while the transport emitted `'RabbitMQ co
521
540
  closed unexpectedly'`, one capital letter apart, so the branch written to ignore that
522
541
  error had never once run (fixed 2026-09-08, `errors.ConnectionError` now carries `code`).
523
542
 
524
- `consume()` refuses for two named reasons, and they ask for OPPOSITE actions:
543
+ `consume()` refuses for three named reasons, and they ask for OPPOSITE actions:
525
544
 
526
545
  | `error.code` | what happened | what fixes it |
527
546
  |---|---|---|
528
547
  | `CONSUMER_QUEUE_MISSING` | the queue does not exist | start the service that owns the queue — a consumer never creates one |
529
548
  | `CONSUMER_DEAD_LETTER_ROUTE_MISSING` | the queue exists, `queueConfig` declares no dead-letter route for it | declare `x-dead-letter-exchange`/`-routing-key` for that queue and bind the destination; the queue itself is fine |
549
+ | `MQ_CONNECTION_FATAL` (with `reason: 'broker-refused'`) | the BROKER refused this queue — 406 PRECONDITION-FAILED for a queue that exists with other arguments, 403/530 for one this account may not use | correct the topology or the permission the broker named in `error.cause`, then restart; retrying gets the same answer |
550
+
551
+ The third one is the SAME verdict a refused connection carries (§ *permanently lost*),
552
+ because it asks the lifecycle owner for the same thing: stop retrying. It refuses the
553
+ QUEUE, not the connection — the client stays connected and `isConnectionFatal()` stays
554
+ `false`. `@onlineapps/service-wrapper` reads `code` to end FÁZE 0.7 permanently; before
555
+ 2026-09-16 a 406 on a queue carried no code at all, so the service kept booting against a
556
+ queue it will never be allowed to declare.
557
+
558
+ **`assertQueue()` answers the same way**, and for the same reason: a service declaring
559
+ its own business queues meets the refusal there, not on the consume path. A declaration
560
+ the broker refuses (403/530/406) leaves that rail as an error carrying
561
+ `code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`, the queue name in `queue` and
562
+ the broker's own error — reply code included — in `cause`. One function states both
563
+ (`_asRefusal(kind, queue, err)`): two copies of one classification would be free to
564
+ drift. Anything the broker did not refuse leaves either rail exactly as it arrived.
530
565
 
531
566
  Any other failure carries **no** code and its `ConsumeError` says so: the reason is in
532
567
  `error.cause`, and this layer does not name the likeliest-sounding one. Until 2026-09-14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/mq-client-core",
3
- "version": "3.2.0",
3
+ "version": "4.0.0",
4
4
  "description": "Core MQ client library for RabbitMQ - shared by infrastructure services and connectors",
5
5
  "oa": {
6
6
  "category": "connector"
@@ -26,7 +26,7 @@
26
26
  "@onlineapps/logger-contract": "2.0.0",
27
27
  "@onlineapps/runtime-config": "1.2.0",
28
28
  "ajv": "^8.12.0",
29
- "amqplib": "^0.10.3"
29
+ "amqplib": "0.10.9"
30
30
  },
31
31
  "devDependencies": {
32
32
  "jest": "^29.7.0"
package/src/BaseClient.js CHANGED
@@ -196,6 +196,24 @@ function invalidConfigurationMessage(unknownKeys, declaredKeys) {
196
196
  * @throws {ValidationError} If the argument is not a plain object of properties, or
197
197
  * redeclares a key the library already owns.
198
198
  */
199
+ /**
200
+ * What was thrown, rendered for a sentence a human reads in a log line.
201
+ *
202
+ * Neither branch invents anything: an `Error` is quoted by its own `message`,
203
+ * and a value that is not an `Error` is quoted whole, because "undefined" in the
204
+ * middle of a refusal is a worse answer than the string somebody rejected with.
205
+ * The object itself always stays reachable as `error.cause`.
206
+ *
207
+ * @param {*} thrown - Whatever the transport rejected with.
208
+ * @returns {string}
209
+ */
210
+ function describeThrown(thrown) {
211
+ if (thrown instanceof Error && typeof thrown.message === 'string' && thrown.message !== '') {
212
+ return thrown.message;
213
+ }
214
+ return String(thrown);
215
+ }
216
+
199
217
  function schemaWithSubclassProperties(extraProperties) {
200
218
  if (extraProperties === null || extraProperties === undefined) {
201
219
  return configSchema;
@@ -395,6 +413,18 @@ class BaseClient {
395
413
  this._logger.info('[BaseClient] Transport connected successfully');
396
414
  this._connected = true;
397
415
  } catch (err) {
416
+ // A refusal is already answered, so it passes through untouched. The
417
+ // transport reached the verdict (`code: 'MQ_CONNECTION_FATAL'`,
418
+ // `reason: 'broker-refused'`) and wrote the sentence that names
419
+ // credentials, vhost and topology as the fix; wrapping it here would
420
+ // replace that with "read error.cause" — a precise answer demoted to a
421
+ // vague one, and the marker the caller stops retrying on lost with it
422
+ // (`ConnectionError` takes its code as a constructor argument and this
423
+ // call passed none, so the wrap erased it). d.295.
424
+ if (err && err.code === 'MQ_CONNECTION_FATAL') {
425
+ throw err;
426
+ }
427
+
398
428
  throw new ConnectionError(
399
429
  '[BaseClient] Failed to connect to broker - Expected: a reachable broker at the configured host. '
400
430
  + 'Fix: read error.cause for the transport reason, then check the MQ url, credentials and network the service was started with.',
@@ -476,9 +506,23 @@ class BaseClient {
476
506
  try {
477
507
  await this._transport.publish(queue, buffer, options);
478
508
  } catch (err) {
509
+ // The reason is QUOTED, never guessed. Until d.514 this sentence read
510
+ // "Expected: the queue to exist and the channel to be open", and two of its
511
+ // three clauses were a guess: the transport refuses a publish for reasons
512
+ // that have nothing to do with a missing queue or a closed channel — the
513
+ // ownership rule (`QueueNotFoundError`, `kind: 'unowned'`, d.419), a spent
514
+ // retry budget, a broker refusal — and for each of those the reader was sent
515
+ // to check a queue that exists and a channel that is open. The true sentence
516
+ // sat in `error.cause`, where a log line does not look. Same defect, same
517
+ // answer as d.297 one call over (`_consumerRefusal()`): name no likeliest
518
+ // cause, carry the one actually caught. `cause` is unchanged, so a caller
519
+ // deciding on the class still reads the object.
479
520
  throw new PublishError(
480
- `[BaseClient] Failed to publish to queue "${queue}" - Expected: the queue to exist and the channel to be open. `
481
- + 'Fix: read error.cause for the broker reason; error.queue names the target queue.',
521
+ `[BaseClient] Failed to publish to queue "${queue}": ${describeThrown(err)} - `
522
+ + 'Expected: publish() to hand the message to the broker. '
523
+ + 'Fix: act on the reason above — this layer does not know which failure it was, and naming the '
524
+ + 'likeliest would send you to fix something that may not be broken. error.cause carries the '
525
+ + 'original error, error.queue names the target queue.',
482
526
  queue,
483
527
  err
484
528
  );
@@ -623,6 +667,18 @@ class BaseClient {
623
667
  _consumerRefusal(queue, err) {
624
668
  const code = err && err.code;
625
669
 
670
+ // The broker ANSWERED and refused this queue (403/530/406). Rethrown
671
+ // UNCHANGED, for the reason `@onlineapps/service-wrapper`'s `MQConnector`
672
+ // gives one layer up: that message carries the broker's own words and names
673
+ // the permission or the topology as the fix, and the verdict it carries
674
+ // (`code: 'MQ_CONNECTION_FATAL'`, `reason: 'broker-refused'`) is what the
675
+ // lifecycle owner reads to stop retrying a decision. Re-wrapping it here
676
+ // would put a sentence that knows less in front of one that knows more, and
677
+ // bury the marker one `cause` deeper (d.577).
678
+ if (code === 'MQ_CONNECTION_FATAL') {
679
+ return err;
680
+ }
681
+
626
682
  if (code === CONSUMER_DEAD_LETTER_ROUTE_MISSING) {
627
683
  return new ConsumeError(
628
684
  `[BaseClient] Cannot consume from queue "${queue}": queueConfig declares no dead-letter route for it - `
@@ -387,22 +387,15 @@ module.exports = {
387
387
  },
388
388
 
389
389
  // ---------------------------------------------------------------------
390
- // Which kind of service this client belongs to. `queueCreationFilter` and
391
- // `queueCreationCallback` stood here until d.419: they were the two keys by
392
- // which a client declared a queue nobody owns, and a queue now comes into
393
- // being from a declaration or not at all
390
+ // `queueCreationFilter` and `queueCreationCallback` stood here until d.419,
391
+ // and `recoveryScope` the permission they were filtered by until d.514.
392
+ // They were how a client declared a queue nobody owns, and a queue now comes
393
+ // into being from a declaration or not at all
394
394
  // (`docs/governance/confirmations/mq-consumer-contract.md` 006). A config
395
- // still carrying either is refused by name at construction — the schema is
396
- // CLOSED, which is what makes the removal visible to the caller instead of
397
- // silently ignored.
395
+ // still carrying any of the three is refused by name at construction — the
396
+ // schema is CLOSED, which is what makes the removal visible to the caller
397
+ // instead of silently ignored.
398
398
  // ---------------------------------------------------------------------
399
- recoveryScope: {
400
- type: 'string',
401
- enum: ['infrastructure', 'business'],
402
- description:
403
- 'Which kind of service this client belongs to. It decides nothing about queue '
404
- + 'creation: no client declares a queue (workers/RecoveryWorker.js).',
405
- },
406
399
  },
407
400
  required: ['type', 'host'], // Only type and host required
408
401
 
@@ -186,11 +186,7 @@ module.exports = {
186
186
  criticalHealthShutdown: true,
187
187
  criticalHealthShutdownDelay: 60000,
188
188
 
189
- // ---------------------------------------------------------------------
190
- // Which kind of service this client belongs to. It decides nothing about queue
191
- // creation: this client declares no queue at all, and the two callbacks that
192
- // used to let it — `queueCreationFilter` and `queueCreationCallback` — left
193
- // with that door (d.419, conf mq-consumer-contract 006).
194
- // ---------------------------------------------------------------------
195
- recoveryScope: 'infrastructure',
189
+ // `recoveryScope` stood here until d.514, with the two callbacks it filtered
190
+ // (`queueCreationFilter`, `queueCreationCallback`, d.419) one batch ahead of
191
+ // it. This client declares no queue at all, so there is no permission to give.
196
192
  };
@@ -648,7 +648,10 @@ module.exports = {
648
648
  * InfrastructureHealthTracker publishes service status changes
649
649
  * Message format includes event_type: 'service.registered' | 'service.validation.completed' |
650
650
  * 'service.version.changed' | 'service.deregistered' | 'service.status.changed'
651
- * NOTE: Heartbeats are NOT published here (too frequent, every 10s)
651
+ * NOTE: Heartbeats are NOT published here - they are too frequent. The cadence
652
+ * is not a number this file owns: see the `heartbeatInterval` constructor option
653
+ * of RegistryClient (@onlineapps/conn-orch-registry), which ServiceWrapper feeds
654
+ * from BIZ_HEARTBEAT_INTERVAL_MS.
652
655
  * NOTE: Healthcheck messages go to infrastructure.health.events exchange
653
656
  */
654
657
  'services': {
@@ -715,6 +718,17 @@ module.exports = {
715
718
  * onward route — because the operator's dashboard matches `*.dlq` and a message
716
719
  * that expired there is a message nobody got to decide about (confirmation
717
720
  * `mq-consumer-contract` 002 point 2, 003 point 1).
721
+ *
722
+ * **A declaration is not a creation instruction. The terminal is brought into being
723
+ * by the owner of the prefix that NAMES it.** `initInfrastructureQueues()` derives
724
+ * the whole list from these sections only when its caller passes no `queues` list;
725
+ * every caller passes one, and the gateway's names exactly the five `workflow.*`
726
+ * queues it owns (`infra/api_gateway/index.js`). `monitoring.dlq` therefore belongs
727
+ * to the monitoring service's own call, beside the queues it consumes. Until that
728
+ * call names it, a `nack(requeue=false)` on a queue of this family dead-letters onto
729
+ * a routing key no queue carries and the broker DROPS the message — measured
730
+ * 2026-09-16 on the dev broker: 78 queues, and the only `.dlq` among them
731
+ * `workflow.dlq` and `delivery.dlq`.
718
732
  */
719
733
  dlq: {
720
734
  durable: true,
@@ -784,6 +798,10 @@ module.exports = {
784
798
  * `workflow.dlq`, `delivery.dlq` and `monitoring.dlq` — durable, capped at 50k, no
785
799
  * TTL, no onward route. A telemetry record the intake cannot process is the one an
786
800
  * operator most needs to look at, and a TTL here would delete it before they did.
801
+ *
802
+ * Brought into being by the owner of the `telemetry.` prefix, the same rule as
803
+ * `monitoring.dlq` above: declared here, created by the service whose
804
+ * `initInfrastructureQueues()` call names it.
787
805
  */
788
806
  dlq: {
789
807
  durable: true,
@@ -891,7 +909,9 @@ module.exports = {
891
909
  * CRITICAL: This is the ONLY queue used for service-to-Registry communication.
892
910
  * Registry listener processes different message types based on msg.type:
893
911
  * - type: 'register' - Service registration requests (full spec + operations)
894
- * - type: 'heartbeat' - Periodic health check messages (sent every 10s)
912
+ * - type: 'heartbeat' - Periodic health check messages (cadence owned by the
913
+ * `heartbeatInterval` constructor option of RegistryClient,
914
+ * @onlineapps/conn-orch-registry - not declared here)
895
915
  * - type: 'deregister' - Voluntary shutdown notice
896
916
  * (The legacy 'apiDescription' / 'apiDescriptionRequest' types are no
897
917
  * longer supported — see operations-registry-contract.md §2.)
@@ -415,10 +415,11 @@ class RabbitMQClient extends EventEmitter {
415
415
 
416
416
  // Recovery worker — connection recovery, and the report of a queue that does
417
417
  // not exist. It creates none: queue creation left it with the two callbacks
418
- // that fed it (d.419).
418
+ // that fed it (d.419) and with `recoveryScope`, the permission they were
419
+ // filtered by (d.514). Which rule the report names is decided by the
420
+ // ownership class in the error, not by who built this client.
419
421
  this._recoveryWorker = new RecoveryWorker({
420
422
  client: this,
421
- scope: this._config.recoveryScope,
422
423
  logger: this._logger,
423
424
  });
424
425
 
@@ -696,6 +697,142 @@ class RabbitMQClient extends EventEmitter {
696
697
  return REFUSAL_REPLY_CODES.some((code) => message.includes(`server: ${code} (`));
697
698
  }
698
699
 
700
+ /**
701
+ * State the broker's refusal of a QUEUE the same way `_failFatally()` states
702
+ * its refusal of a CONNECTION — as a marker on the error, read by the caller
703
+ * that must decide what to do about it.
704
+ *
705
+ * Measured defect (d.577, sonda W532 against the live broker): a queue that
706
+ * already exists with other arguments answers `assertQueue()` with `406
707
+ * PRECONDITION-FAILED`, and `_prepareQueueForConsume()` built a `ConsumeError`
708
+ * for it with no `code` and no `reason`. The reply code survived in `cause`
709
+ * alone — and one wrapping further up (`@onlineapps/conn-orch-registry`) in
710
+ * `cause.cause`, where nothing looks. `@onlineapps/service-wrapper` ends FÁZE
711
+ * 0.7 permanently only on `code: 'MQ_CONNECTION_FATAL'`, so a 406 on a queue
712
+ * was classified TRANSIENT and the service went on booting against a queue it
713
+ * will never be allowed to declare. Every attempt gets the same answer.
714
+ *
715
+ * ONE vocabulary for one fact, therefore: the marker is the one `_failFatally()`
716
+ * already writes for `broker-refused` (d.295/d.490), not a second spelling of
717
+ * it. A refusal of the topology and a refusal of the connection ask the reader
718
+ * for the same thing — stop retrying, fix what the broker named — and the
719
+ * lifecycle owner reads one marker, not a list of them.
720
+ *
721
+ * What it deliberately does NOT do is `_failFatally()`: that declares the
722
+ * CONNECTION permanently lost and stands the client down. The connection here
723
+ * is healthy; it is this queue the broker refuses, and the publish path over
724
+ * the same connection has nothing to answer for.
725
+ *
726
+ * The decision is taken on the REPLY CODE (`_isBrokerRefusal()`,
727
+ * `REFUSAL_REPLY_CODES`), never on the wording of a sentence somebody may
728
+ * reword — the defect d.164 measured. The broker's own error is kept as
729
+ * `cause`, so its reply code stays readable.
730
+ *
731
+ * BOTH rails that talk to the broker about a queue go through this one
732
+ * function (d.582). `consume()` was the first; `assertQueue()` is the other
733
+ * half of the same fact, and it is the one FÁZE 0.8 walks — a service that
734
+ * declares its business queues gets the 406 there, not on the consume path.
735
+ * Two copies of one classification would be free to drift
736
+ * (`change-discipline.md` § One rail per concern), so `kind` decides only the
737
+ * SENTENCE and the class of the wrapper; the verdict is written once.
738
+ *
739
+ * @param {'consume'|'declare'} kind - which rail is answering.
740
+ * @param {string} queue - the queue the broker was asked about.
741
+ * @param {Error} err - what that rail threw.
742
+ * @returns {Error} the same error carrying the verdict, a typed error carrying
743
+ * it when the throw was the broker's raw one, or `err` untouched when the
744
+ * broker refused nothing.
745
+ * @private
746
+ */
747
+ _asRefusal(kind, queue, err) {
748
+ const brokerError = this._brokerRefusalCause(err);
749
+
750
+ if (!brokerError) return err;
751
+
752
+ // Already stated, and in the shape this rail hands out: the verdict is
753
+ // written once, not once per layer that passes it on.
754
+ const alreadyStated = err.code === 'MQ_CONNECTION_FATAL'
755
+ && (kind !== 'consume' || err instanceof ConsumeError);
756
+ if (alreadyStated) return err;
757
+
758
+ // An error this rail built itself — the 406 sentence of
759
+ // `_prepareQueueForConsume()` — is marked in place: it names the fix more
760
+ // precisely than anything this function could write.
761
+ if (kind === 'consume' && err instanceof ConsumeError) {
762
+ return this._markBrokerRefusal(err);
763
+ }
764
+
765
+ const refusal = kind === 'consume'
766
+ ? new ConsumeError(
767
+ `[RabbitMQClient] Cannot consume from queue "${queue}": the broker REFUSED the operation, `
768
+ + `so retrying cannot change the answer (reason: ${brokerError.message}). `
769
+ + 'Expected: an account allowed to use this queue, and a queue whose declaration is the one '
770
+ + 'queueConfig prescribes. '
771
+ + 'Fix: correct the permission or the topology the broker named, then restart the process; '
772
+ + 'error.cause carries the broker reply code.',
773
+ queue,
774
+ brokerError
775
+ )
776
+ : new Error(
777
+ `[RabbitMQClient] Cannot declare queue "${queue}": the broker REFUSED the declaration, `
778
+ + `so retrying cannot change the answer (reason: ${brokerError.message}). `
779
+ + 'Expected: the queue to exist with the arguments queueConfig prescribes, and an account '
780
+ + 'allowed to declare it. '
781
+ + 'Fix: align the declaration the broker named (406 means the queue exists with other '
782
+ + 'arguments) or correct the permission, then restart the process; '
783
+ + 'error.cause carries the broker reply code.',
784
+ { cause: brokerError }
785
+ );
786
+
787
+ if (kind !== 'consume') refusal.queue = queue;
788
+ return this._markBrokerRefusal(refusal);
789
+ }
790
+
791
+ /**
792
+ * The broker error behind a failure, or `null` when the broker refused nothing.
793
+ *
794
+ * One `cause` deep, because that is how far the rails here wrap: the
795
+ * declaration step throws the broker's error, and the sentence built around it
796
+ * keeps it as `cause`. The decision is taken on the REPLY CODE
797
+ * (`_isBrokerRefusal()`, `REFUSAL_REPLY_CODES`), never on the wording of a
798
+ * sentence somebody may reword — the defect d.164 measured.
799
+ *
800
+ * @param {Error|null} err
801
+ * @returns {Error|null} the error carrying the broker's reply code
802
+ * @private
803
+ */
804
+ _brokerRefusalCause(err) {
805
+ if (!err) return null;
806
+
807
+ // An error already carrying the verdict is NOT the broker's own — the
808
+ // broker's is one `cause` below it. Asked of the marked error, the text half
809
+ // of `_isBrokerRefusal()` would answer yes about the wrapper, because the
810
+ // sentence built here quotes the broker's message verbatim (measured while
811
+ // writing d.582: `error.cause.code` came back `'MQ_CONNECTION_FATAL'`
812
+ // instead of `406`). The code is asked first, so the quote cannot be
813
+ // mistaken for the answer.
814
+ if (err.code === 'MQ_CONNECTION_FATAL') {
815
+ return this._isBrokerRefusal(err.cause) ? err.cause : null;
816
+ }
817
+
818
+ if (this._isBrokerRefusal(err)) return err;
819
+ if (this._isBrokerRefusal(err.cause)) return err.cause;
820
+ return null;
821
+ }
822
+
823
+ /**
824
+ * Write the verdict. ONE spelling, the one `_failFatally()` uses.
825
+ *
826
+ * @param {Error} error
827
+ * @returns {Error} the same error, marked
828
+ * @private
829
+ */
830
+ _markBrokerRefusal(error) {
831
+ error.code = 'MQ_CONNECTION_FATAL';
832
+ error.reason = 'broker-refused';
833
+ return error;
834
+ }
835
+
699
836
  /**
700
837
  * A recovery cycle is over and the client is NOT connected, but the broker
701
838
  * never refused it — so this is not the end.
@@ -823,12 +960,27 @@ class RabbitMQClient extends EventEmitter {
823
960
  * confused with an ordinary transient error: the `connection:fatal` event and
824
961
  * the injected `onFatal` callback.
825
962
  *
963
+ * WHO IS TOLD, and why that is not always the same. The `connection:fatal`
964
+ * event and the injected `onFatal` callback exist for a loss that happens
965
+ * asynchronously, with nobody awaiting anything — the owner would otherwise
966
+ * never hear of it. On the BOOT path a caller IS awaiting `connect()`, so it
967
+ * learns by the throw, and announcing as well would be a second notification
968
+ * of one event: the harm `_reportRecoveryFailure()` documents a few methods
969
+ * up, and a crossing of the line the boot caller draws itself (a boot failure
970
+ * is the boot's outcome, not a lifecycle event — see the `BOOT budget` note in
971
+ * `@onlineapps/service-wrapper`'s `_initializeMQ()`). Hence `announce`. The
972
+ * verdict, the state and the sentence are identical either way; only the
973
+ * channel differs (d.295).
974
+ *
826
975
  * @param {Error} lastError - The error from the final failed attempt.
827
976
  * @param {'broker-refused'|'cycles-spent'} reason - Which of the two it is.
977
+ * @param {Object} [options] - Reporting options.
978
+ * @param {boolean} [options.announce=true] - Emit `connection:fatal` and call
979
+ * `onFatal`. False on a path whose caller is awaiting the throw.
828
980
  * @returns {Error} The fatal error, so callers can throw it.
829
981
  * @private
830
982
  */
831
- _failFatally(lastError, reason) {
983
+ _failFatally(lastError, reason, { announce = true } = {}) {
832
984
  this._connectionFatal = true;
833
985
  this._reconnecting = false;
834
986
  this._lastConnectionError = lastError || null;
@@ -855,16 +1007,21 @@ class RabbitMQClient extends EventEmitter {
855
1007
  fatal.cycles = this._reconnectCycles;
856
1008
  fatal.cause = lastError;
857
1009
 
1010
+ // The log line is the RECORD and is unconditional; the two lines below are
1011
+ // the NOTIFICATION, and only a caller that is not awaiting needs it.
858
1012
  this._logger.error(`[RabbitMQClient] [mq-client-core] ${fatal.message}`);
859
- this.emit('connection:fatal', fatal);
860
1013
 
861
- if (this._onFatal) {
862
- try {
863
- this._onFatal(fatal);
864
- } catch (err) {
865
- this._logger.error(
866
- `[RabbitMQClient] [mq-client-core] onFatal handler threw: ${err.message}`
867
- );
1014
+ if (announce) {
1015
+ this.emit('connection:fatal', fatal);
1016
+
1017
+ if (this._onFatal) {
1018
+ try {
1019
+ this._onFatal(fatal);
1020
+ } catch (err) {
1021
+ this._logger.error(
1022
+ `[RabbitMQClient] [mq-client-core] onFatal handler threw: ${err.message}`
1023
+ );
1024
+ }
868
1025
  }
869
1026
  }
870
1027
 
@@ -1327,6 +1484,25 @@ class RabbitMQClient extends EventEmitter {
1327
1484
  }
1328
1485
  this._connection = null;
1329
1486
  }
1487
+
1488
+ // The broker ANSWERED and refused — 403, 530, 406. The same verdict
1489
+ // `_reconnectWithBackoff()` reaches at runtime, on the same rail
1490
+ // (`_isBrokerRefusal`), because it is the same question: did anybody
1491
+ // answer? Until d.295 this catch rethrew whatever amqplib raised, with no
1492
+ // verdict on it, and the boot caller could only guess — `ServiceWrapper`
1493
+ // guessed "transient" and spent its whole connection budget, backoff and
1494
+ // all, on an answer no further attempt can change
1495
+ // (`architecture-principles.md` §4).
1496
+ //
1497
+ // Anything unrecognised still leaves here as it arrived, and that is the
1498
+ // owner's decision, not an omission: giving up forever on a broker that
1499
+ // never answered is forbidden
1500
+ // (`docs/governance/confirmations/mq-client-lifecycle-contract.md` 001
1501
+ // point 3), so only an ANSWERED refusal ends the attempts.
1502
+ if (this._isBrokerRefusal(err)) {
1503
+ throw this._failFatally(err, 'broker-refused', { announce: false });
1504
+ }
1505
+
1330
1506
  throw err;
1331
1507
  } finally {
1332
1508
  if (connectTimeoutTimer) {
@@ -2203,7 +2379,19 @@ class RabbitMQClient extends EventEmitter {
2203
2379
 
2204
2380
  const channel = await this._requireQueueChannel('assertQueue', queue);
2205
2381
 
2206
- return await channel.assertQueue(queue, queueOptions);
2382
+ try {
2383
+ return await channel.assertQueue(queue, queueOptions);
2384
+ } catch (err) {
2385
+ // The SAME verdict the consume rail states, through the SAME function
2386
+ // (d.582). This is the rail FÁZE 0.8 walks: a service declaring its own
2387
+ // business queues meets the 406 here, and until this batch the raw amqplib
2388
+ // error left with the reply code as a NUMBER and no classification at all,
2389
+ // so `@onlineapps/conn-orch-registry` had nothing to carry and
2390
+ // `@onlineapps/service-wrapper` — which reads `error.code` — called a
2391
+ // refusal transient. `_failFatally()` is deliberately not called: it is
2392
+ // this QUEUE the broker refuses, not the connection.
2393
+ throw this._asRefusal('declare', queue, err);
2394
+ }
2207
2395
  }
2208
2396
 
2209
2397
  /**
@@ -2993,8 +3181,8 @@ class RabbitMQClient extends EventEmitter {
2993
3181
  * declared with.
2994
3182
  *
2995
3183
  * ONE rail for the first `consume()` and for every re-registration after a
2996
- * channel recreate. It classifies the queue (reply / infrastructure /
2997
- * business), resolves the arguments from the central config, refuses a queue
3184
+ * channel recreate. It classifies the queue (infrastructure / business /
3185
+ * neither), resolves the arguments from the central config, refuses a queue
2998
3186
  * whose config declares no dead-letter route, and then CHECKS an
2999
3187
  * infrastructure queue (consumers never create those) or ASSERTS a business
3000
3188
  * one.
@@ -3014,15 +3202,24 @@ class RabbitMQClient extends EventEmitter {
3014
3202
  const durable = options.durable !== undefined ? options.durable : this._config.durable;
3015
3203
  let queueOptions = options.queueOptions || { durable };
3016
3204
 
3017
- // Skip assertQueue for reply queues (they're already created with specific settings)
3018
- // Reply queues start with 'rpc.reply.' and are created as non-durable
3019
- const isReplyQueue = queue.startsWith('rpc.reply.');
3020
3205
  // Classification is read once and used by both blocks below — the one that
3021
3206
  // resolves the central arguments, and the one that checks or asserts the queue.
3022
- const isInfraQueue = !isReplyQueue && queueConfig.isInfrastructureQueue(queue);
3023
- const isBusinessQueue = !isReplyQueue && queueConfig.isBusinessQueue(queue);
3207
+ //
3208
+ // There is no third class. Until d.514 a name starting `rpc.reply.` was
3209
+ // exempted here and again below: classification, declaration and the broker
3210
+ // question were all skipped, because the retired RPC reply path declared such
3211
+ // a queue itself, with its own non-durable lifetime. Both exemptions had been
3212
+ // unreachable since d.259: `queueConfig` classifies that name as nothing, so
3213
+ // it declares no dead-letter route, so the gate between the two blocks throws
3214
+ // first — measured against the live broker on a reply queue that really
3215
+ // existed (`tests/integration/dead-letter-route-required.integration.test.js`).
3216
+ // The four questions are answered in the CHANGELOG of that batch; the path the
3217
+ // exemption served is gone (`docs/governance/confirmations/mq-consumer-contract.md`
3218
+ // 003 point 3 — "`rpc.reply.*` is not a topology question … it is a removal
3219
+ // question", and `conn-infra-mq` removed `createTemporaryQueue()` in d.275b).
3220
+ const isInfraQueue = queueConfig.isInfrastructureQueue(queue);
3221
+ const isBusinessQueue = queueConfig.isBusinessQueue(queue);
3024
3222
 
3025
- if (!isReplyQueue) {
3026
3223
  // CRITICAL: Use queueConfig.js to get correct parameters (TTL, max-length, etc.)
3027
3224
  // This prevents 406 PRECONDITION-FAILED errors from TTL mismatches.
3028
3225
  // queueConfig is loaded once at the top of this module; it used to be required
@@ -3066,9 +3263,6 @@ class RabbitMQClient extends EventEmitter {
3066
3263
  : `[RabbitMQClient] [mq-client-core] [CONSUMER] Asserting business queue ${queue} with the declaration queueConfig owns`
3067
3264
  );
3068
3265
  }
3069
- // End of the queue-classification block. What follows applies to EVERY
3070
- // queue this consumer may attach to, reply queues included.
3071
- }
3072
3266
 
3073
3267
  // The dead-letter policy below rejects a spent message with
3074
3268
  // `nack(requeue=false)`, and the broker then moves it ONLY if the queue
@@ -3122,7 +3316,6 @@ class RabbitMQClient extends EventEmitter {
3122
3316
  );
3123
3317
  }
3124
3318
 
3125
- if (!isReplyQueue) {
3126
3319
  this._logger.debug(`[RabbitMQClient] [mq-client-core] [CONSUMER] Asserting queue ${queue} before consume() at ${new Date().toISOString()}`);
3127
3320
  this._logger.debug(`[RabbitMQClient] [mq-client-core] [CONSUMER] Queue options:`, JSON.stringify(queueOptions, null, 2));
3128
3321
  this._logger.debug(`[RabbitMQClient] [mq-client-core] [CONSUMER] _queueChannel state: exists=${!!this._queueChannel}, closed=${!this._isChannelAlive(this._queueChannel)}`);
@@ -3194,7 +3387,11 @@ class RabbitMQClient extends EventEmitter {
3194
3387
  } catch (assertErr) {
3195
3388
  // If queue exists with different arguments (406), this is a CRITICAL ERROR
3196
3389
  // We should NOT proceed - the root cause must be fixed
3197
- if (assertErr.code === 406) {
3390
+ // Read off the BROKER's reply code, which since d.582 travels as the
3391
+ // `cause` of the declaration rail's own refusal — never off the marker,
3392
+ // which says "refused" without saying which of the three codes it was.
3393
+ const brokerError = this._brokerRefusalCause(assertErr);
3394
+ if (brokerError && brokerError.code === 406) {
3198
3395
  this._logger.error(`[RabbitMQClient] [mq-client-core] [CONSUMER] ✗ CRITICAL: Queue ${queue} exists with different arguments!`);
3199
3396
  this._logger.error(`[RabbitMQClient] [mq-client-core] [CONSUMER] Error:`, assertErr.message);
3200
3397
  this._logger.error(`[RabbitMQClient] [mq-client-core] [CONSUMER] Expected options:`, JSON.stringify(queueOptions, null, 2));
@@ -3205,14 +3402,15 @@ class RabbitMQClient extends EventEmitter {
3205
3402
  + 'Fix: find the assertQueue() call that declared it without parameters and fix that call; '
3206
3403
  + 'the queue is NOT redeclared here, because doing so would hide the drift instead of ending it.',
3207
3404
  queue,
3208
- assertErr
3405
+ // The BROKER's error, not the declaration rail's wrapper around it:
3406
+ // one `cause` hop, and `error.cause.code` is the reply code (d.582).
3407
+ brokerError
3209
3408
  );
3210
3409
  }
3211
3410
  // Other error - rethrow
3212
3411
  throw assertErr;
3213
3412
  }
3214
3413
  }
3215
- }
3216
3414
 
3217
3415
  return queueOptions;
3218
3416
  }
@@ -3497,8 +3695,15 @@ class RabbitMQClient extends EventEmitter {
3497
3695
  // Return consumer tag for cancellation
3498
3696
  return consumeResult.consumerTag;
3499
3697
  } catch (err) {
3500
- this.emit('error', err);
3501
- throw err;
3698
+ // ONE place states the broker's verdict for this whole rail — the queue
3699
+ // classification, the declaration and the consumer registration alike —
3700
+ // because it is ONE question ("did the broker answer and refuse?") and a
3701
+ // copy of it per branch is free to drift (`change-discipline.md` § One rail
3702
+ // per concern). Anything the broker did not refuse leaves exactly as it
3703
+ // arrived (d.577).
3704
+ const refusal = this._asRefusal('consume', queue, err);
3705
+ this.emit('error', refusal);
3706
+ throw refusal;
3502
3707
  }
3503
3708
  }
3504
3709
 
@@ -3941,7 +4146,22 @@ class RabbitMQClient extends EventEmitter {
3941
4146
  }
3942
4147
 
3943
4148
  /**
3944
- * Check prefetch utilization for a queue and alert if threshold exceeded
4149
+ * Check prefetch utilization for a queue and alert if threshold exceeded.
4150
+ *
4151
+ * A window of ONE message has no utilisation to watch (d.550). Its only two
4152
+ * states are 0 % (idle) and 100 % (the consumer is doing the one piece of work
4153
+ * it is allowed), and 100 % is the normal state of a healthy consumer, not a
4154
+ * finding anybody can act on. The alarm therefore fired on EVERY delivery to
4155
+ * such a queue, whatever the threshold - `1/1 >= 0.8` holds exactly as
4156
+ * `1/1 >= 0.99` does. MEASURED (BIZ-pdfgen, BIZ-converter, twice per boot):
4157
+ * `Prefetch utilization high: queue 'biz-pdfgen.registry' has 1/1 messages
4158
+ * in-flight (100%, threshold: 80%)` - a warning that cannot fail to happen
4159
+ * says nothing, and it teaches the reader to skip the line where a real
4160
+ * saturation will one day stand.
4161
+ *
4162
+ * The threshold itself is unchanged and no new one is introduced: a window
4163
+ * larger than one message is measured exactly as before.
4164
+ *
3945
4165
  * @private
3946
4166
  * @param {string} queue - Queue name
3947
4167
  * @param {Object} tracking - Prefetch tracking object
@@ -3950,7 +4170,11 @@ class RabbitMQClient extends EventEmitter {
3950
4170
  if (!tracking || tracking.prefetchCount === 0) {
3951
4171
  return; // No prefetch set or tracking not available
3952
4172
  }
3953
-
4173
+
4174
+ if (tracking.prefetchCount <= 1) {
4175
+ return; // One-message window: 0 % or 100 %, and 100 % is the working state.
4176
+ }
4177
+
3954
4178
  const utilization = tracking.inFlight / tracking.prefetchCount;
3955
4179
 
3956
4180
  if (utilization >= this._prefetchUtilizationThreshold) {
@@ -27,13 +27,16 @@ const { assertLogger } = require('@onlineapps/logger-contract');
27
27
  * Čtyři otázky (`change-discipline.md` § Removing something removes its
28
28
  * declaration) jsou zodpovězené v CHANGELOGu dávky.
29
29
  *
30
- * `scope` tedy už o zakládání front nerozhoduje; říká jen, čí ten klient je.
30
+ * `scope` tedy už o zakládání front nerozhodoval; od d.419 neříkal nic a od d.514
31
+ * tu není. Co rozhoduje o tvaru hlášení, je třída vlastnictví, kterou odpověděla
32
+ * CENTRÁLNÍ deklarace — `error.kind` z `QueueNotFoundError` — takže dva klienti
33
+ * namířené na tutéž frontu nemohou dostat různou odpověď podle toho, kdo je
34
+ * postavil. Čtyři otázky k odstranění jsou zodpovězené v CHANGELOGu dávky.
31
35
  */
32
36
  class RecoveryWorker {
33
37
  /**
34
38
  * @param {Object} options
35
39
  * @param {Object} options.client - RabbitMQClient instance
36
- * @param {string} [options.scope='infrastructure'] - 'infrastructure' nebo 'business'
37
40
  * @param {Object} options.logger - Logger s info/warn/error/debug (povinny)
38
41
  */
39
42
  constructor(options = {}) {
@@ -45,7 +48,6 @@ class RecoveryWorker {
45
48
  }
46
49
 
47
50
  this._client = options.client;
48
- this._scope = options.scope || 'infrastructure';
49
51
  this._logger = assertLogger(
50
52
  'RecoveryWorker',
51
53
  options.logger,
@@ -98,9 +100,11 @@ class RecoveryWorker {
98
100
  // with default arguments and no TTL. Refusing at one door while opening the
99
101
  // other is not a guard.
100
102
  //
101
- // `_scope: 'business'` says the CLIENT belongs to a business service. It has
102
- // never meant "may create business queues", and ConnectorMQClient.js:74 sets
103
- // it for every biz service, so that reading made the rule vacuous.
103
+ // `recoveryScope: 'business'` said the CLIENT belongs to a business service.
104
+ // It never meant "may create business queues", and `ConnectorMQClient` set it
105
+ // for every biz service, so that reading made the rule vacuous. The key is
106
+ // gone with the door it guarded (d.514); what decides here is `error.kind`,
107
+ // which the central declaration answers.
104
108
  if (error.kind === 'business') {
105
109
  this._logger.error(`[RecoveryWorker] Cannot create business queue '${error.queueName}' - it is created by its owning service via setupServiceQueues() after registration`);
106
110
  throw error;