@onlineapps/mq-client-core 2.0.1 → 3.0.1

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.
@@ -1,18 +1,246 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * The platform's dead-letter exchange.
5
+ *
6
+ * A name three services must agree on has to have ONE owner, or it has as many
7
+ * as the places that type it: this file typed the name four times, and
8
+ * `@onlineapps/conn-infra-mq` reads it back out of a queue's arguments
9
+ * (`getBusinessQueueConfig('queue')['x-dead-letter-exchange']`) because there was
10
+ * nothing to import. Same reasoning as `src/index.js` § topology, which gave the
11
+ * queue names that travel on the wire one declaration each.
12
+ *
13
+ * Exported as `deadLetterExchange()` — a getter, not the bare constant, so an
14
+ * importer cannot rebind platform topology.
15
+ */
16
+ const DEAD_LETTER_EXCHANGE = 'dlx';
17
+
3
18
  /**
4
19
  * queueConfig.js
5
20
  *
6
- * Central configuration for INFRASTRUCTURE queues only.
7
- * Business queues ({service}.workflow, {service}.queue, {service}.dlq) are created
8
- * by individual services via QueueManager.setupServiceQueues().
21
+ * Central configuration for INFRASTRUCTURE queues, and for the TEMPLATES of the
22
+ * queues a business service owns. `{service}.workflow`, `{service}.queue` and
23
+ * `{service}.dlq` are created by the service itself via
24
+ * QueueManager.setupServiceQueues(); the registry-client templates are declared here
25
+ * for the same reason — one declaration per queue, which is what the dead-letter gate
26
+ * of consume() reads.
9
27
  *
10
28
  * This ensures consistent configuration for infrastructure queues across all services.
11
29
  *
12
30
  * NOTE: This is part of mq-client-core (spodní vrstva) because it's used by both:
13
31
  * - Infrastructure services (via mq-client-core)
14
32
  * - Business services (via conn-infra-mq connector)
33
+ *
34
+ * Every lookup below fails fast with a `ValidationError`: an unknown queue name is a
35
+ * caller mistake, not a runtime condition, and the package has one error rail
36
+ * (`utils/errorHandler.js`) rather than a mix of typed and bare errors.
37
+ */
38
+
39
+ const { ValidationError } = require('../utils/errorHandler');
40
+
41
+ /**
42
+ * The prefix each section's keys carry ON THE WIRE.
43
+ *
44
+ * A section name is NOT automatically the prefix of the names it declares, and
45
+ * assuming it was is the defect d.267 fixed: `deliveryEvents` declares three
46
+ * `delivery.` queues, so a lookup that stripped `delivery.` and read only the
47
+ * `delivery` section answered "not defined" for every one of them. The mapping is
48
+ * stated once, here, and every lookup and every message derives from it rather
49
+ * than repeating a list by hand (`single-source-of-truth.md`).
50
+ *
51
+ * `business` is absent on purpose: it holds TEMPLATES (`{service}.workflow`), not
52
+ * full names, so no prefix composes a wire name from it.
53
+ */
54
+ const SECTION_PREFIXES = Object.freeze({
55
+ workflow: 'workflow',
56
+ delivery: 'delivery',
57
+ deliveryEvents: 'delivery',
58
+ validation: 'validation',
59
+ infrastructure: 'infrastructure',
60
+ monitoring: 'monitoring',
61
+ telemetry: 'telemetry',
62
+ alerts: 'alert',
63
+ registry: 'registry'
64
+ });
65
+
66
+ /**
67
+ * Every infrastructure prefix and the lookup that answers for it — ONE map, read by
68
+ * the classifier, by the dispatch and by the refusal message alike.
69
+ *
70
+ * It is a map rather than three lists because of d.269: `isInfrastructureQueue()`
71
+ * accepted `telemetry.` while `getInfrastructureQueueConfig()` had no branch for it,
72
+ * so a telemetry name was classified as infrastructure and then refused as not being
73
+ * one — and the refusal listed four prefixes while the classifier knew seven, because
74
+ * the sentence was typed by hand. A prefix the classifier knows but nothing can look
75
+ * up is not expressible here (`single-source-of-truth.md`).
76
+ */
77
+ const INFRASTRUCTURE_PREFIX_LOOKUPS = Object.freeze({
78
+ workflow: 'getWorkflowQueueConfig',
79
+ registry: 'getRegistryQueueConfig',
80
+ infrastructure: 'getInfrastructureHealthQueueConfig',
81
+ validation: 'getValidationQueueConfig',
82
+ monitoring: 'getMonitoringQueueConfig',
83
+ telemetry: 'getTelemetryQueueConfig',
84
+ delivery: 'getDeliveryQueueConfig'
85
+ });
86
+
87
+ const INFRASTRUCTURE_PREFIXES = Object.freeze(Object.keys(INFRASTRUCTURE_PREFIX_LOOKUPS));
88
+
89
+ /** Every section declaring names under one prefix — derived, never a second list. */
90
+ const sectionsForPrefix = (prefix) =>
91
+ Object.keys(SECTION_PREFIXES).filter((section) => SECTION_PREFIXES[section] === prefix);
92
+
93
+ const DELIVERY_SECTIONS = sectionsForPrefix('delivery');
94
+
95
+ /** A `{placeholder}` segment of a business template key: matches any ONE name part. */
96
+ const TEMPLATE_PLACEHOLDER = /^\{[^.{}]+\}$/;
97
+
98
+ /**
99
+ * One business template key as a SHAPE: the parts a queue name must carry after the
100
+ * service name, with `null` where the template accepts anything (`registry.events`
101
+ * -> `['registry', 'events']`; a key writing `registry.{kind}` would give
102
+ * `['registry', null]`).
103
+ *
104
+ * A declared dead-letter routing key is written in the same notation
105
+ * (`{service}.dlq`), so `isDeadLetterTarget()` reads it through this one function
106
+ * rather than through a second placeholder rule.
107
+ */
108
+ const templateShape = (template) =>
109
+ template.split('.').map((part) => (TEMPLATE_PLACEHOLDER.test(part) ? null : part));
110
+
111
+ /**
112
+ * Match a queue name against the templates the `business` section declares.
113
+ *
114
+ * The section IS the list — d.278 removed the hand-typed `['workflow', 'queue',
115
+ * 'dlq']`, which had to be edited in step with the templates and therefore could
116
+ * disagree with them. A template nobody can express here is a queue nobody can
117
+ * classify, which is the state `{service}.registry` was in: declared nowhere,
118
+ * classified as neither kind, and so refused by the dead-letter gate of consume().
119
+ *
120
+ * @param {Object} business - The `business` section (templates keyed by their shape).
121
+ * @param {string} queueName - Full queue name.
122
+ * @returns {{serviceName: string, queueType: string}|null} The service and the
123
+ * template key that declares the queue's arguments, or `null` for a name no
124
+ * template matches.
125
+ * @throws {ValidationError} If two templates match one name - a defect in this file,
126
+ * never a runtime condition, and one that would otherwise be resolved by key order.
127
+ */
128
+ const matchBusinessTemplate = (business, queueName) => {
129
+ const parts = queueName.split('.');
130
+ if (parts.length < 2 || parts[0] === '') return null;
131
+
132
+ const tail = parts.slice(1);
133
+ const matched = Object.keys(business).filter((template) => {
134
+ const shape = templateShape(template);
135
+ return (
136
+ shape.length === tail.length
137
+ && shape.every((part, index) => (part === null ? tail[index] !== '' : part === tail[index]))
138
+ );
139
+ });
140
+
141
+ if (matched.length > 1) {
142
+ throw new ValidationError(
143
+ `[queueConfig] Business queue ${queueName} matches more than one template: ${matched.join(', ')} - `
144
+ + 'Expected: exactly one template declares a name, so one queue has one definition. '
145
+ + 'Fix: make the overlapping keys in the `business` section of src/config/queueConfig.js distinct.'
146
+ );
147
+ }
148
+
149
+ return matched.length === 1 ? { serviceName: parts[0], queueType: matched[0] } : null;
150
+ };
151
+
152
+ /**
153
+ * Every full wire name the infrastructure sections DECLARE.
154
+ *
155
+ * Derived from the sections themselves — `SECTION_PREFIXES` says which prefix each
156
+ * one puts on the wire, and only sections whose prefix has a lookup take part, so a
157
+ * name this set contains can always be resolved (the invariant d.269 established).
158
+ * `alerts` is therefore absent, exactly as it is today: nothing looks up `alert.`.
159
+ *
160
+ * @param {Object} config - The queueConfig object (its sections are read from `this`).
161
+ * @returns {Set<string>} The declared names, e.g. `registry.register`, `workflow.dlq`.
15
162
  */
