@winkjs/composer 0.6.0 → 0.8.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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/composer.js +1 -1
  4. package/src/core/emitter-manager/mqtt/connection.js +209 -0
  5. package/src/core/emitter-manager/mqtt/constants.js +43 -29
  6. package/src/core/emitter-manager/mqtt/emitter.js +103 -658
  7. package/src/core/emitter-manager/mqtt/health.js +151 -0
  8. package/src/core/emitter-manager/mqtt/index.js +68 -4
  9. package/src/core/emitter-manager/mqtt/publish.js +292 -0
  10. package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
  11. package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
  12. package/src/core/emitter-manager/terminal/index.js +16 -1
  13. package/src/core/env-vars.js +92 -24
  14. package/src/core/logger/index.js +1 -1
  15. package/src/core/source-manager/mqtt/client.js +197 -63
  16. package/src/core/source-manager/mqtt/dedup.js +16 -11
  17. package/src/core/source-manager/mqtt/index.js +32 -5
  18. package/src/core/source-manager/mqtt/status.js +168 -56
  19. package/src/core/storage-manager/questdb/address-policy.js +264 -0
  20. package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
  21. package/src/core/storage-manager/questdb/flush-engine.js +852 -0
  22. package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
  23. package/src/core/storage-manager/questdb/index.js +542 -862
  24. package/src/core/storage-manager/questdb/persist-plan.js +128 -167
  25. package/src/core/storage-manager/questdb/resolve-options.js +266 -0
  26. package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
  27. package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
  28. package/src/core/test-utils/tcp-proxy.js +194 -1
  29. package/src/core/utils/address/index.js +362 -0
  30. package/src/core/utils/address/probe.js +210 -0
  31. package/src/core/utils/callback-guard/index.js +23 -2
  32. package/src/core/utils/clock/index.js +34 -0
  33. package/src/core/utils/jitter/index.js +51 -0
  34. package/src/core/utils/line-rate/index.js +104 -0
  35. package/src/core/utils/quantize/index.js +43 -19
  36. package/src/core/wiring/wire-emitters.js +3 -1
  37. package/src/core/wiring/wire-storages.js +10 -2
  38. package/src/flow/flow.js +24 -3
  39. package/src/flow/run.js +24 -2
@@ -28,8 +28,8 @@
28
28
  * - Two unavoidable residuals, both bounded: (1) `Set.add`/`Set.delete`
29
29
  * maintain V8's internal hash table — amortized O(1), memory bounded
30
30
  * by `maxEntries`, and the identical profile the pre-ADR-022 cache
31
- * already had (JS offers no pre-sized Set). (2) `Date.now()` exceeds
32
- * the small-integer range, so the timestamp is transiently boxed; it
31
+ * already had (JS offers no pre-sized Set). (2) The clock returns a
32
+ * fractional number, so the timestamp is transiently boxed; it
33
33
  * unboxes on store into the Float64Array and dies in the nursery.
34
34
  * - Eviction clears the ring slot so an evicted id string is not
35
35
  * retained past its window — that release is deliberate.
@@ -48,9 +48,12 @@
48
48
  * -----------
49
49
  * 1. A dedup id is unique per message and repeats only on a QoS-1
50
50
  * retransmission (the emitter stamps UUIDs).
51
- * 2. The clock moves forward at millisecond granularity. A backward
52
- * clock jump can expire entries early or hold them one window too
53
- * long — bounded either way, and self-healing within one window.
51
+ * 2. The clock counts up, in milliseconds. The default is the
52
+ * stopwatch clock (`performance`), which a wall-clock step cannot
53
+ * move. A forward step of the wall clock would otherwise expire
54
+ * every entry at once; a backward step would hold entries one
55
+ * step too long (ADR-018, long-running stability). An injected
56
+ * `nowFn` must count up too.
54
57
  *
55
58
  * LIMITATIONS
56
59
  * -----------
@@ -62,9 +65,9 @@
62
65
  * effective window is maxEntries ÷ rate (the full 120 s up to
63
66
  * about 550 msg/s at defaults; ~6.5 s at 10 k msg/s). Stated in
64
67
  * ADR-022, not hidden.
