@onlineapps/conn-orch-registry 4.0.3 → 5.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/README.md CHANGED
@@ -76,7 +76,15 @@ await client.register({
76
76
  health: '/health'
77
77
  });
78
78
 
79
- client.startHeartbeat();
79
+ // The give-up budget is required: how many missed beats mean the service can no
80
+ // longer prove it is alive, and what happens then. Neither is defaulted here.
81
+ client.startHeartbeat({
82
+ maxFailures: 5,
83
+ onFatal: (err) => {
84
+ logger.error(err.message);
85
+ process.exit(1); // the restart policy boots it again and it re-registers
86
+ }
87
+ });
80
88
 
81
89
  // Graceful shutdown
82
90
  process.on('SIGINT', async () => {
@@ -17,14 +17,14 @@ Microservice Registry Backend
17
17
  | (setup connection) | |
18
18
  | | |
19
19
  |-- register() ----------->| |
20
- | (full spec: endpoints | |
21
- | + operations + meta) |-- validate() --------->|
20
+ | (endpoints + | |
21
+ | operations + meta) |-- validate() --------->|
22
22
  | | (check if tested) |
23
23
  | |<-- validation result --|
24
24
  | | |
25
25
  |<-- register.confirmed ---| |
26
26
  | | |
27
- |-- startHeartbeat() ----->| |
27
+ |-- startHeartbeat({…}) -->| |
28
28
  | (if validated OK) | |
29
29
  ```
30
30
 
@@ -70,13 +70,15 @@ async function initializeService() {
70
70
  author: 'Development Team',
71
71
  documentation: 'https://docs.example.com/users'
72
72
  },
73
- health: '/health',
74
- spec: openApiSpec // Optional: OpenAPI specification object
73
+ health: '/health'
75
74
  });
76
75
 
77
- // Step 3: If registration successful, start heartbeat
76
+ // Step 3: If registration successful, start heartbeat with its give-up budget
78
77
  if (registrationResult.success) {
79
- registryClient.startHeartbeat();
78
+ registryClient.startHeartbeat({
79
+ maxFailures: 5,
80
+ onFatal: (err) => { console.error(err.message); process.exit(1); }
81
+ });
80
82
  console.log('Service registered and activated');
81
83
  } else {
82
84
  console.error('Registration failed:', registrationResult.message);
@@ -135,11 +137,25 @@ against the Operations Registry contract and either confirms or rejects.
135
137
 
136
138
  **Returns:** Registration result object with `success` status
137
139
 
138
- #### `startHeartbeat()`
139
- Starts sending periodic heartbeat messages. Should only be called after successful registration.
140
+ #### `startHeartbeat({ maxFailures, onFatal })`
141
+ Starts sending periodic heartbeat messages at the cadence the constructor was given. Should only be called
142
+ after successful registration.
143
+
144
+ Both options are REQUIRED and neither is defaulted:
145
+
146
+ - `maxFailures` — a positive whole number of **consecutive** failed beats. One success resets the count.
147
+ - `onFatal(err)` — called once, with the error naming the count and the last cause, at the moment the budget
148
+ is spent. The loop is already stopped when it runs.
149
+
150
+ A loop with no budget beats forever into a broker that refuses it, so the service keeps running while every
151
+ reader of the registry sees it as stale. What a dead heartbeat MEANS (stop the process, page someone) is the
152
+ caller's decision — this client counts and reports, it never decides.
153
+
154
+ The cadence is not an argument here: it has one owner, the constructor option `heartbeatInterval`
155
+ (owner confirmation `api/docs/governance/confirmations/biz-health-freshness.md` 001 point 3).
140
156
 
141
157
  #### `stopHeartbeat()`
142
- Stops the heartbeat timer.
158
+ Stops the heartbeat timer. Idempotent: calling it twice, or before any `startHeartbeat()`, does nothing.
143
159
 
144
160
  #### `async subscribeToChanges()`
145
161
  Subscribes to registry change events (optional).
@@ -184,7 +200,10 @@ registryClient.startHeartbeat(); // Started immediately
184
200
  await registryClient.init();
185
201
  const result = await registryClient.register(serviceInfo); // NEW: Registration required
186
202
  if (result.success) {
187
- registryClient.startHeartbeat(); // Only after validation
203
+ registryClient.startHeartbeat({ // Only after validation
204
+ maxFailures: 5,
205
+ onFatal: (err) => { console.error(err.message); process.exit(1); }
206
+ });
188
207
  }
189
208
  ```
190
209
 
@@ -71,7 +71,13 @@ registryClient.on(EVENTS.ERROR, (err) => {
71
71
  await registryClient.register(await loadServiceSpec());
72
72
  console.log('Register message published.');
73
73
 
74
- registryClient.startHeartbeat();
74
+ registryClient.startHeartbeat({
75
+ maxFailures: 5,
76
+ onFatal: (err) => {
77
+ console.error(err.message);
78
+ process.exit(1); // the restart policy boots it again and it re-registers
79
+ }
80
+ });
75
81
  console.log('Heartbeat loop started.');
76
82
 
77
83
  const shutdown = async () => {
@@ -63,7 +63,13 @@ async function main() {
63
63
  });
64
64
 
65
65
  // Start heartbeat
66
- client.startHeartbeat();
66
+ client.startHeartbeat({
67
+ maxFailures: 5,
68
+ onFatal: (err) => {
69
+ console.error(err.message);
70
+ process.exit(1);
71
+ }
72
+ });
67
73
 
68
74
  // OPT-IN: Subscribe to registry changes
69
75
  console.log('Subscribing to registry changes...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-registry",
3
- "version": "4.0.3",
3
+ "version": "5.0.0",
4
4
  "license": "MIT",
5
5
  "description": "conn-orch-registry provides the core communication mechanism for microservices in this environment. It enables them to interact with a services_registry to receive and fulfill tasks by submitting heartbeats or their API descriptions.",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@onlineapps/logger-contract": "2.0.0",
40
- "@onlineapps/mq-client-core": "3.2.0",
40
+ "@onlineapps/mq-client-core": "4.0.0",
41
41
  "uuid": "^9.0.1"
42
42
  },
43
43
  "devDependencies": {
@@ -90,11 +90,25 @@ class QueueManager {
90
90
  // The core's refusal says "the configured host" without naming it — which
91
91
  // host is exactly what a reader of a boot failure needs, so it is added
92
92
  // here, redacted, with the core's error as the cause.
93
- throw new Error(
93
+ //
94
+ // The CLASSIFICATION the source put on the failure travels with the rewrap.
95
+ // `@onlineapps/mq-client-core` owns the question "did the broker answer and
96
+ // refuse?" (`REFUSAL_REPLY_CODES`, `_isBrokerRefusal()`) and states the
97
+ // answer as `code: 'MQ_CONNECTION_FATAL'` / `reason: 'broker-refused'`;
98
+ // `new Error(msg, { cause })` carries neither, so the marker died here and
99
+ // `@onlineapps/service-wrapper` — which ends its boot on `error.code` — was
100
+ // left with an unclassified failure and called a refusal transient (d.532,
101
+ // measured on biz-meta). Nothing is classified HERE: what the source left
102
+ // unmarked stays unmarked, exactly as `@onlineapps/conn-infra-mq` does it
103
+ // in `ConnectorMQClient` (d.498b).
104
+ const refusal = new Error(
94
105
  `[QueueManager] Failed to connect to RabbitMQ at ${this.safeAmqpUrl} - ${error.message}. `
95
106
  + 'Fix: check broker health and the credentials in the AMQP URI.',
96
107
  { cause: error }
97
108
  );
109
+ refusal.code = error.code;
110
+ refusal.reason = error.reason;
111
+ throw refusal;
98
112
  }
99
113
  }
100
114
 
@@ -137,12 +151,30 @@ class QueueManager {
137
151
  await this.client.assertQueue(q);
138
152
  } catch (assertErr) {
139
153
  const errorMsg = assertErr.message || String(assertErr);
140
- throw new Error(
154
+ // The CLASSIFICATION the source put on the failure travels with the
155
+ // rewrap, exactly as it does in `init()` a few methods up (d.532). This
156
+ // was the THIRD path and the one d.532 did not reach: FÁZE 0.8 declares
157
+ // the business queues, and `new Error(msg, { cause })` carries no own
158
+ // property, so whatever the core decided about this failure died here and
159
+ // `@onlineapps/service-wrapper` — which reads `error.code` — was handed an
160
+ // unclassified one (d.579).
161
+ //
162
+ // Nothing is classified HERE: what the source left unmarked stays
163
+ // unmarked. This package neither reads AMQP reply codes nor matches on
164
+ // message text; the question "did the broker answer and refuse?" belongs
165
+ // to `@onlineapps/mq-client-core` (`REFUSAL_REPLY_CODES`,
166
+ // `_isBrokerRefusal()`), and a second copy of that list here would be free
167
+ // to drift from the one the broker is actually talked to through
168
+ // (`change-discipline.md` § One rail per concern).
169
+ const refusal = new Error(
141
170
  `[QueueManager] Failed to assert queue ${q} - ${errorMsg} (code ${assertErr.code || 'N/A'}). `
142
171
  + 'Fix: a 406 PRECONDITION-FAILED means the queue exists with different arguments; '
143
172
  + 'align them or let its owner declare it.',
144
173
  { cause: assertErr }
145
174
  );
175
+ refusal.code = assertErr.code;
176
+ refusal.reason = assertErr.reason;
177
+ throw refusal;
146
178
  }
147
179
  this.logger.debug('[QueueManager] Queue asserted', {
148
180
  serviceName: this.serviceName,
@@ -2,13 +2,15 @@
2
2
  * registryClient.js
3
3
  *
4
4
  * ServiceRegistryClient for communication between a microservice (via Agent)
5
- * and the central registry. Sends the full service specification in the
6
- * `register` message (including the `operations` map) and periodic heartbeat
7
- * messages. Receives registration responses (register.confirmed /
8
- * register.rejected) and revalidation requests from the registry.
5
+ * and the central registry. Sends the service's `operations` map in the
6
+ * `register` message and periodic heartbeat messages. Receives registration
7
+ * responses (register.confirmed / register.rejected) and revalidation requests
8
+ * from the registry.
9
9
  *
10
10
  * Legacy `apiDescription` / `apiDescriptionRequest` round-trips have been
11
- * removed — the full spec is part of the `register` message itself.
11
+ * removed — what the registry validates travels in the `register` message
12
+ * itself, as `operations` (schema-v3). The `spec` key that used to carry an
13
+ * OpenAPI document beside it left in d.562: nothing ever read it.
12
14
  *
13
15
  * Uses QueueManager to manage AMQP queues. Emits events through EventEmitter.
14
16
  *
@@ -33,6 +35,32 @@ const { queueConfig } = require('@onlineapps/mq-client-core');
33
35
  const DEFAULTS = require('./defaults');
34
36
  const { assertLogger } = require('@onlineapps/logger-contract');
35
37
 
38
+ /**
39
+ * Keys `register()` REFUSES, with the reason the caller needs to act on (d.562).
40
+ *
41
+ * All three used to travel on every registration and nothing ever read them:
42
+ * `api/infra/api_services_registry/src/listeners/registry.listener.js`
43
+ * destructures the `register` message without them, and no other reader exists on
44
+ * the platform (`api/docs/biz/30-operations/registration-wire.md` §2.1).
45
+ *
46
+ * They are refused rather than ignored: a key silently dropped leaves the caller
47
+ * believing something travelled. And they are refused under BOTH spellings each
48
+ * was accepted by (`token` / `validationToken`, `secret` / `tokenSecret`) — two
49
+ * names for one field is the defect this table closes, not a compatibility list.
50
+ */
51
+ const RETIRED_SERVICE_INFO_KEYS = Object.freeze({
52
+ spec: 'the OpenAPI document a registry used to dereference over HTTP; biz containers expose no HTTP '
53
+ + '(ADR 0005) and what the registry validates today is `operations` (registration-wire.md §3)',
54
+ token: 'part of the JWT token API that left @onlineapps/service-validator-core in d.344; a validation '
55
+ + 'run is proven by `validationProof` / `validationData` and the certificate the validator signs',
56
+ validationToken: 'part of the JWT token API that left @onlineapps/service-validator-core in d.344; a '
57
+ + 'validation run is proven by `validationProof` / `validationData` and the certificate the validator signs',
58
+ secret: 'the shared secret of that same token API, whose last reader (HTTP POST /validate) was deleted '
59
+ + '2026-08-22; nothing verifies a token with it, and a broker message is no place for a secret',
60
+ tokenSecret: 'the shared secret of that same token API, whose last reader (HTTP POST /validate) was '
61
+ + 'deleted 2026-08-22; nothing verifies a token with it, and a broker message is no place for a secret'
62
+ });
63
+
36
64
 
37
65
  class ServiceRegistryClient extends EventEmitter {
38
66
  /**
@@ -190,11 +218,22 @@ class ServiceRegistryClient extends EventEmitter {
190
218
  } catch (consumeErr) {
191
219
  // Not logged before throwing: the thrown message already names the service,
192
220
  // the queue and the cause.
193
- throw new Error(
221
+ //
222
+ // The CLASSIFICATION the source put on the failure travels with the rewrap,
223
+ // for the same reason it does in `QueueManager.init()`: a queue the broker
224
+ // refused with 406 PRECONDITION-FAILED is answered identically on every
225
+ // attempt, and `@onlineapps/service-wrapper` ends FÁZE 0.7 permanently on
226
+ // `error.code` rather than on this sentence (d.532). `new Error(msg,
227
+ // { cause })` carries no own property, so the marker used to die here.
228
+ // Nothing is classified HERE — what the source left unmarked stays unmarked.
229
+ const refusal = new Error(
194
230
  `[RegistryClient] ${this.serviceName}: Failed to start consumer on ${this.serviceRegistryQueue} - `
195
231
  + `${consumeErr.message}. Fix: check RabbitMQ health; without this consumer no registry reply is ever read.`,
196
232
  { cause: consumeErr }
197
233
  );
234
+ refusal.code = consumeErr.code;
235
+ refusal.reason = consumeErr.reason;
236
+ throw refusal;
198
237
  } finally {
199
238
  if (consumeRegistryTimeout) clearTimeout(consumeRegistryTimeout);
200
239
  }
@@ -426,7 +465,6 @@ class ServiceRegistryClient extends EventEmitter {
426
465
  * @param {Array} serviceInfo.endpoints - API endpoints provided by the service
427
466
  * @param {Object} serviceInfo.metadata - Additional service metadata
428
467
  * @param {string} serviceInfo.health - Health check endpoint
429
- * @param {Object} serviceInfo.spec - OpenAPI specification
430
468
  * @param {(boolean|null)} [serviceInfo.deployable] - Manifest-conformance verdict of the
431
469
  * caller's last validation run: `true`, `false`, or `null` when the run never measured
432
470
  * it. Emitted only when the caller states one; see the `deployable` block below.
@@ -450,6 +488,19 @@ class ServiceRegistryClient extends EventEmitter {
450
488
  );
451
489
  }
452
490
 
491
+ // A retired key is refused BY NAME, before anything is published: dropping it
492
+ // in silence would leave the caller believing it travelled. The value is never
493
+ // echoed - two of these keys carried a secret.
494
+ for (const key of Object.keys(serviceInfo)) {
495
+ if (Object.prototype.hasOwnProperty.call(RETIRED_SERVICE_INFO_KEYS, key)) {
496
+ throw new Error(
497
+ `[RegistryClient] ${this.serviceName}: serviceInfo carries the retired key "${key}" - `
498
+ + `${RETIRED_SERVICE_INFO_KEYS[key]}. `
499
+ + `Fix: delete "${key}" from the object passed to register().`
500
+ );
501
+ }
502
+ }
503
+
453
504
  // FAIL-FAST: a client that was never initialized, or that has been closed, is not
454
505
  // usable. It used to silently call queueManager.init() here, which re-opened a
455
506
  // channel WITHOUT restarting the response consumer started by init() - so the
@@ -475,15 +526,13 @@ class ServiceRegistryClient extends EventEmitter {
475
526
  operations: serviceInfo.operations || {},
476
527
  metadata: serviceInfo.metadata || {},
477
528
  health: serviceInfo.health || '/health',
478
- spec: serviceInfo.spec || null,
479
- validationToken: serviceInfo.token || serviceInfo.validationToken,
480
- tokenSecret: serviceInfo.secret || serviceInfo.tokenSecret,
481
529
  responseQueue: this.serviceRegistryQueue, // Queue for registry to send response
482
530
  timestamp: new Date().toISOString()
483
531
  };
484
532
 
485
533
  // Propagate workspaceScoped flag to the registry so consumers can discover
486
- // which services are workspace-scoped. See biz-service-onboarding.md §4.
534
+ // which services are workspace-scoped.
535
+ // @see api/docs/biz/60-templates/onboarding-checklist.md §4
487
536
  if (typeof serviceInfo.workspaceScoped === 'boolean') {
488
537
  msg.workspaceScoped = serviceInfo.workspaceScoped;
489
538
  }
@@ -732,36 +781,115 @@ class ServiceRegistryClient extends EventEmitter {
732
781
  }
733
782
 
734
783
  /**
735
- * Starts periodic heartbeat messages.
784
+ * Starts periodic heartbeat messages, and gives up after a budget of
785
+ * consecutive failures.
786
+ *
787
+ * A heartbeat loop with no budget is worse than none: the beats stop reaching
788
+ * the registry, the loop logs and reschedules forever, and the service keeps
789
+ * running while every consumer of `registry:services` reads it as stale. Until
790
+ * 2026-09-15 that budget lived in `@onlineapps/service-wrapper`, in a second
791
+ * loop around `sendHeartbeat()` — two loops for one cadence
792
+ * (`.claude/rules/change-discipline.md` § One rail per concern).
793
+ *
794
+ * The cadence is NOT an argument here. It has one owner and it is the
795
+ * constructor's `heartbeatInterval` (owner confirmation
796
+ * docs/governance/confirmations/biz-health-freshness.md 001 point 3); taking it
797
+ * again per call would be the second number that confirmation refuses.
798
+ *
799
+ * Neither option is defaulted. How many missed beats mean death, and what death
800
+ * means, are decisions of the caller that owns the process — this client only
801
+ * counts and reports (`.claude/rules/architecture-principles.md` §3, §4).
802
+ *
803
+ * @param {Object} options
804
+ * @param {number} options.maxFailures - REQUIRED. Consecutive failed beats the
805
+ * service may accumulate before it can no longer prove it is alive. A
806
+ * positive whole number; one success resets the count to zero.
807
+ * @param {function(Error): void} options.onFatal - REQUIRED. Called exactly once,
808
+ * with the §5 error naming the count and the last cause, at the moment the
809
+ * budget is spent. The loop is already stopped when it is called.
736
810
  */
737
- startHeartbeat() {
738
- // Send immediately after init
739
- this.sendHeartbeat().catch(err => {
740
- this.logger.error('[RegistryClient] Initial heartbeat failed', {
741
- serviceName: this.serviceName,
742
- error: err.message
743
- });
744
- });
811
+ startHeartbeat(options) {
812
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
813
+ throw new Error(
814
+ `[RegistryClient] ${this.serviceName}: Missing heartbeat options - startHeartbeat({ maxFailures, onFatal }) `
815
+ + `requires both, got ${JSON.stringify(options) === undefined ? 'undefined' : JSON.stringify(options)}. `
816
+ + 'Expected: an object naming the give-up budget and the handler that receives the fatal error. '
817
+ + 'Fix: call client.startHeartbeat({ maxFailures, onFatal }); this loop carries no default budget and '
818
+ + 'decides nothing about the service.'
819
+ );
820
+ }
745
821
 
746
- // Repeat at the configured interval using a safe recursive timeout to avoid parallel runs
747
- const heartbeatLoop = async () => {
822
+ const { maxFailures, onFatal } = options;
823
+
824
+ // Checked before the timer exists, so a wrong budget fails the boot naming the
825
+ // option rather than killing the service on beat one (§4, fail-fast).
826
+ if (!Number.isInteger(maxFailures) || maxFailures <= 0) {
827
+ throw new Error(
828
+ `[RegistryClient] ${this.serviceName}: Invalid maxFailures - expected a positive whole number of consecutive `
829
+ + `failed beats, got ${JSON.stringify(maxFailures) === undefined ? 'undefined' : JSON.stringify(maxFailures)}. `
830
+ + 'Expected: the budget its owner decided. Fix: pass the number of beats the service may miss before it is '
831
+ + 'no longer alive; at 0 the loop would give up on the first blip, which is the opposite of a budget.'
832
+ );
833
+ }
834
+
835
+ if (typeof onFatal !== 'function') {
836
+ throw new Error(
837
+ `[RegistryClient] ${this.serviceName}: Invalid onFatal - expected a function, got `
838
+ + `${JSON.stringify(onFatal) === undefined ? 'undefined' : JSON.stringify(onFatal)}. `
839
+ + 'Expected: the caller decides what a dead heartbeat means (stop the service, page someone); this client '
840
+ + 'only reports that the budget is spent. '
841
+ + 'Fix: pass onFatal: (err) => { /* shut the service down so it re-registers on restart */ }.'
842
+ );
843
+ }
844
+
845
+ let consecutiveFailures = 0;
846
+
847
+ // ONE accounting path for every beat, the immediate one included: a first beat
848
+ // counted outside the budget would make `maxFailures` mean "N + 1 for the first
849
+ // failure" — a number the caller did not set.
850
+ const beat = async () => {
748
851
  try {
749
852
  await this.sendHeartbeat();
853
+ consecutiveFailures = 0;
750
854
  } catch (err) {
855
+ consecutiveFailures += 1;
751
856
  this.logger.error('[RegistryClient] Heartbeat failed', {
752
857
  serviceName: this.serviceName,
753
- error: err.message
858
+ error: err.message,
859
+ consecutiveFailures,
860
+ maxFailures
754
861
  });
755
- } finally {
756
- // Schedule next heartbeat only after previous one finished (or failed)
757
- if (this.heartbeatTimer !== null) {
758
- this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
862
+
863
+ if (consecutiveFailures >= maxFailures) {
864
+ // Stopped BEFORE onFatal runs: the handler tears the process down, and a
865
+ // timer still armed would fire one more beat into a closing service.
866
+ this.stopHeartbeat();
867
+ onFatal(new Error(
868
+ `[RegistryClient] ${this.serviceName}: Registry heartbeat failed ${consecutiveFailures} times in a row - `
869
+ + `the service can no longer prove it is alive; last cause: ${err.message} `
870
+ + `Expected: a beat reaches ${this.registryQueue} within the budget of ${maxFailures}. `
871
+ + 'Fix: check the registry service and the queue it consumes, then restart this service so it '
872
+ + 'registers again.',
873
+ { cause: err }
874
+ ));
759
875
  }
760
876
  }
761
877
  };
762
878
 
763
- // Use setTimeout instead of setInterval for better control
879
+ // Repeat at the configured cadence with a recursive timeout, so two beats can
880
+ // never run in parallel; `heartbeatTimer === null` is how a stop (from
881
+ // stopHeartbeat, or from the budget above) refuses the next schedule.
882
+ const heartbeatLoop = async () => {
883
+ await beat();
884
+ if (this.heartbeatTimer !== null) {
885
+ this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
886
+ }
887
+ };
888
+
889
+ // Armed before the immediate beat is awaited, so the budget can clear it: the
890
+ // beat's rejection is handled in a microtask, i.e. after this line has run.
764
891
  this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
892
+ beat();
765
893
  }
766
894
 
767
895
  /**