163
+ const declaredInfrastructureNames = (config) => {
164
+ const names = new Set();
165
+
166
+ for (const [section, prefix] of Object.entries(SECTION_PREFIXES)) {
167
+ if (!Object.prototype.hasOwnProperty.call(INFRASTRUCTURE_PREFIX_LOOKUPS, prefix)) continue;
168
+ for (const key of Object.keys(config[section] || {})) {
169
+ names.add(`${prefix}.${key}`);
170
+ }
171
+ }
172
+
173
+ return names;
174
+ };
175
+
176
+ /**
177
+ * Every name this configuration dead-letters TO — the END of a chain.
178
+ *
179
+ * Derived from the declarations themselves: the value of every
180
+ * `x-dead-letter-routing-key` any section writes, placeholders and all
181
+ * (`{service}.dlq`, `workflow.failed`, `workflow.dlq`, `delivery.dlq`). A list of
182
+ * names typed here would have to be edited in step with the templates and could
183
+ * therefore disagree with them — the defect d.278 removed from
184
+ * `matchBusinessTemplate()`, and the reason `declaredInfrastructureNames()` above
185
+ * derives its set the same way.
186
+ *
187
+ * The `.dlq` family needs no rule of its own: every `.dlq` this configuration
188
+ * declares — `workflow.dlq`, `delivery.dlq`, `{service}.dlq` — is already the
189
+ * target of a declared routing key (measured 2026-09-14), so the set covers it.
190
+ * A `.dlq` nothing routes to would be a queue with no writer, which is a defect
191
+ * in the declaration, not a case to paper over here.
192
+ *
193
+ * @param {Object} config - The queueConfig object (its sections are read from `this`).
194
+ * @returns {Set<string>} The routing keys, e.g. `workflow.failed`, `{service}.dlq`.
195
+ */
196
+ const declaredDeadLetterTargets = (config) => {
197
+ const targets = new Set();
198
+
199
+ for (const section of Object.values(config)) {
200
+ if (section === null || typeof section !== 'object') continue;
201
+ for (const entry of Object.values(section)) {
202
+ if (entry === null || typeof entry !== 'object') continue;
203
+ const routingKey = entry.arguments && entry.arguments['x-dead-letter-routing-key'];
204
+ if (typeof routingKey === 'string' && routingKey !== '') targets.add(routingKey);
205
+ }
206
+ }
207
+
208
+ return targets;
209
+ };
210
+
211
+ /**
212
+ * The two ways out of "this configuration declares no such queue", named in the
213
+ * refusal itself.
214
+ *
215
+ * A name like `registry.foo` can be two different mistakes, and the message cannot
216
+ * tell which: an infrastructure queue nobody declared, or a queue owned by a service
217
+ * named `registry` written in a shape no template matches. Naming only the first sends
218
+ * the reader to add a platform queue for something a service owns — the very confusion
219
+ * d.279 came from. The service-owned alternatives are rendered from the `business`
220
+ * templates, never typed out, so the sentence cannot fall behind them.
221
+ *
222
+ * @param {Object} business - The `business` section.
223
+ * @param {string} queueName - The full name that was not found.
224
+ * @returns {string} The `Fix:` sentence (`architecture-principles.md` §5).
225
+ */
226
+ const waysOut = (business, queueName) => {
227
+ const parts = queueName.split('.');
228
+ const owner = parts[0];
229
+ const declare = 'Fix: declare the entry in src/config/queueConfig.js if the platform owns the queue';
230
+
231
+ // Only the templates a name of THIS shape could take: offering `registry.registry.events`
232
+ // as the fix for `registry.foo` would be a suggestion that cannot be followed.
233
+ const owned = Object.keys(business)
234
+ .filter((template) => template.split('.').length === parts.length - 1)
235
+ .map((template) => `${owner}.${template}`);
236
+
237
+ if (owned.length === 0) {
238
+ return `${declare}; no queue a service owns has this shape, so the name is not a service-owned one either.`;
239
+ }
240
+
241
+ return `${declare}, or — if the service named "${owner}" owns it — use one of the names that service's own `
242
+ + `queues take (${owned.join(', ')}), which this configuration already classifies.`;
243
+ };
16
244
 