65
- * 3. Memory, measured (benchmark/mqtt-source/dedup-memory.js,
66
- * 2026-07-09): 86 bytes per production-shaped id, ~6.7 MB worst
67
- * case at the 65,536 cap including bounded V8 Set slack.
68
+ * 3. Memory, measured on the private benchmark harness (2026-07-09):
69
+ * 86 bytes per production-shaped id, ~6.7 MB worst case at the
70
+ * 65,536 cap including bounded V8 Set slack.
68
71
  *
69
72
  * @see ADR-022 - The full decision
70
73
  */
@@ -73,6 +76,7 @@ import {
73
76
  DEFAULT_DEDUP_WINDOW_MS,
74
77
  DEFAULT_DEDUP_MAX_ENTRIES
75
78
  } from './constants.js';
79
+ import { monotonicNow } from '../../utils/clock/index.js';
76
80
 
77
81
  // ============================================================================
78
82
  // VALIDATION HELPER
@@ -108,8 +112,9 @@ const assertPositiveInteger = function ( value, name ) {
108
112
  * expires once its age reaches this many milliseconds
109
113
  * @param {number} [options.maxEntries=65536] - Count cap: the memory
110
114
  * guarantee; the oldest entry is evicted when the ring is full
111
- * @param {function} [options.nowFn=Date.now] - Clock source. Injection
112
- * point for deterministic tests; production uses the default
115
+ * @param {function} [options.nowFn=monotonicNow] - Clock source, in
116
+ * milliseconds and counting up. Injection point for deterministic
117
+ * tests; production uses the stopwatch clock
113
118
  * @returns {Object} Cache instance with isDuplicate, size, clear, has
114
119
  */
115
120
  const createDedupCache = function ( options = {} ) {
@@ -122,7 +127,7 @@ const createDedupCache = function ( options = {} ) {
122
127
  const {
123
128
  windowMs = DEFAULT_DEDUP_WINDOW_MS,
124
129
  maxEntries = DEFAULT_DEDUP_MAX_ENTRIES,
125
- nowFn = Date.now
130
+ nowFn = monotonicNow
126
131
  } = options;
127
132
 
128
133
  assertPositiveInteger( windowMs, 'windowMs' );
@@ -31,7 +31,9 @@
31
31
  * of `configSchema` — the wiring layer adds them to the config at start
32
32
  * time, after DSL validation. A user-supplied `onMessage` would be
33
33
  * silently overwritten; the unknown-key rejection turns that mistake
34
- * into a fail-fast error instead.
34
+ * into a fail-fast error instead. `onMessage` is also outside the
35
+ * callback guard on purpose: the flow's dispatch guard owns it, so a
36
+ * pipeline throw is contained once, at that one chokepoint (ADR-027).
35
37
  *
36
38
  * ASSUMPTIONS
37
39
  * -----------
@@ -49,6 +51,10 @@
49
51
  * 2. Duplicate filtering is opt-in per message: only messages that
50
52
  * carry `winkDedupId` are protected. Watch the `dedupBypassed`
51
53
  * counter to spot publishers that are not stamping ids.
54
+ * 3. No payload size cap and no intake rate cap. The broker's own
55
+ * per-client limits are the first line against a flood; TCP
56
+ * back-pressure bounds the rest. A cap is ADR-026 work
57
+ * (proposed), together with TLS and authentication.
52
58
  *
53
59
  * Usage in flow:
54
60
  * import mqtt from './core/source-manager/mqtt/index.js';
@@ -65,8 +71,24 @@
65
71
  */
66
72
 
67
73
  import { validators } from '../../utils/validate/index.js';
74
+ import { classifyAddress } from '../../utils/address/index.js';
68
75
  import { createMQTTSourceClient } from './client.js';
69
76
 
77
+ /**
78
+ * Schema validator for `brokerUrl`: non-empty and never `localhost`
79
+ * (ADR-030). A value the URL grammar cannot read passes; the MQTT
80
+ * library reports its own error for it.
81
+ *
82
+ * @param {*} value - The configured value
83
+ * @returns {boolean} Whether the value is allowed
84
+ */
85
+ const isAllowedBrokerUrl = function ( value ) {
86
+ if ( !validators.nonEmptyString( value ) ) {
87
+ return false;
88
+ }
89
+ return classifyAddress( value, 'url' ).kind !== 'localhost';
90
+ }; // isAllowedBrokerUrl()
91
+
70
92
  // ============================================================================
71
93
  // SOURCE ADAPTER
72
94
  // ============================================================================
@@ -83,7 +105,9 @@ const id = 'mqtt';
83
105
  * Crash-survival class per ADR-018. For a source the value describes
84
106
  * the INPUT it can recover after a disconnect: the persistent broker
85
107
  * session holds subscribed QoS-1 messages across the gap and replays
86
- * them on reconnect.
108
+ * them on reconnect. The class is earned only under a fixed
109
+ * `clientId` and a broker with persistence on. The auto-generated name
110
+ * changes on every start, so a restart under it claims no backlog.
87
111
  *
88
112
  * @type {string}
89
113
  */
@@ -97,7 +121,9 @@ const durabilityClass = 'broker-queue';
97
121
  * @returns {boolean}
98
122
  */
99
123
  const isTopicOrTopicArray = function ( value ) {
100
- if ( typeof value === 'string' && value.length > 0 ) return true;
124
+ if ( typeof value === 'string' && value.length > 0 ) {
125
+ return true;
126
+ }
101
127
  if ( Array.isArray( value ) && value.length > 0 ) {
102
128
  return value.every( ( t ) => typeof t === 'string' && t.length > 0 );
103
129
  }
@@ -163,8 +189,9 @@ const configSchema = {
163
189
  brokerUrl: {
164
190
  type: 'string',
165
191
  required: true,
166
- validator: validators.nonEmptyString,
167
- error: 'brokerUrl must be a non-empty string (e.g., mqtt://broker.local:1883)'
192
+ validator: isAllowedBrokerUrl,
193
+ error: 'brokerUrl must be a non-empty broker URL with a literal address or a name, never ' +
194
+ 'localhost; e.g., mqtt://127.0.0.1:1883'
168
195
  },
169
196
  topics: {
170
197
  required: true,
@@ -29,6 +29,25 @@
29
29
  * later failed on still counts here. Failure visibility belongs
30
30
  * to the flow's MESSAGE_HANDLER_FAILED reports (ADR-018), not to
31
31
  * this counter.
32
+ * - The logger facade (ADR-028) — one line per health edge, and a
33
+ * bounded line per record fault. These lines print with or without
34
+ * an `onStatus` handler. Inside a flow the runtime's own status
35
+ * wrapper forwards only red payloads when the user gave no handler,
36
+ * so a yellow edge that reached only the payload channel used to
37
+ * vanish. The line is the guaranteed audience. The payload
38
+ * additionally reaches a listening handler.
39
+ *
40
+ * yellow edge → warn source degraded [CODE]: <detail>
41
+ * red edge → error source error [CODE]: <detail>
42
+ * back to green → warn source recovered [CODE]: cleared after N s
43
+ * forced stop → warn source stopped: <note>
44
+ *
45
+ * An edge is a change of status or code. A phase change inside the
46
+ * same status and code prints nothing, so a retry storm cannot fill
47
+ * the log. A clean stop prints nothing. The per-record lines
48
+ * (`decode failed [DECODE_ERROR]`, `transform failed
49
+ * [CALLBACK_FAILED]`) print the first two of an episode in full and
50
+ * then one summary a minute, the emitter's attempt-line shape.
32
51
  *
33
52
  * Health rules (evaluated on events and on tick):
34
53
  * - RED — a subscribe failure (connected but deaf — no retry
@@ -44,6 +63,12 @@
44
63
  * Precedence is the order above; the first rule that matches names
45
64
  * the code.
46
65
  *
66
+ * Clock: the default `nowFn` is the stopwatch clock (`performance`),
67
+ * which only counts up. The wall clock can step when NTP corrects a
68
+ * board after boot or after a long time without network. A forward
69
+ * step would fire the 30 s red early; a backward step would delay it
70
+ * (ADR-018, long-running stability). Tests inject their own clock.
71
+ *
47
72
  * Hot-path allocation profile (ADR-018 zero-alloc — allocation only where
48
73
  * unavoidable, each residual stated):
49
74
  * - Counter bumps, ring writes, and the health evaluation are plain
@@ -54,20 +79,18 @@
54
79
  * metrics snapshots (1 Hz). Snapshots are fresh objects because a
55
80
  * consumer may retain them; mutating a shared one under the
56
81
  * caller's feet is a footgun we refuse.
57
- * - `nowFn()` (Date.now) runs once per received message to feed the
58
- * quiet-period clock — the same transient boxing the dedup cache
59
- * already documents.
60
- *
61
- * Callers without an `onStatus` handler still see error-path
62
- * payloads via a classified `console.error` line (the CSV source's
63
- * fallback pattern); lifecycle payloads stay quiet.
82
+ * - `nowFn()` runs once per received message to feed the quiet-period
83
+ * clock. It returns a fractional number, so the value is transiently
84
+ * boxed — the same profile the dedup cache documents.
85
+ * - The facade lines are built only on an edge or a record fault,
86
+ * never on the success path.
64
87
  *
65
88
  * ASSUMPTIONS
66
89
  * -----------
67
90
  * 1. All reporter calls come from one event loop (mqtt.js event
68
91
  * handlers plus one interval) — there is no locking.
69
- * 2. The clock (`nowFn`) moves forward; the time rules use plain
70
- * subtraction.
92
+ * 2. An injected `nowFn` counts up, in milliseconds. The time rules
93
+ * use plain subtraction.
71
94
  *
72
95
  * LIMITATIONS
73
96
  * -----------
@@ -79,9 +102,13 @@
79
102
  * 2. Counters are plain JS numbers: exact to 2^53. At one million
80
103
  * messages per second that is ~285 years of uptime — not the
81
104
  * binding constraint.
105
+ * 3. The per-record line bound paces on the stopwatch clock too
106
+ * (the shared line-rate helper), so a wall-clock step moves
107
+ * neither a summary nor what the health rules decide.
82
108
  *
83
109
  * @see src/core/source-manager/mqtt/client.js - The transport wiring
84
110
  * @see ADR-018 - lifecycle phases, status shapes, error codes
111
+ * @see ADR-028 - the line grammar
85
112
  */
86
113
 
87
114
  import {
@@ -89,8 +116,16 @@ import {
89
116
  DECODE_RING_SIZE
90
117
  } from './constants.js';
91
118
  import { wrapCallback } from '../../utils/callback-guard/index.js';
119
+ import { createLineBound } from '../../utils/line-rate/index.js';
120
+ import { monotonicNow } from '../../utils/clock/index.js';
92
121
  import { logger } from '../../logger/index.js';
93
122
 
123
+ /** How many per-record fault lines per episode print in full. */
124
+ const FULL_RECORD_LINES_PER_EPISODE = 2;
125
+
126
+ /** The per-record summary interval, also the quiet gap that ends an episode. */
127
+ const RECORD_SUMMARY_INTERVAL_MS = 60000;
128
+
94
129
  // ============================================================================
95
130
  // VALIDATION HELPERS
96
131
  // ============================================================================
@@ -110,6 +145,31 @@ const assertOptionalFunction = function ( value, name ) {
110
145
  }
111
146
  };
112
147
 
148
+ // ============================================================================
149
+ // FACADE LINES
150
+ // ============================================================================
151
+
152
+ /**
153
+ * Builds the bounded facade line for one per-record fault family. The
154
+ * first FULL_RECORD_LINES_PER_EPISODE faults of an episode print in
155
+ * full; later ones are counted into one summary a minute.
156
+ *
157
+ * @param {string} label - The line's message, e.g. `decode failed [DECODE_ERROR]`
158
+ * @returns {Function} `( detail ) => void`
159
+ */
160
+ const createRecordLine = function ( label ) {
161
+ return createLineBound( {
162
+ fullLines: FULL_RECORD_LINES_PER_EPISODE,
163
+ intervalMs: RECORD_SUMMARY_INTERVAL_MS,
164
+ printFull: function ( detail ) {
165
+ logger.warn( `winkComposer/mqttSource: ${label}: ${detail}` );
166
+ },
167
+ printSummary: function ( count, seconds, detail ) {
168
+ logger.warn( `winkComposer/mqttSource: ${label}: ${detail}; ${count} more in the last ${seconds} s` );
169
+ }
170
+ } );
171
+ }; // createRecordLine()
172
+
113
173
  // ============================================================================
114
174
  // STATUS REPORTER FACTORY
115
175
  // ============================================================================
@@ -123,8 +183,9 @@ const assertOptionalFunction = function ( value, name ) {
123
183
  * @param {number} [options.expectedQuietPeriodMs] - Opt-in quiet rule:
124
184
  * yellow when no packet arrives for longer than this
125
185
  * @param {function} [options.dedupSizeFn] - Live dedup-cache size read
126
- * @param {function} [options.nowFn=Date.now] - Clock source. Injection
127
- * point for deterministic tests; production uses the default
186
+ * @param {function} [options.nowFn=monotonicNow] - Clock source, in
187
+ * milliseconds and counting up. Injection point for deterministic
188
+ * tests; production uses the stopwatch clock
128
189
  * @returns {Object} Reporter with lifecycle notes, hot-path counters,
129
190
  * tick and snapshot
130
191
  */
@@ -140,7 +201,7 @@ const createStatusReporter = function ( options = {} ) {
140
201
  onMetrics = null,
141
202
  expectedQuietPeriodMs = null,
142
203
  dedupSizeFn = null,
143
- nowFn = Date.now
204
+ nowFn = monotonicNow
144
205
  } = options;
145
206
 
146
207
  assertOptionalFunction( onStatus, 'onStatus' );
@@ -192,6 +253,18 @@ const createStatusReporter = function ( options = {} ) {
192
253
  let lastPhase = null;
193
254
  let lastCode = null;
194
255
 
256
+ // ── Facade-line state (ADR-028) ──────────────────────────────────
257
+ // The edge lines key on status and code only, so a phase change
258
+ // inside one degraded episode prints nothing. `degradedSince` and
259
+ // `degradedPhase` remember the first non-green edge of the
260
+ // episode for the recovery line.
261
+ let printedStatus = 'green';
262
+ let printedCode = null;
263
+ let degradedSince = 0;
264
+ let degradedPhase = null;
265
+ const decodeLine = createRecordLine( 'decode failed [DECODE_ERROR]' );
266
+ const transformLine = createRecordLine( 'transform failed [CALLBACK_FAILED]' );
267
+
195
268
  /**
196
269
  * Record one decode outcome in the ring.
197
270
  *
@@ -229,9 +302,9 @@ const createStatusReporter = function ( options = {} ) {
229
302
  // Both user callbacks are armed by the shared callback guard.
230
303
  // They were validated raw above and are wrapped here, once
231
304
  // (ADR-018: a misbehaving user callback never reaches transport
232
- // code and never fails silently). A broken onStatus reports to
233
- // the console in this adapter's classified line family. It
234
- // cannot report through itself. A broken onMetrics reports on
305
+ // code and never fails silently). A broken onStatus reports
306
+ // through the facade in this adapter's classified line family.
307
+ // It cannot report through itself. A broken onMetrics reports on
235
308
  // both channels; the comment at its wrap below explains why.
236
309
  const safeOnStatus = wrapCallback( onStatus, {
237
310
  name: 'onStatus',
@@ -242,18 +315,15 @@ const createStatusReporter = function ( options = {} ) {
242
315
  } );
243
316
 
244
317
  /**
245
- * Route a status payload: to the caller's handler when supplied;
246
- * otherwise error-path payloads go to a classified console.error
247
- * (never silent — ADR-018's two-party rule) and lifecycle
248
- * payloads stay quiet.
318
+ * Hand a status payload to the caller's handler, when there is
319
+ * one. The facade lines are printed by the callers of this
320
+ * function, so a payload with no handler is not silent.
249
321
  *
250
322
  * @param {Object} payload - The structured status payload
251
323
  */
252
324
  const emitStatus = function ( payload ) {
253
325
  if ( safeOnStatus ) {
254
326
  safeOnStatus( payload );
255
- } else if ( payload.error ) {
256
- logger.error( `winkComposer/mqttSource: source error [${payload.error.code}]: ${payload.error.message}` );
257
327
  }
258
328
  };
259
329
 
@@ -261,31 +331,27 @@ const createStatusReporter = function ( options = {} ) {
261
331
  // `connected` and `phase` read the reporter's live state at fault
262
332
  // time, so the payload tells the truth about the moment it fired.
263
333
  //
264
- // The console line comes first and always fires. It is the
265
- // guaranteed audience: inside a flow the runtime installs its own
266
- // onStatus wrapper, which forwards only red payloads when the
267
- // user gave no handler, so the yellow payload alone could vanish
268
- // (fresh-eyes find, 2026-08-28). The payload then additionally
269
- // reaches a listening handler, so a health view sees the
270
- // degradation. With no handler at all, emitStatus would print
271
- // the same line a second time — skip the payload there.
334
+ // The facade line comes first and always fires. It is the
335
+ // guaranteed audience. Inside a flow the runtime installs its own
336
+ // onStatus wrapper. That wrapper forwards only red payloads when
337
+ // the user gave no handler, so the yellow payload alone could
338
+ // vanish (fresh-eyes find, 2026-08-28). The payload then
339
+ // additionally reaches a listening handler, so a health view sees
340
+ // the degradation.
272
341
  const safeOnMetrics = wrapCallback( onMetrics, {
273
342
  name: 'onMetrics',
274
343
  severity: 'yellow',
275
344
  report: function ( severity, name, detail ) {
276
- const message = `user callback ${name} failed: ${detail}`;
277
345
  logger.error( `winkComposer/mqttSource: user callback ${name} failed [CALLBACK_FAILED]: ${detail}` );
278
- if ( safeOnStatus ) {
279
- emitStatus( {
280
- status: severity,
281
- connected,
282
- phase,
283
- error: {
284
- code: 'CALLBACK_FAILED',
285
- message
286
- }
287
- } );
288
- }
346
+ emitStatus( {
347
+ status: severity,
348
+ connected,
349
+ phase,
350
+ error: {
351
+ code: 'CALLBACK_FAILED',
352
+ message: `user callback ${name} failed: ${detail}`
353
+ }
354
+ } );
289
355
  }
290
356
  } );
291
357
 
@@ -352,7 +418,7 @@ const createStatusReporter = function ( options = {} ) {
352
418
  return lastSubscribeError;
353
419
  }
354
420
  if ( code === 'CONNECTION_LOST' ) {
355
- return `not connected for ${now - disconnectedAt}ms (red threshold ${DISCONNECT_RED_MS}ms)`;
421
+ return `not connected for ${Math.round( now - disconnectedAt )}ms (red threshold ${DISCONNECT_RED_MS}ms)`;
356
422
  }
357
423
  if ( code === 'CONNECT_FAILED' ) {
358
424
  return lastConnectError;
@@ -361,7 +427,43 @@ const createStatusReporter = function ( options = {} ) {
361
427
  return `decode-error ratio above 1% over the last ${ringCount} messages`;
362
428
  }
363
429
  // QUIET_PERIOD_EXCEEDED — the only code left.
364
- return `no message received for ${now - lastReceivedAt}ms (expected quiet period ${expectedQuietPeriodMs}ms)`;
430
+ return `no message received for ${Math.round( now - lastReceivedAt )}ms (expected quiet period ${expectedQuietPeriodMs}ms)`;
431
+ };
432
+
433
+ /**
434
+ * Print one facade line per health edge (ADR-028). An edge is a
435
+ * change of status or code. A yellow edge prints at warn, a red
436
+ * edge at error. The return to green prints at warn with the
437
+ * seconds since the first non-green edge of the episode. A stop
438
+ * is not a recovery, so it prints nothing.
439
+ *
440
+ * @param {string} status - The status just computed
441
+ * @param {number} now - Current timestamp
442
+ */
443
+ const printEdge = function ( status, now ) {
444
+ if ( status === printedStatus && healthCode === printedCode ) {
445
+ return;
446
+ }
447
+ if ( status === 'green' ) {
448
+ if ( phase !== 'stopped' ) {
449
+ const seconds = Math.round( ( now - degradedSince ) / 1000 );
450
+ const what = printedCode === null ? `: ${degradedPhase} cleared` : ` [${printedCode}]: cleared`;
451
+ logger.warn( `winkComposer/mqttSource: source recovered${what} after ${seconds} s` );
452
+ }
453
+ } else {
454
+ if ( printedStatus === 'green' ) {
455
+ degradedSince = now;
456
+ degradedPhase = phase;
457
+ }
458
+ const what = healthCode === null ? `: ${phase}` : ` [${healthCode}]: ${healthMessage( healthCode, now )}`;
459
+ if ( status === 'red' ) {
460
+ logger.error( `winkComposer/mqttSource: source error${what}` );
461
+ } else {
462
+ logger.warn( `winkComposer/mqttSource: source degraded${what}` );
463
+ }
464
+ }
465
+ printedStatus = status;
466
+ printedCode = healthCode;
365
467
  };
366
468
 
367
469
  /**
@@ -379,11 +481,12 @@ const createStatusReporter = function ( options = {} ) {
379
481
  lastPhase = phase;
380
482
  lastCode = healthCode;
381
483
 
484
+ printEdge( status, now );
382
485
  const payload = {
383
486
  status,
384
487
  connected,
385
488
  phase,
386
- msSinceLastMsg: now - lastReceivedAt
489
+ msSinceLastMsg: Math.round( now - lastReceivedAt )
387
490
  };
388
491
  if ( healthCode !== null ) {
389
492
  payload.error = { code: healthCode, message: healthMessage( healthCode, now ) };
@@ -476,7 +579,8 @@ const createStatusReporter = function ( options = {} ) {
476
579
  /**
477
580
  * The stop's time budget ran out and the socket was forced
478
581
  * closed. Reported with the sources' shared `note` convention
479
- * (CSV and testHarness use the same wording).
582
+ * (CSV and testHarness use the same wording), and as one warn
583
+ * line.
480
584
  *
481
585
  * @param {number} timeoutMs - The exceeded budget
482
586
  */
@@ -484,15 +588,19 @@ const createStatusReporter = function ( options = {} ) {
484
588
  phase = 'stopped';
485
589
  connected = false;
486
590
  const now = nowFn();
591
+ const note = `Stop took longer than ${timeoutMs}ms — forced.`;
487
592
  lastStatus = 'yellow';
488
593
  lastPhase = 'stopped';
489
594
  lastCode = null;
595
+ printedStatus = 'yellow';
596
+ printedCode = null;
597
+ logger.warn( `winkComposer/mqttSource: source stopped: ${note}` );
490
598
  emitStatus( {
491
599
  status: 'yellow',
492
600
  connected: false,
493
601
  phase: 'stopped',
494
- msSinceLastMsg: now - lastReceivedAt,
495
- note: `Stop took longer than ${timeoutMs}ms — forced.`
602
+ msSinceLastMsg: Math.round( now - lastReceivedAt ),
603
+ note
496
604
  } );
497
605
  emitMetrics();
498
606
  },
@@ -527,9 +635,10 @@ const createStatusReporter = function ( options = {} ) {
527
635
  },
528
636
 
529
637
  /**
530
- * A payload could not be decoded. Emits the mandated
531
- * per-record report (ADR-018: skip, classify, continue — never
532
- * silent), then re-evaluates the ratio rule.
638
+ * A payload could not be decoded. Prints the bounded facade
639
+ * line, emits the mandated per-record report (ADR-018: skip,
640
+ * classify, continue — never silent), then re-evaluates the
641
+ * ratio rule.
533
642
  *
534
643
  * @param {string} detail - Operator-facing description
535
644
  */
@@ -538,6 +647,7 @@ const createStatusReporter = function ( options = {} ) {
538
647
  skipped += 1;
539
648
  lastReceivedAt = nowFn();
540
649
  ringPush( 1 );
650
+ decodeLine( detail );
541
651
  emitStatus( {
542
652
  status: 'yellow',
543
653
  connected,
@@ -555,17 +665,19 @@ const createStatusReporter = function ( options = {} ) {
555
665
  },
556
666
 
557
667
  /**
558
- * The user's transform threw. Emits the mandated per-record
559
- * report (skip, classify, continue — the transform contract,
560
- * source-transform-uniformity 2026-07-11), classified as
561
- * CALLBACK_FAILED: user code, never a transport failure. No
562
- * evaluate() — a transform throw feeds no health rule, so
563
- * there is no transition to re-derive.
668
+ * The user's transform threw. Prints the bounded facade line
669
+ * and emits the mandated per-record report (skip, classify,
670
+ * continue — the transform contract, source-transform-
671
+ * uniformity 2026-07-11), classified as CALLBACK_FAILED: user
672
+ * code, never a transport failure. No evaluate() — a transform
673
+ * throw feeds no health rule, so there is no transition to
674
+ * re-derive.
564
675
  *
565
676
  * @param {string} detail - Operator-facing description
566
677
  */
567
678
  transformFailed: function ( detail ) {
568
679
  skipped += 1;
680
+ transformLine( detail );
569
681
  emitStatus( {
570
682
  status: 'yellow',
571
683
  connected,