muaddib-scanner 2.11.119 → 2.11.120

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.119",
3
+ "version": "2.11.120",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-16T08:29:32.212Z",
3
+ "timestamp": "2026-06-18T19:56:37.339Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -13,6 +13,7 @@ const { poll, getPollBackoffMs, SOFT_BACKPRESSURE_THRESHOLD } = require('./inges
13
13
  const { ensureWorkers, drainWorkers, getTargetConcurrency, setTargetConcurrency, getActiveWorkers, terminateAllWorkers, getInFlightItems, computeInterruptDisposition } = require('./queue.js');
14
14
  const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY } = require('./adaptive-concurrency.js');
15
15
  const { startHealthcheck } = require('./healthcheck.js');
16
+ const { startLagSampler } = require('./event-loop-monitor.js');
16
17
  const { startDeferredWorker, stopDeferredWorker, persistDeferredQueue, restoreDeferredQueue, clearDeferredQueue } = require('./deferred-sandbox.js');
17
18
  const { evictFromScanQueueBulk, enqueueScan } = require('./scan-queue.js');
18
19
  const { isSpillEnabled, shouldDrain, drainBacklog, getBacklogSize } = require('./spill.js');
@@ -966,6 +967,7 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
966
967
  let pollIntervalHandle = null; // Decoupled poll timer — set after initial poll
967
968
  let queuePersistHandle = null; // Queue persistence timer
968
969
  let concurrencyAdjustHandle = null; // Adaptive concurrency timer
970
+ let loopLagSamplerStop = null; // Event-loop stall attribution (instrumentation)
969
971
 
970
972
  // Restore queue from previous run (if file exists and is < 24h old)
971
973
  const restoredCount = restoreQueue(scanQueue);
@@ -1001,6 +1003,10 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
1001
1003
  clearInterval(concurrencyAdjustHandle);
1002
1004
  concurrencyAdjustHandle = null;
1003
1005
  }
1006
+ if (loopLagSamplerStop) {
1007
+ loopLagSamplerStop();
1008
+ loopLagSamplerStop = null;
1009
+ }
1004
1010
  // Bounded drain (phase C, C7). The old unbounded `await drainWorkers()`
1005
1011
  // could outlive systemd's TimeoutStopSec (scans run up to 300s): SIGKILL
1006
1012
  // then landed MID-drain, persistQueue never ran, and every in-flight scan
@@ -1109,6 +1115,24 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
1109
1115
  }
1110
1116
  }, ADJUST_INTERVAL_MS);
1111
1117
 
1118
+ // ─── Event-loop stall attribution (instrumentation only — see event-loop-monitor.js) ───
1119
+ // The RSS breaker, governor RSS feed and EMERGENCY purge all live on these
1120
+ // main-thread timers; a long SYNC op (extractAllTo) that wedges the loop
1121
+ // disables them silently → unchecked RSS climb → cgroup OOM (4-6 min of zero
1122
+ // completions/logs before every kill, 2026-06-17/18). NAME the culprit op so the
1123
+ // real fix targets it. Off-switch: MUADDIB_LOOP_MONITOR=0.
1124
+ if (process.env.MUADDIB_LOOP_MONITOR !== '0') {
1125
+ loopLagSamplerStop = startLagSampler({
1126
+ onStall: (r) => {
1127
+ const o = r.op;
1128
+ const where = o
1129
+ ? ` during op=${o.label}${o.meta && o.meta.name ? ` ${o.meta.name}@${o.meta.version || '?'}${o.meta.unpackedSizeMb != null ? ` (${o.meta.unpackedSizeMb}MB)` : ''}` : ''}${o.running ? ', STILL RUNNING' : ''}`
1130
+ : ' (no op breadcrumb — widen instrumentation)';
1131
+ console.warn(`[MONITOR] LOOP-STALL: event loop blocked ${r.blockedSec}s${where} | rss=${r.rssMb}MB`);
1132
+ }
1133
+ });
1134
+ }
1135
+
1112
1136
  // ─── Decoupled polling ───
1113
1137
  // Poll runs on its own interval, independent of processing.