17
245
  module.exports = {
18
246
  /**
@@ -65,23 +293,67 @@ module.exports = {
65
293
 
66
294
  /**
67
295
  * workflow.completed - Completed workflows
296
+ *
297
+ * Dead-letters to workflow.dlq over the DEFAULT exchange, where the routing key
298
+ * is the destination queue name — the shape workflow.init/workflow.control
299
+ * already use. The dispatcher rejects a message here on an invalid payload, a
300
+ * schema violation and a delivery failure; until d.278 the broker dropped each
301
+ * one, because the queue declared nowhere to put it.
302
+ *
303
+ * @see api/docs/governance/confirmations/mq-consumer-contract.md 003
68
304
  */
69
305
  completed: {
70
306
  durable: true,
71
307
  arguments: {
72
308
  'x-message-ttl': 300000, // 5 minutes TTL
73
- 'x-max-length': 10000
309
+ 'x-max-length': 10000,
310
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
311
+ 'x-dead-letter-routing-key': 'workflow.dlq'
312
+ }
313
+ },
314
+
315
+ /**
316
+ * workflow.delivery_requested - a run handed over to the Delivery Dispatcher.
317
+ *
318
+ * The hand-over is NOT the end of a run. A cookbook declaring `delivery.on_result`
319
+ * has one step left when the document is ready to leave the platform: the step that
320
+ * records how the delivery went. Publishing that hand-over on `workflow.completed`
321
+ * made the terminal message mean "not finished yet", and left the real end of such a
322
+ * run without a message at all. The owner gave the hand-over a name of its own:
323
+ * `workflow.delivery_requested`, carrying `status: "delivery_requested"`, while
324
+ * `workflow.completed` stays the single terminal message.
325
+ *
326
+ * Same arguments as `workflow.completed` — a hand-over is the same payload with a
327
+ * different verb, read by the same consumer, so it expires and caps the same way —
328
+ * and the same dead-letter destination: the mechanical rule of confirmation
329
+ * `mq-consumer-contract` 003 routes every queue of this family to `workflow.dlq`
330
+ * over the DEFAULT exchange, where the routing key IS the destination queue's name.
331
+ *
332
+ * @see api/docs/governance/confirmations/delivery-receipt-chain.md 002, 003
333
+ * @see api/docs/governance/confirmations/mq-consumer-contract.md 003
334
+ */
335
+ delivery_requested: {
336
+ durable: true,
337
+ arguments: {
338
+ 'x-message-ttl': 300000, // 5 minutes TTL, as workflow.completed
339
+ 'x-max-length': 10000,
340
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
341
+ 'x-dead-letter-routing-key': 'workflow.dlq'
74
342
  }
75
343
  },
76
344
 
77
345
  /**
78
346
  * workflow.failed - Failed workflows (DLQ entry point)
79
- * Workflows land here after max retries exhausted, waiting for human decision
347
+ * Workflows land here after max retries exhausted, waiting for human decision.
348
+ *
349
+ * No TTL: the queue is read by an operator, not by a service, and it has no
350
+ * dead-letter exchange — an expiring message is a deleted message.
351
+ *
352
+ * @see api/docs/governance/confirmations/mq-consumer-contract.md 002
80
353
  */
81
354
  failed: {
82
355
  durable: true,
83
356
  arguments: {
84
- 'x-message-ttl': 300000, // 5 minutes TTL
85
357
  'x-max-length': 10000
86
358
  }
87
359
  },
@@ -89,17 +361,28 @@ module.exports = {
89
361
  /**
90
362
  * workflow.discarded - Discarded workflows (final state)
91
363
  * Workflows land here after human decision to discard from DLQ
364
+ *
365
+ * Dead-letters to workflow.dlq, like every other queue of this family
366
+ * (confirmation mq-consumer-contract 003). The operator's own queues —
367
+ * workflow.failed and workflow.dlq — are the two that keep no onward route.
92
368
  */
93
369
  discarded: {
94
370
  durable: true,
95
371
  arguments: {
96
372
  'x-message-ttl': 300000, // 5 minutes TTL
97
- 'x-max-length': 10000
373
+ 'x-max-length': 10000,
374
+ 'x-dead-letter-exchange': '',
375
+ 'x-dead-letter-routing-key': 'workflow.dlq'
98
376
  }
99
377
  },
100
378
 
101
379
  /**
102
380
  * workflow.dlq - Dead letter queue for workflows
381
+ *
382
+ * The end of the road for this family: read by an operator through the DLQ
383
+ * dashboard contract, never forwarded onward. No TTL and no dead-letter route,
384
+ * for the same reason workflow.failed has neither — an expiring or forwarded
385
+ * message is one the operator never gets to decide about.
103
386
  */
104
387
  dlq: {
105
388
  durable: true,
@@ -111,9 +394,22 @@ module.exports = {
111
394
  },
112
395
 
113
396
  /**
114
- * Business queue templates
115
- * These are templates for service-specific queues ({service}.workflow, {service}.queue, {service}.dlq)
116
- * Used by QueueManager.setupServiceQueues() to ensure consistent configuration
397
+ * Business queue templates - every queue a business service OWNS.
398
+ *
399
+ * A key here is the queue name's tail after the service name, and it is read as a
400
+ * PATTERN: a `{placeholder}` segment matches any one part. That is what makes
401
+ * `isBusinessQueue()`/`parseBusinessQueue()` derive from this section instead of
402
+ * repeating a list of types by hand, and it is the shape
403
+ * docs/standards/queue-ownership.md prescribes - "infrastructure knows them by
404
+ * that pattern, never by a list of names".
405
+ *
406
+ * Every one of them dead-letters over `dlx` to `{service}.dlq`, the queue each
407
+ * service already has and the one binding d.198a left behind
408
+ * (api/docs/governance/confirmations/mq-consumer-contract.md 003).
409
+ *
410
+ * `{service}.workflow`, `{service}.queue` and `{service}.dlq` are created by
411
+ * QueueManager.setupServiceQueues(); the registry-client queues by
412
+ * @onlineapps/conn-orch-registry.
117
413
  */
118
414
  business: {
119
415
  /**
@@ -125,7 +421,7 @@ module.exports = {
125
421
  arguments: {
126
422
  'x-message-ttl': 300000, // 5 minutes TTL
127
423
  'x-max-length': 10000, // Max 10k messages
128
- 'x-dead-letter-exchange': 'dlx',
424
+ 'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
129
425
  'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
130
426
  }
131
427
  },
@@ -139,7 +435,7 @@ module.exports = {
139
435
  arguments: {
140
436
  'x-message-ttl': 30000, // 30 seconds TTL
141
437
  'x-max-length': 10000, // Max 10k messages
142
- 'x-dead-letter-exchange': 'dlx',
438
+ 'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
143
439
  'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
144
440
  }
145
441
  },
@@ -154,6 +450,41 @@ module.exports = {
154
450
  // No TTL for DLQ - messages should persist
155
451
  'x-max-length': 50000 // Higher limit for DLQ
156
452
  }
453
+ },
454
+
455
+ /**
456
+ * {service}.registry - The service's own queue for registry control messages
457
+ * (register.confirmed and the rest), asserted by @onlineapps/conn-orch-registry.
458
+ *
459
+ * No TTL and no length cap: that is what the queues carry on the broker today
460
+ * (measured 2026-09-12, every `<service>.registry` reports `arguments = {}`), and
461
+ * a value invented here would be a second truth about a running queue. What the
462
+ * template adds is the dead-letter route the queue never had.
463
+ */
464
+ registry: {
465
+ durable: true,
466
+ arguments: {
467
+ 'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
468
+ 'x-dead-letter-routing-key': '{service}.dlq' // Placeholder, replaced with actual service name
469
+ }
470
+ },
471
+
472
+ /**
473
+ * {service}.registry.events - The service's copy of the registry event fanout,
474
+ * bound to the `registry.changes` exchange by @onlineapps/conn-orch-registry.
475
+ *
476
+ * TTL and cap are the ones that connector asserts today, kept to the value, so
477
+ * moving it onto this template cannot produce a 406 on a queue that already
478
+ * exists with them.
479
+ */
480
+ 'registry.events': {
481
+ durable: true,
482
+ arguments: {
483
+ 'x-message-ttl': 60000, // 1 minute TTL - a stale index event is useless
484
+ 'x-max-length': 1000,
485
+ 'x-dead-letter-exchange': DEAD_LETTER_EXCHANGE,
486
+ 'x-dead-letter-routing-key': '{service}.dlq'
487
+ }
157
488
  }
158
489
  },
159
490
 
@@ -169,7 +500,9 @@ module.exports = {
169
500
  retry: {
170
501
  durable: true,
171
502
  arguments: {
172
- 'x-max-length': 10000 // Allow up to 10k pending retry messages
503
+ 'x-max-length': 10000, // Allow up to 10k pending retry messages
504
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
505
+ 'x-dead-letter-routing-key': 'delivery.dlq'
173
506
  }
174
507
  },
175
508
 
@@ -188,7 +521,28 @@ module.exports = {
188
521
  durable: true,
189
522
  arguments: {
190
523
  'x-message-ttl': 60000, // Notifications older than 60s are irrelevant
191
- 'x-max-length': 10000
524
+ 'x-max-length': 10000,
525
+ 'x-dead-letter-exchange': '',
526
+ 'x-dead-letter-routing-key': 'delivery.dlq'
527
+ }
528
+ },
529
+
530
+ /**
531
+ * delivery.dlq - Dead letter queue of the whole delivery family
532
+ *
533
+ * Every delivery queue — the dispatcher's retry and websocket queues, and the
534
+ * four event queues the endpoint consumes — rejects into this one, over the
535
+ * default exchange, so no binding has to exist for the route to work. Declared
536
+ * with the arguments every other dead-letter queue carries: durable, capped, no
537
+ * TTL, no onward route. The operator's dashboard matches `*.dlq`, which is why
538
+ * the family gets its own queue rather than sharing one with the rest of the
539
+ * infrastructure (confirmation mq-consumer-contract 003, variant A2 rejected).
540
+ */
541
+ dlq: {
542
+ durable: true,
543
+ arguments: {
544
+ // No TTL for DLQ - messages should persist
545
+ 'x-max-length': 50000 // Higher limit for DLQ
192
546
  }
193
547
  }
194
548
  },
@@ -233,6 +587,23 @@ module.exports = {
233
587
  'x-message-ttl': 10000, // 10 seconds TTL (prevent stale data)
234
588
  'x-max-length': 1000
235
589
  }
590
+ },
591
+
592
+ /**
593
+ * infrastructure.health.events - Fanout exchange for infrastructure health events
594
+ *
595
+ * Registry publishes; the monitoring consumer and the delivery endpoint each bind a
596
+ * queue of their own (`monitoring.infrastructure.health.events`,
597
+ * `delivery.health.events`). The name is a contract between THREE sides of one
598
+ * wire, which is why batch 243g-C removed the per-service env override: an override
599
+ * on one side loses the events silently. It then left the name as a private
600
+ * constant in each of the three services, so d.270 gave it an owner here and
601
+ * `src/index.js` publishes it.
602
+ */
603
+ 'health.events': {
604
+ type: 'exchange',
605
+ exchangeType: 'fanout',
606
+ durable: true
236
607
  }
237
608
  },
238
609
 
@@ -298,6 +669,56 @@ module.exports = {
298
669
  type: 'exchange',
299
670
  exchangeType: 'fanout',
300
671
  durable: true
672
+ },
673
+
674
+ /**
675
+ * monitoring.infrastructure.health.events - the monitoring consumer's own copy of
676
+ * the infrastructure health events, bound to the infrastructure.health.events
677
+ * fanout exchange. Declared with `durable` and nothing else, exactly as the
678
+ * consumer creates it.
679
+ */
680
+ 'infrastructure.health.events': {
681
+ durable: true
682
+ }
683
+ },
684
+
685
+ /**
686
+ * Telemetry queue configurations
687
+ *
688
+ * The OpenTelemetry intake of the monitoring consumer: every service publishes
689
+ * logs, metrics and spans to `telemetry.exchange` with a routing key carrying its
690
+ * own name, and these three queues are bound to it
691
+ * (`docs/standards/monitoring-queues.md`, `docs/architecture/monitoring.md`).
692
+ *
693
+ * The values are the ones the queues are created with today; this config is their
694
+ * declaration, so whoever asserts them reads it here rather than retyping it.
695
+ */
696
+ telemetry: {
697
+ /** telemetry.logs.queue - OpenTelemetry log records, drained into Loki. */
698
+ 'logs.queue': {
699
+ durable: true,
700
+ arguments: {
701
+ 'x-message-ttl': 60000, // 1 minute TTL for unprocessed messages
702
+ 'x-max-length': 100000 // Max 100k messages in queue
703
+ }
704
+ },
705
+
706
+ /** telemetry.metrics.queue - OpenTelemetry metric samples. */
707
+ 'metrics.queue': {
708
+ durable: true,
709
+ arguments: {
710
+ 'x-message-ttl': 60000,
711
+ 'x-max-length': 100000
712
+ }
713
+ },
714
+
715
+ /** telemetry.traces.queue - OpenTelemetry spans. */
716
+ 'traces.queue': {
717
+ durable: true,
718
+ arguments: {
719
+ 'x-message-ttl': 60000,
720
+ 'x-max-length': 100000
721
+ }
301
722
  }
302
723
  },
303
724
 
@@ -314,7 +735,9 @@ module.exports = {
314
735
  durable: true,
315
736
  arguments: {
316
737
  'x-message-ttl': 60000, // 60s TTL - real-time events, stale ones are useless
317
- 'x-max-length': 10000
738
+ 'x-max-length': 10000,
739
+ 'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
740
+ 'x-dead-letter-routing-key': 'delivery.dlq'
318
741
  }
319
742
  },
320
743
 
@@ -326,7 +749,9 @@ module.exports = {
326
749
  durable: true,
327
750
  arguments: {
328
751
  'x-message-ttl': 60000,
329
- 'x-max-length': 5000
752
+ 'x-max-length': 5000,
753
+ 'x-dead-letter-exchange': '',
754
+ 'x-dead-letter-routing-key': 'delivery.dlq'
330
755
  }
331
756
  },
332
757
 
@@ -338,7 +763,33 @@ module.exports = {
338
763
  durable: true,
339
764
  arguments: {
340
765
  'x-message-ttl': 300000, // 5 minutes - alerts are more important
341
- 'x-max-length': 5000
766
+ 'x-max-length': 5000,
767
+ 'x-dead-letter-exchange': '',
768
+ 'x-dead-letter-routing-key': 'delivery.dlq'
769
+ }
770
+ },
771
+
772
+ /**
773
+ * delivery.resource.events - Resource-changed events for WS clients
774
+ * Bound to the monitoring.resource exchange by the routing pattern
775
+ * `resource.changed.#` - the one binding of this family that is not a fanout
776
+ * (docs/standards/queue-ownership.md).
777
+ *
778
+ * The queue has existed on the broker since the endpoint started asserting it
779
+ * with `{ durable: true }` and nothing else; it was the only queue of the
780
+ * family this configuration did not declare, so the d.259 gate had nothing to
781
+ * read for it. The arguments are its sibling's — same TTL, same cap — because
782
+ * it carries the same kind of real-time event.
783
+ *
784
+ * @see api/docs/governance/confirmations/mq-consumer-contract.md 003
785
+ */
786
+ 'resource.events': {
787
+ durable: true,
788
+ arguments: {
789
+ 'x-message-ttl': 60000,
790
+ 'x-max-length': 10000,
791
+ 'x-dead-letter-exchange': '',
792
+ 'x-dead-letter-routing-key': 'delivery.dlq'
342
793
  }
343
794
  }