1114
1138
  // This ensures new packages are ingested even while a large batch is being scanned.
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Event-loop stall ATTRIBUTION (2026-06-18). Pure observability — NO change to
5
+ * scan behavior, scoring, or detection.
6
+ *
7
+ * Why: every in-process OOM defense — the RSS circuit breaker, the memory
8
+ * governor's RSS feed (`updateGovernorRss`), the EMERGENCY queue purge — runs on
9
+ * the main-thread `setInterval` poll loop (`daemon.js`). A long SYNCHRONOUS op on
10
+ * that loop wedges it: none of those timers fire, so RSS climbs to the cgroup
11
+ * MemoryMax (9.5G) unchecked → kernel SIGKILL (cgroup OOM). Measured signature
12
+ * 2026-06-17/18: 4-6 min of ZERO scan completions AND zero daemon log lines
13
+ * immediately before EVERY OOM kill. Leading suspect for the block: adm-zip
14
+ * `extractAllTo` (synchronous), which `extractArchive` runs on the main thread for
15
+ * the large-package quick-scan (4071 size_skip/24h) and pre-worker extraction.
16
+ *
17
+ * This module does NOT fix the stall — it NAMES it, so the next refactor targets
18
+ * the confirmed culprit instead of a guess:
19
+ * - `beginOp`/`endOp` leave a breadcrumb naming the op (and package) in flight.
20
+ * - `startLagSampler` runs its OWN low-frequency timer; its tardiness measures
21
+ * how long the loop was blocked (the sampler is starved the same way the
22
+ * breaker is, but decoupled so it can't perturb the breaker). On a stall it
23
+ * reports the op whose lifetime OVERLAPS the blocked window — the still-running
24
+ * op, or the one that just ended (the blocking op has usually returned by the
25
+ * time the loop frees and the sampler can finally fire).
26
+ * Resolving stalls share their source with the fatal one, so attributing the
27
+ * resolving precursors pins the culprit.
28
+ *
29
+ * Bounded (CLAUDE.md §2): a single current + single last-ended breadcrumb slot;
30
+ * the stall log is size-capped. Instrumentation NEVER throws.
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ const STALL_LOG_FILE = process.env.MUADDIB_LOOP_STALL_FILE
37
+ || path.join(__dirname, '..', '..', 'data', 'loop-stalls.jsonl');
38
+ const STALL_LOG_MAX_BYTES = 256 * 1024; // bounded: truncate-then-append past this
39
+
40
+ // Threshold 5s: far above normal GC/IO jitter (sub-100ms) and a full scan's
41
+ // main-thread slice, far below the multi-minute fatal stalls — catches the
42
+ // resolving precursors without noise. Sampler cadence 1s.
43
+ const DEFAULT_INTERVAL_MS = 1000;
44
+ const DEFAULT_THRESHOLD_MS = 5000;
45
+
46
+ // Injectable clock (tests drive it via _reset(clockFn)); default real time.
47
+ let _clock = () => Date.now();
48
+ function _now() { return _clock(); }
49
+
50
+ // ─── Breadcrumb: the op currently / most-recently on the main thread ───
51
+ let _current = null; // { label, meta, startedAt }
52
+ let _lastEnded = null; // { label, meta, startedAt, endedAt }
53
+
54
+ /** Mark the start of a (potentially blocking, synchronous) main-thread op. */
55
+ function beginOp(label, meta) {
56
+ _current = { label: String(label || 'op'), meta: meta || null, startedAt: _now() };
57
+ return _current;
58
+ }
59
+
60
+ /** Mark it done. Token optional; a mismatched token is ignored (nesting-safe). */
61
+ function endOp(token) {
62
+ if (!_current) return;
63
+ if (token && token !== _current) return;
64
+ _lastEnded = { ..._current, endedAt: _now() };
65
+ _current = null;
66
+ }
67
+
68
+ /**
69
+ * The op whose lifetime overlaps [windowStartMs, windowEndMs] — i.e. the op on
70
+ * the thread during the blocked window. Prefers the still-running op, else the
71
+ * one that ended inside the window. Null when nothing was instrumented (a real
72
+ * signal: widen the breadcrumbs).
73
+ */
74
+ function opOverlapping(windowStartMs, windowEndMs) {
75
+ if (_current && _current.startedAt <= windowEndMs) {
76
+ return { label: _current.label, meta: _current.meta, running: true, elapsedMs: _now() - _current.startedAt };
77
+ }
78
+ if (_lastEnded && _lastEnded.endedAt >= windowStartMs && _lastEnded.startedAt <= windowEndMs) {
79
+ return { label: _lastEnded.label, meta: _lastEnded.meta, running: false, durationMs: _lastEnded.endedAt - _lastEnded.startedAt };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // ─── Lag core (pure, unit-testable: timestamps in, no timers) ───
85
+ const _state = { lastTickMs: null, intervalMs: DEFAULT_INTERVAL_MS, thresholdMs: DEFAULT_THRESHOLD_MS, maxLagMs: 0, stalls: 0 };
86
+
87
+ function configure(opts = {}) {
88
+ if (Number.isFinite(opts.intervalMs)) _state.intervalMs = opts.intervalMs;
89
+ if (Number.isFinite(opts.thresholdMs)) _state.thresholdMs = opts.thresholdMs;
90
+ }
91
+
92
+ /**
93
+ * Feed one sampler tick. Returns { lagMs, windowStartMs, firstTick }. lagMs = how
94
+ * much LATER than the configured interval this tick fired = the time the loop was
95
+ * blocked since the previous tick. First call seeds (lag 0).
96
+ */
97
+ function observeTick(nowMs) {
98
+ if (_state.lastTickMs === null) {
99
+ _state.lastTickMs = nowMs;
100
+ return { lagMs: 0, windowStartMs: nowMs, firstTick: true };
101
+ }
102
+ const windowStartMs = _state.lastTickMs;
103
+ const lagMs = Math.max(0, nowMs - _state.lastTickMs - _state.intervalMs);
104
+ _state.lastTickMs = nowMs;
105
+ if (lagMs > _state.maxLagMs) _state.maxLagMs = lagMs;
106
+ return { lagMs, windowStartMs, firstTick: false };
107
+ }
108
+
109
+ function isStall(lagMs, thresholdMs = _state.thresholdMs) { return lagMs >= thresholdMs; }
110
+ function getMaxLagMs() { return _state.maxLagMs; }
111
+ function getStallCount() { return _state.stalls; }
112
+
113
+ function _appendStall(record) {
114
+ try {
115
+ try {
116
+ const st = fs.statSync(STALL_LOG_FILE);
117
+ if (st.size > STALL_LOG_MAX_BYTES) fs.truncateSync(STALL_LOG_FILE, 0); // bounded
118
+ } catch { /* absent — first write */ }
119
+ fs.appendFileSync(STALL_LOG_FILE, JSON.stringify(record) + '\n');
120
+ } catch { /* instrumentation must never throw */ }
121
+ }
122
+
123
+ /** Build the structured stall record for a detected lag (pure; exported for tests). */
124
+ function buildStallRecord(lagMs, windowStartMs, nowMs) {
125
+ const op = opOverlapping(windowStartMs, nowMs);
126
+ return {
127
+ ts: new Date(nowMs).toISOString(),
128
+ lagMs,
129
+ blockedSec: Math.round(lagMs / 100) / 10,
130
+ op: op
131
+ ? { label: op.label, meta: op.meta, running: op.running, durationMs: op.running ? op.elapsedMs : op.durationMs }
132
+ : null,
133
+ rssMb: Math.round(process.memoryUsage().rss / 1024 / 1024)
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Start the lag sampler. Its OWN tardiness measures loop lag. unref'd: a pure
139
+ * monitor never keeps the process alive on its own. Returns a stop fn.
140
+ * onStall(record) is an optional extra sink (the daemon logs it); every stall is
141
+ * also appended to STALL_LOG_FILE for post-hoc analysis.
142
+ */
143
+ function startLagSampler(opts = {}) {
144
+ configure(opts);
145
+ _state.lastTickMs = null;
146
+ const onStall = typeof opts.onStall === 'function' ? opts.onStall : null;
147
+ const timer = setInterval(() => {
148
+ const now = Date.now();
149
+ const { lagMs, windowStartMs, firstTick } = observeTick(now);
150
+ if (firstTick || !isStall(lagMs)) return;
151
+ _state.stalls += 1;
152
+ const record = buildStallRecord(lagMs, windowStartMs, now);
153
+ _appendStall(record);
154
+ if (onStall) { try { onStall(record); } catch { /* ignore */ } }
155
+ }, _state.intervalMs);
156
+ if (timer && typeof timer.unref === 'function') timer.unref();
157
+ return () => clearInterval(timer);
158
+ }
159
+
160
+ /** Test helper: clear all state; optionally install a deterministic clock. */
161
+ function _reset(clockFn) {
162
+ _current = null;
163
+ _lastEnded = null;
164
+ _state.lastTickMs = null;
165
+ _state.intervalMs = DEFAULT_INTERVAL_MS;
166
+ _state.thresholdMs = DEFAULT_THRESHOLD_MS;
167
+ _state.maxLagMs = 0;
168
+ _state.stalls = 0;
169
+ _clock = typeof clockFn === 'function' ? clockFn : (() => Date.now());
170
+ }
171
+
172
+ module.exports = {
173
+ beginOp,
174
+ endOp,
175
+ opOverlapping,
176
+ configure,
177
+ observeTick,
178
+ isStall,
179
+ getMaxLagMs,
180
+ getStallCount,
181
+ buildStallRecord,
182
+ startLagSampler,
183
+ _reset,
184
+ _state,
185
+ STALL_LOG_FILE
186
+ };
@@ -22,6 +22,7 @@ const { buildTrainingRecord } = require('../ml/feature-extractor.js');
22
22
  const { appendWorkerMem } = require('./worker-mem.js');
23
23
  const { acquireHeavySlot, releaseHeavySlot, isHeavyScan, getHeavyLaneState, heavyWaitMaxMs, HEAVY_REQUEUE_MAX } = require('./heavy-lane.js');
24
24
  const { isGovernorEnabled, classifyWeight, acquireMemoryTicket, releaseMemoryTicket, isFrozen: isGovernorFrozen, getGovernorState } = require('./memory-governor.js');
25
+ const { beginOp, endOp } = require('./event-loop-monitor.js');
25
26
  const { appendRecord: appendTrainingRecord, relabelRecords } = require('../ml/jsonl-writer.js');
26
27
 
27
28
  // From ./state.js
@@ -764,7 +765,8 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
764
765
  // Validates actual tarball contents (not just registry metadata).
765
766
  let bypassQuickScan = false;
766
767
  try {
767
- extractedDir = extractArchive(tgzPath, tmpDir);
768
+ const _crumb = beginOp('extract:quickscan', { name, version, unpackedSizeMb: Math.round(unpackedSize / 1024 / 1024) });
769
+ try { extractedDir = extractArchive(tgzPath, tmpDir); } finally { endOp(_crumb); }
768
770
 
769
771
  const [pkgThreats, shellThreats] = await Promise.all([
770
772
  scanPackageJson(extractedDir),
@@ -813,7 +815,8 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
813
815
  }
814
816
 
815
817
  if (!extractedDir) {
816
- extractedDir = extractArchive(tgzPath, tmpDir);
818
+ const _crumb = beginOp('extract:prework', { name, version, unpackedSizeMb: Math.round((meta.unpackedSize || 0) / 1024 / 1024) });
819
+ try { extractedDir = extractArchive(tgzPath, tmpDir); } finally { endOp(_crumb); }
817
820
  }
818
821
 
819
822
  // ML Phase 2a: Count JS files and detect test presence for enriched features
@@ -1117,10 +1117,17 @@ function safeLedgerRollup() {
1117
1117
  function formatLedgerField(rollup) {
1118
1118
  if (!rollup || rollup.total <= 0) return null;
1119
1119
  const pct = rollup.alertRate != null ? (rollup.alertRate * 100).toFixed(2) : '0.00';
1120
- const lines = [`Scanned ${rollup.scanned} · Alerted ${rollup.alerted} (${pct}%)`];
1120
+ // All counts here are name@version scan EVENTS (NOT distinct package names — that
1121
+ // ratio is the headline's "Noms uniques"). alertRate = suspect+confirmed / scanned
1122
+ // (NOT a TPR — see computeLedgerRollup's HONEST METRIC NOTE).
1123
+ const lines = [`Scans: ${rollup.scanned} events · alertés ${rollup.alerted} (${pct}%)`];
1121
1124
  if (rollup.dropped > 0) {
1122
1125
  const vanishedNote = rollup.exactVanished ? `${rollup.vanished}` : `≥${rollup.vanished}`;
1123
- lines.push(`Dropped ${rollup.dropped} (${vanishedNote} vanished)`);
1126
+ // `dropped` folds in recoverable spill (backlog awaiting drain) + queue-cap evictions
1127
+ // + burst-extras; `vanished` is the distinct name@version subset never (re)scanned
1128
+ // in-window — still version-granular (a name with 50 dropped versions = 50 here) and
1129
+ // it too still includes not-yet-drained spill, so it is NOT a registry-removal count.
1130
+ lines.push(`Non scannés: ${rollup.dropped} events (${vanishedNote} name@ver jamais (re)scannés)`);
1124
1131
  }
1125
1132
  const ecos = Object.keys(rollup.byEcosystem)
1126
1133
  .sort((a, b) => rollup.byEcosystem[b].total - rollup.byEcosystem[a].total);
@@ -1235,36 +1242,35 @@ function buildDailyReportEmbed(stats, dailyAlerts, ledgerRollup) {
1235
1242
  const pypiPub = stats.pypiChangelogPackages || 0;
1236
1243
  const published = npmPub + pypiPub;
1237
1244
  const catchupSkipped = (stats.npmCatchupSkippedSeqs || 0) + (stats.pypiCatchupSkippedEvents || 0);
1238
- // Clarify the Ops headline so it isn't read as an overnight drop: it counts
1239
- // COMPLETED scans in the exact ledger window [last report → now], version/
1240
- // dedup-collapsed intentionally lower than the in-memory counter (stats.scanned),
1241
- // which also tallies retries, burst extras and size-cap rejections
1242
- // (cf. queue.js uniqueScanAttempts). Surface the raw counter when it diverges.
1243
- const opsQualifier = headline ? ' (completed, deduped, 24h)' : '';
1245
+ // UNIT DISCIPLINE (2026-06-18): the Coverage field stacked three counting units
1246
+ // with no labels, so the headline read as self-contradictory e.g.
1247
+ // "30K/90K pkgs · 112K vanished" looks like 112K > 90K packages (impossible), but
1248
+ // `vanished` counts name@VERSION events while the ratio counts distinct NAMES.
1249
+ // Each line below now states its unit; the version-granular drop/vanished detail
1250
+ // lives in the Ops embed's Ledger field, NOT next to a name ratio.
1251
+ // • "Noms uniques" → distinct package NAMES (version-collapsed) — the honest headline
1252
+ // • "Scans" → name@version scan events (same unit as Clean/Suspects/Errors)
1253
+ // • "compteur" → in-memory stats.scanned (events + retries + burst + size-cap)
1254
+ const opsQualifier = headline ? ' (events terminés)' : '';
1244
1255
  const rawCounter = (headline && typeof stats.scanned === 'number' && stats.scanned > hScanned)
1245
- ? ` · counter ${stats.scanned} (incl. retries/burst)`
1256
+ ? ` · compteur ${stats.scanned} (retries/burst inclus)`
1246
1257
  : '';
1247
1258
  const opsSuffix = catchupSkipped > 0
1248
- ? `\nOps: ${hScanned}${opsQualifier}${rawCounter} | Catch-up skip: ${catchupSkipped}`
1249
- : `\nOps: ${hScanned}${opsQualifier}${rawCounter}`;
1259
+ ? `\nScans: ${hScanned}${opsQualifier}${rawCounter} | Catch-up skip: ${catchupSkipped}`
1260
+ : `\nScans: ${hScanned}${opsQualifier}${rawCounter}`;
1250
1261
  let coverageText;
1251
1262
  if (ledger && ledger.distinctPackages > 0 && ledger.distinctCoverage != null) {
1252
1263
  const pct = (ledger.distinctCoverage * 100).toFixed(0);
1253
1264
  const approx = ledger.exactVanished === false ? '~' : '';
1254
- coverageText = `${ledger.distinctScanned}/${ledger.distinctPackages} pkgs (${approx}${pct}%)`;
1255
- // Honest 24h coverage loss surfaced next to coverage: `vanished` = distinct names
1256
- // dropped and never re-scanned in the window — the real miss count. The raw
1257
- // `dropped` aggregate (which also folds in recoverable spill + retries, so it
1258
- // OVERSTATES loss) is relegated to the Ops embed's Ledger field, not the headline.
1259
- if (ledger.vanished > 0) {
1260
- coverageText += ` · ${ledger.exactVanished ? '' : '≥'}${ledger.vanished} vanished`;
1261
- }
1262
- if (published > 0) coverageText += `\nRaw events: ${attempted}/${published}`;
1265
+ // Headline = distinct package NAMES scanned vs seen (version-collapsed, immune to
1266
+ // version-spam). The name@version drop/vanished detail is in the Ledger field below.
1267
+ coverageText = `Noms uniques: ${ledger.distinctScanned}/${ledger.distinctPackages} (${approx}${pct}%)`;
1268
+ if (published > 0) coverageText += `\nPubliés: ${attempted}/${published}`;
1263
1269
  coverageText += opsSuffix;
1264
1270
  } else if (published > 0) {
1265
1271
  // Fallback: ledger unavailable (first boot / empty ledger) → legacy event ratio.
1266
1272
  const coverageRatio = (attempted / published * 100).toFixed(0);
1267
- coverageText = `${attempted}/${published} (${coverageRatio}%)${opsSuffix}`;
1273
+ coverageText = `Publiés: ${attempted}/${published} (${coverageRatio}%)${opsSuffix}`;
1268
1274
  } else {
1269
1275
  coverageText = `${attempted} attempted${opsSuffix}`;
1270
1276
  }
@@ -1363,16 +1369,19 @@ function buildDailyReportEmbed(stats, dailyAlerts, ledgerRollup) {
1363
1369
  // --- Embed 2: Ops / system state (kept OUT of the daily headline) ---
1364
1370
  // Operator feedback: a daily that mixes 24h outcome with multi-day system state
1365
1371
  // reads as failure when it isn't. Each line here carries its own clock:
1366
- // • Ledger → 24h window. Its `dropped` folds in recoverable spill + retries,
1367
- // so it OVERSTATES loss `vanished` (in the Coverage field) is the
1368
- // honest miss count, which is why dropped sits here, not the headline.
1372
+ // • Ledger → 24h window, in name@version scan EVENTS (NOT package names
1373
+ // the name ratio is the headline's "Noms uniques"). `dropped` folds
1374
+ // in recoverable spill (backlog awaiting drain) + queue-cap evictions
1375
+ // + burst-extras; `vanished` is the distinct name@version subset never
1376
+ // (re)scanned in-window — also version-granular, and it too still
1377
+ // includes not-yet-drained spill, so neither is a registry-removal count.
1369
1378
  // • Stability → cumulative since the 08:00 reset (backlog = point-in-time depth
1370
1379
  // of the persistent spill file, the one snapshot in this field).
1371
1380
  // • Degradations / System → instantaneous snapshot (degradations have no TTL: if
1372
1381
  // shown, the condition is active right now, not earlier in the window).
1373
1382
  title: '⚙️ Ops / état système',
1374
1383
  color: 0x95a5a6,
1375
- description: 'Ledger = fenêtre 24h (dropped inclut le spill récupérable — voir « vanished » pour la perte réelle) · Stability = cumulé depuis 08:00 (backlog = instantané) · Degradations/System = instantané',
1384
+ description: 'Ledger = fenêtre 24h en events name@version (pas des noms de paquets — voir « Noms uniques » dans le headline) · « Non scannés » inclut le spill récupérable en attente de drain · Stability = cumulé depuis 08:00 (backlog = instantané) · Degradations/System = instantané',
1376
1385
  fields: [
1377
1386
  ...((stats.sandboxDeferred || stats.deferredProcessed || stats.deferredExpired)
1378
1387
  ? [{ name: 'Deferred Sandbox', value: `Enqueued: ${stats.sandboxDeferred || 0} | Processed: ${stats.deferredProcessed || 0} | Expired: ${stats.deferredExpired || 0}`, inline: false }]