344
795
  },
@@ -398,6 +849,42 @@ module.exports = {
398
849
  }
399
850
  },
400
851
 
852
+ /**
853
+ * The full wire name of a declared entry: the section's prefix + the entry's key.
854
+ *
855
+ * Composed here, never retyped by a caller — and never composed from the section
856
+ * NAME, because a section name is not automatically a prefix (`deliveryEvents`
857
+ * declares `delivery.` names, `alerts` declares `alert.`). The entry must exist:
858
+ * a name this configuration does not declare would be asserted against the broker
859
+ * by whoever imported it, which is how a typo becomes a second queue.
860
+ *
861
+ * @param {string} section - Section holding the entry (e.g. 'deliveryEvents').
862
+ * @param {string} key - The entry's key within that section (e.g. 'health.events').
863
+ * @returns {string} The full name (e.g. 'delivery.health.events').
864
+ * @throws {ValidationError} If the section composes no wire name, or the entry is undeclared.
865
+ */
866
+ queueName(section, key) {
867
+ const prefix = SECTION_PREFIXES[section];
868
+
869
+ if (prefix === undefined) {
870
+ throw new ValidationError(
871
+ `[queueConfig] Cannot compose a name: section "${section}" declares no wire prefix - `
872
+ + `Expected: one of ${Object.keys(SECTION_PREFIXES).join(', ')}. `
873
+ + 'Fix: name a section that declares full queue names; "business" holds templates, not names.'
874
+ );
875
+ }
876
+
877
+ if (!this[section]?.[key]) {
878
+ throw new ValidationError(
879
+ `[queueConfig] Cannot compose a name: queueConfig declares no "${key}" under "${section}" - `
880
+ + `Expected: the entry to exist before its name is published. `
881
+ + 'Fix: declare it in src/config/queueConfig.js, or use the key the owning service declares.'
882
+ );
883
+ }
884
+
885
+ return `${prefix}.${key}`;
886
+ },
887
+
401
888
  /**
402
889
  * Get infrastructure queue configuration by type and name
403
890
  * @param {string} type - Queue type: 'workflow', 'registry'
@@ -407,7 +894,11 @@ module.exports = {
407
894
  getQueueConfig(type, name) {
408
895
  const config = this[type]?.[name];
409
896
  if (!config) {
410
- throw new Error(`Infrastructure queue config not found: ${type}.${name}`);
897
+ throw new ValidationError(
898
+ `[queueConfig] Infrastructure queue config not found: ${type}.${name} - `
899
+ + `Expected: queueConfig to define the "${name}" entry under "${type}". `
900
+ + waysOut(this.business, `${type}.${name}`)
901
+ );
411
902
  }
412
903
 
413
904
  // Deep clone to avoid modifying original
@@ -422,7 +913,10 @@ module.exports = {
422
913
  getWorkflowQueueConfig(queueName) {
423
914
  const parts = queueName.split('.');
424
915
  if (parts.length !== 2 || parts[0] !== 'workflow') {
425
- throw new Error(`Invalid workflow queue name: ${queueName}. Expected format: workflow.{name}`);
916
+ throw new ValidationError(
917
+ `[queueConfig] Invalid workflow queue name: ${queueName}. Expected format: workflow.{name} - `
918
+ + 'Fix: pass exactly two dot-separated parts whose first part is "workflow", e.g. "workflow.init".'
919
+ );
426
920
  }
427
921
  return this.getQueueConfig('workflow', parts[1]);
428
922
  },
@@ -435,37 +929,103 @@ module.exports = {
435
929
  getRegistryQueueConfig(queueName) {
436
930
  const parts = queueName.split('.');
437
931
  if (parts.length !== 2 || parts[0] !== 'registry') {
438
- throw new Error(`Invalid registry queue name: ${queueName}. Expected format: registry.{name}`);
932
+ throw new ValidationError(
933
+ `[queueConfig] Invalid registry queue name: ${queueName}. Expected format: registry.{name} - `
934
+ + 'Fix: pass exactly two dot-separated parts whose first part is "registry", e.g. "registry.register".'
935
+ );
439
936
  }
440
937
  return this.getQueueConfig('registry', parts[1]);
441
938
  },
442
939
 
443
940
  /**
444
- * Check if queue name is an infrastructure queue
941
+ * The infrastructure prefixes this configuration classifies by, in declaration
942
+ * order. Published so a caller — `initInfrastructureQueues()`, a queue-ownership
943
+ * probe, a document generator — reads the list instead of keeping a copy of it.
944
+ * @returns {ReadonlyArray<string>} Prefixes WITHOUT the trailing dot.
945
+ */
946
+ infrastructurePrefixes() {
947
+ return INFRASTRUCTURE_PREFIXES;
948
+ },
949
+
950
+ /**
951
+ * Check if queue name is an infrastructure queue.
952
+ *
953
+ * Three questions, in this order, because a prefix alone answers the wrong one:
954
+ *
955
+ * 1. **Does an infrastructure section declare this exact name?** Then it is
956
+ * infrastructure, whatever else it resembles — the declaration is the platform's
957
+ * own statement about that queue (`workflow.dlq`, `delivery.dlq`).
958
+ * 2. **Does it match a business template?** Then a service owns it. `registry` is a
959
+ * legal service name — `docs/biz/60-templates/naming.md` § Infrastructure Services
960
+ * gives it as the example — and the service by that name has `registry.workflow`,
961
+ * `registry.queue` and `registry.dlq` on the broker today, with exactly the
962
+ * arguments the templates prescribe (measured 2026-09-13). Before d.279 the
963
+ * `registry.` prefix claimed them for the platform, the registry lookup then had no
964
+ * entry for `queue`/`workflow`/`dlq`, and so `getDeadLetterRoute()` threw for a
965
+ * queue whose route is declared — which the d.259 consume gate reads as "no route",
966
+ * refusing the owner's own consumer.
967
+ * 3. **Otherwise, does it carry an infrastructure prefix?** Then it is infrastructure
968
+ * TERRITORY: declared nowhere, but a publisher must still refuse to auto-create it
969
+ * with default arguments rather than treat it as an ordinary name
970
+ * (`rabbitmqClient.js`, the 404 branch). The lookup that owns the prefix is what
971
+ * reports the missing declaration.
972
+ *
445
973
  * @param {string} queueName - Queue name to check
446
974
  * @returns {boolean} True if infrastructure queue
447
975
  */
448
976
  isInfrastructureQueue(queueName) {
449
- return queueName.startsWith('workflow.') ||
450
- queueName.startsWith('registry.') ||
451
- queueName.startsWith('infrastructure.') ||
452
- queueName.startsWith('validation.') ||
453
- queueName.startsWith('monitoring.') ||
454
- queueName.startsWith('telemetry.') ||
455
- queueName.startsWith('delivery.');
977
+ if (declaredInfrastructureNames(this).has(queueName)) {
978
+ return true;
979
+ }
980
+
981
+ if (matchBusinessTemplate(this.business, queueName) !== null) {
982
+ return false;
983
+ }
984
+
985
+ return INFRASTRUCTURE_PREFIXES.some((prefix) => queueName.startsWith(`${prefix}.`));
456
986
  },
457
987
 
458
988
  /**
459
- * Get delivery queue configuration
460
- * @param {string} queueName - Queue name (e.g., 'delivery.retry')
989
+ * Get delivery queue configuration.
990
+ *
991
+ * The `delivery.` prefix is declared by TWO sections — `delivery` (queues the
992
+ * dispatcher owns) and `deliveryEvents` (queues the delivery endpoint consumes) —
993
+ * so the lookup searches both. Which section holds a name is authoring history;
994
+ * the prefix is what travels on the wire, and a caller knows only that.
995
+ *
996
+ * @param {string} queueName - Queue name (e.g., 'delivery.retry', 'delivery.health.events')
461
997
  * @returns {Object} Queue configuration
998
+ * @throws {ValidationError} If no section declares the name, or — a defect, not a
999
+ * runtime condition — if more than one does.
462
1000
  */
463
1001
  getDeliveryQueueConfig(queueName) {
464
1002
  if (!queueName.startsWith('delivery.')) {
465
- throw new Error(`Queue ${queueName} is not a delivery queue`);
1003
+ throw new ValidationError(
1004
+ `[queueConfig] Queue ${queueName} is not a delivery queue - Expected: a name starting with "delivery.". `
1005
+ + 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
1006
+ );
1007
+ }
1008
+ const name = queueName.slice('delivery.'.length);
1009
+ const sections = DELIVERY_SECTIONS.filter((section) => this[section]?.[name]);
1010
+
1011
+ if (sections.length > 1) {
1012
+ throw new ValidationError(
1013
+ `[queueConfig] Delivery queue ${queueName} is declared in more than one section: ${sections.join(', ')} - `
1014
+ + 'Expected: exactly one section declares a name, so one queue has one definition. '
1015
+ + 'Fix: delete the duplicate entry in src/config/queueConfig.js.'
1016
+ );
1017
+ }
1018
+
1019
+ if (sections.length === 0) {
1020
+ throw new ValidationError(
1021
+ `[queueConfig] Infrastructure queue config not found: ${queueName} - `
1022
+ + `Expected: queueConfig to define the "${name}" entry under one of the delivery sections `
1023
+ + `(${DELIVERY_SECTIONS.join(', ')}). `
1024
+ + waysOut(this.business, queueName)
1025
+ );
466
1026
  }
467
- const name = queueName.replace('delivery.', '');
468
- return this.getQueueConfig('delivery', name);
1027
+
1028
+ return this.getQueueConfig(sections[0], name);
469
1029
  },
470
1030
 
471
1031
  /**
@@ -475,39 +1035,48 @@ module.exports = {
475
1035
  */
476
1036
  getValidationQueueConfig(queueName) {
477
1037
  if (!queueName.startsWith('validation.')) {
478
- throw new Error(`Queue ${queueName} is not a validation queue`);
1038
+ throw new ValidationError(
1039
+ `[queueConfig] Queue ${queueName} is not a validation queue - Expected: a name starting with "validation.". `
1040
+ + 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
1041
+ );
479
1042
  }
480
1043
  const name = queueName.replace('validation.', '');
481
1044
  return this.getQueueConfig('validation', name);
482
1045
  },
483
1046
 
484
1047
  /**
485
- * Check if queue name is a business queue
1048
+ * Check if queue name is a business queue - a queue a business service OWNS.
1049
+ *
1050
+ * Answered from the templates the `business` section declares, so the classifier
1051
+ * and the arguments can never disagree: `{service}.workflow`, `{service}.queue`,
1052
+ * `{service}.dlq` and the two registry-client queues.
1053
+ *
486
1054
  * @param {string} queueName - Queue name to check
487
1055
  * @returns {boolean} True if business queue
488
1056
  */
489
1057
  isBusinessQueue(queueName) {
490
- // Business queues follow pattern: {service}.{type}
491
- // where type is: workflow, queue, dlq
492
- const parts = queueName.split('.');
493
- if (parts.length !== 2) return false;
494
- return ['workflow', 'queue', 'dlq'].includes(parts[1]);
1058
+ return this.parseBusinessQueue(queueName) !== null;
495
1059
  },
496
1060
 
497
1061
  /**
498
1062
  * Parse business queue name to extract service name and queue type
1063
+ *
1064
+ * A name an infrastructure section DECLARES is never a business queue, however well
1065
+ * it fits a template: `workflow.dlq`, `delivery.dlq` and `monitoring.workflow` all
1066
+ * match one, and all three are declared platform queues. Until d.279 both classifiers
1067
+ * answered `true` for them, so which arguments such a queue got was decided by the
1068
+ * order of branches in whichever caller asked.
1069
+ *
499
1070
  * @param {string} queueName - Business queue name (e.g., 'hello-service.workflow')
500
- * @returns {Object} { serviceName, queueType } or null if not a business queue
1071
+ * @returns {{serviceName: string, queueType: string}|null} The service and the
1072
+ * template key to pass to getBusinessQueueConfig(), or null if not a business queue
501
1073
  */
502
1074
  parseBusinessQueue(queueName) {
503
- if (!this.isBusinessQueue(queueName)) {
1075
+ if (declaredInfrastructureNames(this).has(queueName)) {
504
1076
  return null;
505
1077
  }
506
- const parts = queueName.split('.');
507
- return {
508
- serviceName: parts[0],
509
- queueType: parts[1]
510
- };
1078
+
1079
+ return matchBusinessTemplate(this.business, queueName);
511
1080
  },
512
1081
 
513
1082
  /**
@@ -519,7 +1088,12 @@ module.exports = {
519
1088
  getBusinessQueueConfig(queueType, serviceName) {
520
1089
  const template = this.business?.[queueType];
521
1090
  if (!template) {
522
- throw new Error(`Business queue template not found: ${queueType}`);
1091
+ throw new ValidationError(
1092
+ `[queueConfig] Business queue template not found: ${queueType} - `
1093
+ + `Expected: one of the declared business queue templates (${Object.keys(this.business).join(', ')}). `
1094
+ + 'Fix: pass a declared template key - parseBusinessQueue() returns it for any business queue name - '
1095
+ + 'or add the template to the `business` section of src/config/queueConfig.js.'
1096
+ );
523
1097
  }
524
1098
 
525
1099
  // Deep clone to avoid modifying original
@@ -544,7 +1118,10 @@ module.exports = {
544
1118
  getInfrastructureHealthQueueConfig(queueName) {
545
1119
  const parts = queueName.split('.');
546
1120
  if (parts.length < 2 || parts[0] !== 'infrastructure') {
547
- throw new Error(`Invalid infrastructure health queue name: ${queueName}. Expected format: infrastructure.{name}`);
1121
+ throw new ValidationError(
1122
+ `[queueConfig] Invalid infrastructure health queue name: ${queueName}. Expected format: infrastructure.{name} - `
1123
+ + 'Fix: pass at least two dot-separated parts whose first part is "infrastructure", e.g. "infrastructure.health.checks".'
1124
+ );
548
1125
  }
549
1126
  const name = parts.slice(1).join('.');
550
1127
  return this.getQueueConfig('infrastructure', name);
@@ -557,32 +1134,213 @@ module.exports = {
557
1134
  */
558
1135
  getMonitoringQueueConfig(queueName) {
559
1136
  if (!queueName.startsWith('monitoring.')) {
560
- throw new Error(`Queue ${queueName} is not a monitoring queue`);
1137
+ throw new ValidationError(
1138
+ `[queueConfig] Queue ${queueName} is not a monitoring queue - Expected: a name starting with "monitoring.". `
1139
+ + 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
1140
+ );
561
1141
  }
562
1142
  const name = queueName.replace('monitoring.', '');
563
1143
  return this.getQueueConfig('monitoring', name);
564
1144
  },
565
1145
 
1146
+ /**
1147
+ * Get telemetry queue configuration
1148
+ * @param {string} queueName - Queue name (e.g., 'telemetry.logs.queue')
1149
+ * @returns {Object} Queue configuration
1150
+ */
1151
+ getTelemetryQueueConfig(queueName) {
1152
+ if (!queueName.startsWith('telemetry.')) {
1153
+ throw new ValidationError(
1154
+ `[queueConfig] Queue ${queueName} is not a telemetry queue - Expected: a name starting with "telemetry.". `
1155
+ + 'Fix: call getInfrastructureQueueConfig(), which routes each prefix to the right lookup.'
1156
+ );
1157
+ }
1158
+ const name = queueName.slice('telemetry.'.length);
1159
+ return this.getQueueConfig('telemetry', name);
1160
+ },
1161
+
1162
+ /**
1163
+ * The COMPLETE declaration a queue is brought into being with — `durable` and
1164
+ * every argument, dead-letter route included.
1165
+ *
1166
+ * ONE owner of the mapping name → options. Every path that declares a queue
1167
+ * reads it here and nowhere else: the public `assertQueue()` of the transport
1168
+ * (and through it `RecoveryWorker.createQueue()`, the cookbook-router's
1169
+ * QueueManager and the publish path's 404 branch), and the consumer's
1170
+ * `_prepareQueueForConsume()`.
1171
+ *
1172
+ * Until d.410 those two worked the same three lines out separately —
1173
+ * `{ durable: cfg.durable !== false, arguments: { ...cfg.arguments } }`, once
1174
+ * per path — and the consumer then declared on the raw queue channel instead of
1175
+ * the public rail. Two implementations of one concern
1176
+ * (`change-discipline.md` § One rail per concern). They agreed on the config of
1177
+ * the day, so nothing failed; what they cost is the guarantee: edit one copy and
1178
+ * the other keeps declaring the old arguments, and whichever declarer the broker
1179
+ * hears second is answered `406 PRECONDITION_FAILED` — the exact failure the
1180
+ * central config exists to prevent, and the one a service hits at boot, when its
1181
+ * consumer meets a queue somebody else already declared differently.
1182
+ *
1183
+ * Ownership is NOT decided here. This answers only "what does the declaration
1184
+ * say", never "who may declare it": the publish path still refuses to create an
1185
+ * infrastructure or business queue (`docs/standards/queue-ownership.md`), and
1186
+ * the consumer still CHECKS an infrastructure queue rather than asserting it.
1187
+ *
1188
+ * @param {string} queueName - Full queue name.
1189
+ * @returns {{durable: boolean, arguments: Object}|null} A fresh copy of the
1190
+ * declaration, or `null` when no section declares this name — there is no
1191
+ * declaration to hand out, and the caller that needs one says so itself.
1192
+ * @throws {ValidationError} If the name belongs to a section that has no entry
1193
+ * for it. A missing DEFINITION is reported by the lookup that owns it, never
1194
+ * flattened into "nothing declares it" — same contract as
1195
+ * `getDeadLetterRoute()` below.
1196
+ */
1197
+ declarationOptions(queueName) {
1198
+ let config;
1199
+
1200
+ if (this.isInfrastructureQueue(queueName)) {
1201
+ config = this.getInfrastructureQueueConfig(queueName);
1202
+ } else if (this.isBusinessQueue(queueName)) {
1203
+ const parsed = this.parseBusinessQueue(queueName);
1204
+ config = this.getBusinessQueueConfig(parsed.queueType, parsed.serviceName);
1205
+ } else {
1206
+ return null;
1207
+ }
1208
+
1209
+ return {
1210
+ durable: config.durable !== false,
1211
+ arguments: { ...config.arguments }
1212
+ };
1213
+ },
1214
+
1215
+ /**
1216
+ * The dead-letter route this configuration declares for a queue.
1217
+ *
1218
+ * The DECLARATION is the only source of truth available on the consumer path.
1219
+ * The broker cannot be asked: AMQP answers `checkQueue()` with `queue.declare-ok`,
1220
+ * which carries `{ queue, messageCount, consumerCount }` and no arguments at all
1221
+ * (measured against the live broker, 2026-09-12), so a consumer has no way to
1222
+ * read a running queue's `x-dead-letter-*` over the channel it consumes on.
1223
+ * Whoever declares a queue here declares its route here.
1224
+ *
1225
+ * A route is BOTH parts. With `x-dead-letter-exchange: ''` — the default
1226
+ * exchange, which the shared workflow queues use — the routing key IS the
1227
+ * destination queue name, so a declaration without it would send a rejected
1228
+ * message back to the queue it came from: a loop, not a dead-letter route.
1229
+ *
1230
+ * @param {string} queueName - Full queue name.
1231
+ * @returns {{exchange: string, routingKey: string}|null} The declared route, or
1232
+ * `null` when this configuration declares none — including a name no section
1233
+ * classifies, such as a temporary `rpc.reply.*` queue.
1234
+ * @throws {ValidationError} If the name belongs to a section that has no entry
1235
+ * for it. A missing DEFINITION is reported by the lookup that owns it, never
1236
+ * flattened into "declares no route".
1237
+ */
1238
+ getDeadLetterRoute(queueName) {
1239
+ let args;
1240
+
1241
+ if (this.isInfrastructureQueue(queueName)) {
1242
+ args = this.getInfrastructureQueueConfig(queueName).arguments;
1243
+ } else if (this.isBusinessQueue(queueName)) {
1244
+ const parsed = this.parseBusinessQueue(queueName);
1245
+ args = this.getBusinessQueueConfig(parsed.queueType, parsed.serviceName).arguments;
1246
+ } else {
1247
+ return null;
1248
+ }
1249
+
1250
+ if (args === null || typeof args !== 'object') {
1251
+ return null;
1252
+ }
1253
+
1254
+ const exchange = args['x-dead-letter-exchange'];
1255
+ const routingKey = args['x-dead-letter-routing-key'];
1256
+
1257
+ if (typeof exchange !== 'string' || typeof routingKey !== 'string' || routingKey === '') {
1258
+ return null;
1259
+ }
1260
+
1261
+ return { exchange, routingKey };
1262
+ },
1263
+
1264
+ /**
1265
+ * Is this queue the END of a dead-letter chain — a queue something
1266
+ * dead-letters TO?
1267
+ *
1268
+ * The counterpart of `getDeadLetterRoute()`, and the reason a queue may have no
1269
+ * route without that being a defect. `workflow.failed` is named in
1270
+ * `x-dead-letter-routing-key` by `workflow.init` and `workflow.control`;
1271
+ * `{service}.dlq` by every business template; `workflow.dlq` and `delivery.dlq`
1272
+ * by their own families. Giving such a queue a route of its own would either
1273
+ * close a loop or start an endless chain, which is why the confirmed topology
1274
+ * leaves them without one (`docs/governance/confirmations/mq-consumer-contract.md`
1275
+ * 002 point 2, 003 point 2 — "workflow.failed and workflow.dlq themselves keep
1276
+ * no route").
1277
+ *
1278
+ * The consumer's dead-letter gate reads this second question, so a terminal
1279
+ * queue is consumable while every OTHER queue with no declared route stays
1280
+ * refused (`transports/rabbitmqClient.js`, d.452). What happens to a message
1281
+ * that fails there is unchanged and stated in the README: the budget is spent,
1282
+ * `nack(requeue=false)` is issued, the broker DROPS the message — the end of the
1283
+ * chain is the end — and the loss is logged as an error rather than turned into
1284
+ * an endless requeue.
1285
+ *
1286
+ * Derived from the templates (`declaredDeadLetterTargets()`), never from a list
1287
+ * of names: a `{placeholder}` segment matches any one part, exactly as a
1288
+ * business template key does.
1289
+ *
1290
+ * @param {string} queueName - Full queue name.
1291
+ * @returns {boolean} True if this configuration dead-letters to this name.
1292
+ */
1293
+ isDeadLetterTarget(queueName) {
1294
+ if (typeof queueName !== 'string' || queueName === '') return false;
1295
+
1296
+ const parts = queueName.split('.');
1297
+
1298
+ for (const target of declaredDeadLetterTargets(this)) {
1299
+ const shape = templateShape(target);
1300
+ if (shape.length !== parts.length) continue;
1301
+ if (shape.every((part, index) => (part === null ? parts[index] !== '' : part === parts[index]))) {
1302
+ return true;
1303
+ }
1304
+ }
1305
+
1306
+ return false;
1307
+ },
1308
+
566
1309
  /**
567
1310
  * Get infrastructure queue configuration by queue name (auto-detect type)
1311
+ *
1312
+ * The prefix decides the lookup, read from `INFRASTRUCTURE_PREFIX_LOOKUPS` — the
1313
+ * same map `isInfrastructureQueue()` classifies by, so the two can never disagree.
1314
+ *
568
1315
  * @param {string} queueName - Full queue name (e.g., 'workflow.init', 'registry.register', 'infrastructure.health.checks', 'monitoring.workflow')
569
1316
  * @returns {Object} Queue configuration
570
1317
  */
571
1318
  getInfrastructureQueueConfig(queueName) {
572
- if (queueName.startsWith('workflow.')) {
573
- return this.getWorkflowQueueConfig(queueName);
574
- } else if (queueName.startsWith('registry.')) {
575
- return this.getRegistryQueueConfig(queueName);
576
- } else if (queueName.startsWith('infrastructure.')) {
577
- return this.getInfrastructureHealthQueueConfig(queueName);
578
- } else if (queueName.startsWith('monitoring.')) {
579
- return this.getMonitoringQueueConfig(queueName);
580
- } else if (queueName.startsWith('validation.')) {
581
- return this.getValidationQueueConfig(queueName);
582
- } else if (queueName.startsWith('delivery.')) {
583
- return this.getDeliveryQueueConfig(queueName);
584
- } else {
585
- throw new Error(`Queue ${queueName} is not an infrastructure queue. Infrastructure queues must start with 'workflow.', 'registry.', 'infrastructure.', or 'monitoring.'`);
1319
+ const prefix = INFRASTRUCTURE_PREFIXES.find((candidate) => queueName.startsWith(`${candidate}.`));
1320
+
1321
+ if (prefix === undefined) {
1322
+ throw new ValidationError(
1323
+ `[queueConfig] Queue ${queueName} is not an infrastructure queue. Infrastructure queues must start with `
1324
+ + `${INFRASTRUCTURE_PREFIXES.map((name) => `'${name}.'`).join(', ')} - `
1325
+ + 'Fix: use isBusinessQueue()/getBusinessQueueConfig() for a service-owned queue, or correct the prefix.'
1326
+ );
586
1327
  }
1328
+
1329
+ return this[INFRASTRUCTURE_PREFIX_LOOKUPS[prefix]](queueName);
1330
+ },
1331
+
1332
+ /**
1333
+ * The platform's dead-letter exchange, as the one thing to import instead of
1334
+ * the literal.
1335
+ *
1336
+ * Every business template routes here (`x-dead-letter-exchange`), and so does
1337
+ * `getDeadLetterRoute()` for a business queue. `@onlineapps/conn-infra-mq` binds
1338
+ * `<service>.dlq` to it and had to read the name back out of a queue's arguments
1339
+ * for want of an export.
1340
+ *
1341
+ * @returns {string}
1342
+ */
1343
+ deadLetterExchange() {
1344
+ return DEAD_LETTER_EXCHANGE;
587
1345
  }
588
1346
  };