@celilo/cli 0.26.1 → 0.27.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.
@@ -575,7 +575,17 @@ export async function getInstalledCollections(): Promise<Map<string, InstalledCo
575
575
  version: parseSemanticVersion(versionStr),
576
576
  path: path as string,
577
577
  });
578
- } catch {}
578
+ } catch (error) {
579
+ // Rule 6.2: never a bare catch. An unparseable version dropped the
580
+ // collection from the map entirely, so it read as NOT INSTALLED —
581
+ // and the installer would then try to install it again, every time,
582
+ // reporting success while nothing changed.
583
+ console.warn(
584
+ ` ⚠ Ignoring installed collection '${name}': unparseable version '${versionStr}' (${
585
+ error instanceof Error ? error.message : String(error)
586
+ })`,
587
+ );
588
+ }
579
589
  }
580
590
  }
581
591
  }
@@ -587,6 +597,41 @@ export async function getInstalledCollections(): Promise<Map<string, InstalledCo
587
597
  }
588
598
  }
589
599
 
600
+ /**
601
+ * Decide what an integrity outcome means for the import, and say so.
602
+ *
603
+ * A REFUTED collection stops the import; an UNCHECKABLE one warns. The split is
604
+ * the point (celilo#524):
605
+ *
606
+ * - `mismatch` — files on disk do not match the collection's own manifest.
607
+ * Ansible is about to execute that content, and celilo cannot account for
608
+ * it. Refusing costs an import; accepting runs unaccounted-for code.
609
+ * - `unverifiable` — the check could not run, most often because the
610
+ * collection ships no `file_manifest_file` checksum at all. That is a
611
+ * property of the publisher, not evidence of tampering, and blocking on it
612
+ * would refuse ordinary collections forever. It warns.
613
+ *
614
+ * Refusing is cheap HERE specifically. This runs during `module import`
615
+ * (`module/import.ts`), the only caller, and nothing installs collections in
616
+ * the deploy path — so a false positive means "this module did not import",
617
+ * not "the fleet stopped deploying". There is also no module row yet, so
618
+ * nothing is left half-created to clean up.
619
+ *
620
+ * ⚠️ This is SELF-ATTESTATION, not provenance: the checksums live inside the
621
+ * artifact being checked. It catches corruption and post-install modification.
622
+ * It cannot catch a coherently re-signed tampered collection, and a clean
623
+ * import must not be read as saying otherwise.
624
+ */
625
+ export function reportIntegrity(name: string, outcome: IntegrityOutcome): string | null {
626
+ if (outcome.status === 'mismatch') {
627
+ return `Integrity check FAILED for Ansible collection '${name}': ${outcome.detail}.\nThe installed files do not match the collection's own manifest — this is corruption or tampering.\nImport refused rather than run unverified content.`;
628
+ }
629
+ if (outcome.status === 'unverifiable') {
630
+ console.warn(` ⚠ Integrity could not be checked for ${name}: ${outcome.reason} (non-fatal)`);
631
+ }
632
+ return null;
633
+ }
634
+
590
635
  /**
591
636
  * Install result
592
637
  */
@@ -599,16 +644,22 @@ export interface InstallResult {
599
644
  }
600
645
 
601
646
  /**
602
- * Verify checksum of a file
647
+ * The three things checking one file's checksum can tell you.
603
648
  *
604
- * @param filePath - Path to file
605
- * @param expectedChecksum - Expected SHA256 checksum
606
- * @returns True if checksum matches, null if file doesn't exist
607
- */
608
- async function verifyFileChecksum(
609
- filePath: string,
610
- expectedChecksum: string,
611
- ): Promise<boolean | null> {
649
+ * Named states rather than `boolean | null` (celilo#524). The tri-state was
650
+ * right a file that is not on disk is genuinely neither a match nor a
651
+ * mismatch but spelled as `null` it read as "nothing to worry about", and
652
+ * every caller duly treated it as one. `absent` cannot be misread.
653
+ */
654
+ export type FileCheck = 'match' | 'mismatch' | 'absent';
655
+
656
+ /**
657
+ * Verify one file against its expected SHA-256.
658
+ *
659
+ * `absent` means the file could not be read at all, which is a statement about
660
+ * our knowledge, not about the file's integrity.
661
+ */
662
+ async function verifyFileChecksum(filePath: string, expectedChecksum: string): Promise<FileCheck> {
612
663
  const { createHash } = await import('node:crypto');
613
664
  const { readFile, access } = await import('node:fs/promises');
614
665
  const { constants } = await import('node:fs');
@@ -621,112 +672,130 @@ async function verifyFileChecksum(
621
672
  const hash = createHash('sha256');
622
673
  hash.update(content);
623
674
  const actualChecksum = hash.digest('hex');
624
- return actualChecksum === expectedChecksum;
675
+ return actualChecksum === expectedChecksum ? 'match' : 'mismatch';
625
676
  } catch (_error: unknown) {
626
- // File doesn't exist or can't be read - this is OK, not all files are installed
627
- return null;
677
+ // Unreadable. NOT evidence of integrity see the type's note.
678
+ return 'absent';
628
679
  }
629
680
  }
630
681
 
631
682
  /**
632
- * Verify integrity of installed collection
683
+ * What a verification run actually established.
684
+ *
685
+ * Three states, because there are three (celilo#524). The function used to
686
+ * return `boolean` and could not, in practice, return `false`: three
687
+ * independent paths reported "integrity verified" for a collection that had
688
+ * been tampered with, and no constructible input reached the failure return.
689
+ *
690
+ * `unverifiable` is the state that was missing. A manifest that declares no
691
+ * checksum, or a file set with nothing on disk to sample, tells you NOTHING
692
+ * about integrity — and folding that into `verified` is what let the one thing
693
+ * an attacker fully controls (the manifest they ship) switch the check off.
694
+ * Same absent-vs-empty distinction as `parseInterfaceBaseline`.
695
+ */
696
+ export type IntegrityOutcome =
697
+ | { status: 'verified'; filesChecked: number }
698
+ /** Something was checked and did not match. Integrity is refuted. */
699
+ | { status: 'mismatch'; detail: string }
700
+ /** Nothing could be checked. Integrity is unknown — NOT confirmed. */
701
+ | { status: 'unverifiable'; reason: string };
702
+
703
+ /**
704
+ * Verify an installed Galaxy collection against the checksums in its own
705
+ * MANIFEST.json / FILES.json.
633
706
  *
634
- * @param collectionInfo - Collection installation info
635
- * @returns True if integrity checks pass
707
+ * ⚠️ This is self-attestation, not provenance: the checksums live inside the
708
+ * artifact being checked. It detects corruption and post-install modification,
709
+ * and it CANNOT detect a coherently re-signed tampered collection. That is a
710
+ * reason to be precise about what it reports, not a reason to report a
711
+ * comforting answer.
636
712
  */
637
- async function verifyCollectionIntegrity(collectionInfo: InstalledCollection): Promise<boolean> {
713
+ export async function verifyCollectionIntegrity(
714
+ collectionInfo: InstalledCollection,
715
+ ): Promise<IntegrityOutcome> {
638
716
  const { readFile } = await import('node:fs/promises');
639
717
  const { join } = await import('node:path');
640
718
 
719
+ const [namespace, collection] = collectionInfo.name.split('.');
720
+ const dir = join(collectionInfo.path, namespace, collection);
721
+
641
722
  try {
642
- // Read MANIFEST.json
643
- const manifestPath = join(
644
- collectionInfo.path,
645
- collectionInfo.name.split('.')[0],
646
- collectionInfo.name.split('.')[1],
647
- 'MANIFEST.json',
648
- );
649
- const manifestContent = await readFile(manifestPath, 'utf-8');
650
723
  const manifest = parseJsonWithValidation(
651
- manifestContent,
724
+ await readFile(join(dir, 'MANIFEST.json'), 'utf-8'),
652
725
  GalaxyManifestSchema,
653
726
  'Ansible Galaxy MANIFEST.json',
654
727
  );
655
728
 
656
- // Verify FILES.json checksum
657
- const filesJsonPath = join(
658
- collectionInfo.path,
659
- collectionInfo.name.split('.')[0],
660
- collectionInfo.name.split('.')[1],
661
- 'FILES.json',
662
- );
663
-
664
729
  const expectedFilesChecksum = manifest.file_manifest_file?.chksum_sha256;
665
730
  if (!expectedFilesChecksum) {
666
- // No checksum to verify
667
- return true;
731
+ // Was `return true`. Stripping `file_manifest_file` from the manifest was
732
+ // enough to report a tampered collection as verified.
733
+ return {
734
+ status: 'unverifiable',
735
+ reason: 'MANIFEST.json declares no FILES.json checksum',
736
+ };
668
737
  }
669
738
 
670
- const filesChecksumValid = await verifyFileChecksum(filesJsonPath, expectedFilesChecksum);
671
- if (!filesChecksumValid) {
672
- console.warn(` ⚠ Checksum mismatch for FILES.json in ${collectionInfo.name}`);
673
- return false;
739
+ const filesJsonPath = join(dir, 'FILES.json');
740
+ switch (await verifyFileChecksum(filesJsonPath, expectedFilesChecksum)) {
741
+ case 'mismatch':
742
+ return { status: 'mismatch', detail: 'FILES.json does not match its manifest checksum' };
743
+ case 'absent':
744
+ return { status: 'unverifiable', reason: 'FILES.json is missing or unreadable' };
674
745
  }
675
746
 
676
- // Sample verify a few files from FILES.json
677
- const filesContent = await readFile(filesJsonPath, 'utf-8');
678
747
  const filesData = parseJsonWithValidation(
679
- filesContent,
748
+ await readFile(filesJsonPath, 'utf-8'),
680
749
  GalaxyFilesSchema,
681
750
  'Ansible Galaxy FILES.json',
682
751
  );
683
752
  const files = filesData.files.filter(
684
753
  (f: { ftype: string; chksum_sha256?: string }) => f.ftype === 'file' && f.chksum_sha256,
685
754
  );
755
+ if (files.length === 0) {
756
+ return { status: 'unverifiable', reason: 'FILES.json lists no checksummed files' };
757
+ }
686
758
 
687
- // Verify up to 5 random files that exist
759
+ // Sample rather than verify everything: a large collection is thousands of
760
+ // files and this runs on every deploy. Sampling bounds the cost; what it
761
+ // must never do is report a sample of ZERO as a pass.
688
762
  let verified = 0;
689
763
  let attempts = 0;
690
764
  const maxAttempts = Math.min(20, files.length);
691
765
 
692
766
  while (verified < 5 && attempts < maxAttempts) {
767
+ attempts++;
693
768
  const file = files[Math.floor(Math.random() * files.length)];
769
+ if (!file.chksum_sha256) continue;
694
770
 
695
- // Skip files without checksums
696
- if (!file.chksum_sha256) {
697
- attempts++;
698
- continue;
699
- }
700
-
701
- const filePath = join(
702
- collectionInfo.path,
703
- collectionInfo.name.split('.')[0],
704
- collectionInfo.name.split('.')[1],
705
- file.name,
706
- );
707
-
708
- const fileChecksumValid = await verifyFileChecksum(filePath, file.chksum_sha256);
709
-
710
- if (fileChecksumValid === false) {
711
- // File exists but checksum mismatch - this is a real error
712
- console.warn(` ⚠ Checksum mismatch for ${file.name} in ${collectionInfo.name}`);
713
- return false;
771
+ const result = await verifyFileChecksum(join(dir, file.name), file.chksum_sha256);
772
+ if (result === 'mismatch') {
773
+ return { status: 'mismatch', detail: `${file.name} does not match its checksum` };
714
774
  }
775
+ if (result === 'match') verified++;
776
+ // 'absent' — try another. Bounded by maxAttempts, and a run that finds
777
+ // nothing present is reported as unverifiable below, not as a pass.
778
+ }
715
779
 
716
- if (fileChecksumValid === true) {
717
- // File exists and checksum matches
718
- verified++;
719
- }
720
-
721
- // If fileChecksumValid === null, file doesn't exist, try another
722
-
723
- attempts++;
780
+ if (verified === 0) {
781
+ // Was `return true`. A collection whose every listed file was missing
782
+ // from disk verified clean.
783
+ return {
784
+ status: 'unverifiable',
785
+ reason: `none of the ${attempts} sampled file(s) were present on disk`,
786
+ };
724
787
  }
725
788
 
726
- return true;
727
- } catch {
728
- // If verification fails, log warning but don't fail install
729
- return true;
789
+ return { status: 'verified', filesChecked: verified };
790
+ } catch (error) {
791
+ // Was `return true` behind a comment claiming it logged, which it did not
792
+ // so ANY exception in the body above (unreadable manifest, schema
793
+ // violation, malformed JSON) was reported as integrity verified. Rule 6.2:
794
+ // the caller renders this, and it is now a distinct outcome from success.
795
+ return {
796
+ status: 'unverifiable',
797
+ reason: error instanceof Error ? error.message : String(error),
798
+ };
730
799
  }
731
800
  }
732
801
 
@@ -778,10 +847,11 @@ export async function installAnsibleCollections(
778
847
  const freshInstalled = await getInstalledCollections();
779
848
  const installedCollection = freshInstalled.get(req.name);
780
849
  if (installedCollection) {
781
- const integrityOk = await verifyCollectionIntegrity(installedCollection);
782
- if (!integrityOk) {
783
- console.warn(` ⚠ Integrity check failed for ${req.name} (non-fatal)`);
784
- }
850
+ const refusal = reportIntegrity(
851
+ req.name,
852
+ await verifyCollectionIntegrity(installedCollection),
853
+ );
854
+ if (refusal) return { success: false, installed, skipped, error: refusal };
785
855
  }
786
856
  } catch (installError) {
787
857
  // Installation failed
@@ -800,10 +870,8 @@ export async function installAnsibleCollections(
800
870
  skipped.push(`${req.name} ${existingVersionStr}`);
801
871
 
802
872
  // Verify integrity of existing collection
803
- const integrityOk = await verifyCollectionIntegrity(existing);
804
- if (!integrityOk) {
805
- console.warn(` ⚠ Integrity check failed for ${req.name} (non-fatal)`);
806
- }
873
+ const refusal = reportIntegrity(req.name, await verifyCollectionIntegrity(existing));
874
+ if (refusal) return { success: false, installed, skipped, error: refusal };
807
875
  }
808
876
  }
809
877
 
@@ -94,9 +94,20 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
94
94
 
95
95
  // The reason escalation declined is the single most useful fact when someone
96
96
  // asks "why was I not paged", so name it rather than aggregating it away.
97
- const skipped = Object.entries(report.skipped).sort(([, a], [, b]) => b - a);
98
- if (skipped.length > 0) {
99
- lines.push(` not delivered: ${skipped.map(([r, n]) => `${r}×${n}`).join(', ')}`);
97
+ if (report.skipped.length > 0) {
98
+ // Aggregate first the shape of a sweep at a glance — then name each alert.
99
+ // A bare `within_grace×2` does not answer "why was I not paged" for the
100
+ // alert someone is actually looking at, and that question is the whole
101
+ // reason the reason is reported at all (#450).
102
+ const byReason = new Map<string, number>();
103
+ for (const { reason } of report.skipped) {
104
+ byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
105
+ }
106
+ const counts = [...byReason].sort(([, a], [, b]) => b - a);
107
+ lines.push(` not delivered: ${counts.map(([r, n]) => `${r}×${n}`).join(', ')}`);
108
+ for (const { alertKey, reason } of report.skipped) {
109
+ lines.push(` ${alertKey} (${reason})`);
110
+ }
100
111
  }
101
112
  // The error itself, not just a count: the transport is loaded lazily inside
102
113
  // the send, so a capability that will not load produces no other record
@@ -298,7 +298,6 @@ export async function handleMachineAdd(
298
298
  hardware: detectedInfo.hardware,
299
299
  role,
300
300
  interfaces,
301
- assignedModuleIds: [],
302
301
  earmarkedModule: earmark || null,
303
302
  });
304
303
 
@@ -4,7 +4,11 @@
4
4
  */
5
5
 
6
6
  import type { NetworkZone } from '../../db/schema';
7
- import { type MachineFilters, listMachines } from '../../services/machine-pool';
7
+ import {
8
+ type MachineFilters,
9
+ getModulesOnMachine,
10
+ listMachines,
11
+ } from '../../services/machine-pool';
8
12
  import { celiloIntro } from '../prompts';
9
13
  import type { CommandResult } from '../types';
10
14
 
@@ -38,9 +42,11 @@ export async function handleMachineList(
38
42
 
39
43
  console.log('');
40
44
  for (const machine of machines) {
41
- const assignedCount = machine.assignedModuleIds.length;
42
- const assignedText =
43
- assignedCount === 0 ? 'None (available)' : machine.assignedModuleIds.join(', ');
45
+ // Derived, not a stored snapshot (celilo#773): this line reported
46
+ // "None (available)" for a machine that was in fact hosting a VERIFIED
47
+ // module, which is the one place an operator would look to check.
48
+ const occupants = getModulesOnMachine(machine.id);
49
+ const assignedText = occupants.length === 0 ? 'None (available)' : occupants.join(', ');
44
50
 
45
51
  const roleLabel = machine.role === 'router' ? ' [router]' : '';
46
52
  console.log(`${machine.hostname} (${machine.zone})${roleLabel}`);
@@ -4,7 +4,12 @@
4
4
  */
5
5
 
6
6
  import { askConfirm, withInterviewSession } from '../../services/bus-interview';
7
- import { getMachineByHostname, getMachineByIp, removeMachine } from '../../services/machine-pool';
7
+ import {
8
+ getMachineByHostname,
9
+ getMachineByIp,
10
+ getModulesOnMachine,
11
+ removeMachine,
12
+ } from '../../services/machine-pool';
8
13
  import { celiloIntro, celiloOutro } from '../prompts';
9
14
  import type { CommandResult } from '../types';
10
15
 
@@ -43,12 +48,13 @@ export async function handleMachineRemove(
43
48
  }
44
49
  const hostname = machine.hostname;
45
50
 
46
- // Check for assigned modules
47
- if (machine.assignedModuleIds.length > 0) {
48
- console.log(
49
- `\nError: Machine '${hostname}' has ${machine.assignedModuleIds.length} assigned module(s):`,
50
- );
51
- for (const moduleId of machine.assignedModuleIds) {
51
+ // Derived from the same source placement uses (celilo#773), so the two
52
+ // cannot disagree. Previously this refused to remove an empty machine over
53
+ // a module that no longer existed, and let an occupied one be removed.
54
+ const occupants = getModulesOnMachine(machine.id);
55
+ if (occupants.length > 0) {
56
+ console.log(`\nError: Machine '${hostname}' has ${occupants.length} assigned module(s):`);
57
+ for (const moduleId of occupants) {
52
58
  console.log(` - ${moduleId}`);
53
59
  }
54
60
  console.log('\nModules must be unassigned or shut down before removing the machine.\n');
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { detectMachineInfo, testSshConnection } from '../../services/machine-detector';
7
- import { getMachineByHostname, getModuleResourcesOnMachine } from '../../services/machine-pool';
7
+ import { getMachineByHostname, getModulesOnMachine } from '../../services/machine-pool';
8
8
  import { ManagedSshKey } from '../../services/ssh-key-manager';
9
9
  import { celiloIntro } from '../prompts';
10
10
  import type { CommandResult } from '../types';
@@ -57,21 +57,19 @@ export async function handleMachineStatus(
57
57
 
58
58
  console.log('Assigned Modules');
59
59
  console.log('───────────────');
60
- if (machine.assignedModuleIds.length === 0) {
60
+ const occupants = getModulesOnMachine(machine.id);
61
+ if (occupants.length === 0) {
61
62
  console.log('None (available)');
62
63
  } else {
63
- for (const moduleId of machine.assignedModuleIds) {
64
+ for (const moduleId of occupants) {
64
65
  console.log(` - ${moduleId}`);
65
66
  }
66
67
 
67
- // Show resource allocation
68
- const allocated = await getModuleResourcesOnMachine(machine.id);
69
- console.log('');
70
- console.log('Resource Allocation');
71
- console.log('──────────────────');
72
- console.log(`CPU: ${allocated.cpu} / ${machine.hardware.cpu_cores} cores`);
73
- console.log(`Memory: ${allocated.memory} / ${machine.hardware.memory_mb} MB`);
74
- console.log(`Disk: ${allocated.disk} / ${machine.hardware.disk_gb} GB`);
68
+ // No "Resource Allocation" block (celilo#773). It printed
69
+ // `0 / <total>` for every resource on every machine, because its source
70
+ // returned hard-coded zeros behind a TODO — an operator reading it would
71
+ // conclude a fully-committed box was entirely free. The machine's own
72
+ // hardware is already printed above; that part is real.
75
73
  }
76
74
  console.log('');
77
75
 
package/src/db/schema.ts CHANGED
@@ -428,10 +428,12 @@ export const machines = sqliteTable('machines', {
428
428
  .$type<Array<{ name: string; ipAddress: string; zone: string }>>()
429
429
  .notNull()
430
430
  .default(sql`'[]'`),
431
- assignedModuleIds: text('assigned_module_ids', { mode: 'json' })
432
- .$type<string[]>()
433
- .notNull()
434
- .default(sql`'[]'`),
431
+ // No `assigned_module_ids` (celilo#773). Occupancy is derived from
432
+ // `module_infrastructure` / `module_systems` at the point of use — both are
433
+ // written by the deploy path and both cascade on module removal, so a machine
434
+ // frees itself. The dropped column had one append-only writer, no removal
435
+ // path, and no reader that reconciled it, and it had already diverged in both
436
+ // directions on the live fleet.
435
437
  /** Module ID this machine is earmarked for. If set, only this module can use this machine. */
436
438
  earmarkedModule: text('earmarked_module'),
437
439
  /**
@@ -332,6 +332,11 @@ export async function loadCapabilityFunctions(
332
332
  secrets: providerSecrets,
333
333
  systems: getModuleSystems(capability.moduleId, db),
334
334
  logger,
335
+ // WHO IS CALLING, so a provider can scope per-consumer state to them.
336
+ // `createPublicWeb` has always had this; compiled factories did not,
337
+ // which made a module-provided capability structurally unable to
338
+ // offer `unregisterRoutes()`-style methods.
339
+ consumerModuleId: consumingModuleId,
335
340
  });
336
341
  // Stamp here too, not only on the legacy path: a consumer that cannot
337
342
  // get what it needs must be able to name WHICH provider could not give
@@ -1037,6 +1042,7 @@ async function buildFirewallChain(
1037
1042
  secrets: leafSecrets,
1038
1043
  systems: getModuleSystems(hasExternal.moduleId, db),
1039
1044
  logger,
1045
+ consumerModuleId: consumingModuleId,
1040
1046
  });
1041
1047
  leafFirewall = stampProvider(leafFirewall, hasExternal.moduleId);
1042
1048
  debugLog(
@@ -46,6 +46,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
46
46
  secrets: {},
47
47
  systems: [],
48
48
  logger: makeLogger(),
49
+ consumerModuleId: 'test-consumer',
49
50
  debug: false,
50
51
  screenshotDir: '',
51
52
  capabilities: {},
@@ -350,6 +351,7 @@ describe('defineCapabilityFunction', () => {
350
351
  secrets: { token: 'abc' },
351
352
  systems: [],
352
353
  logger: makeLogger(),
354
+ consumerModuleId: 'test-consumer',
353
355
  });
354
356
 
355
357
  expect(typeof methods.create_oidc_client).toBe('function');
@@ -376,6 +378,7 @@ describe('defineCapabilityFunction', () => {
376
378
  secrets: {},
377
379
  systems: [],
378
380
  logger: makeLogger(),
381
+ consumerModuleId: 'test-consumer',
379
382
  });
380
383
 
381
384
  const result = await methods.registerHost({ fqdn: 'www.example.com' });
@@ -404,6 +407,7 @@ describe('defineCapabilityFunction', () => {
404
407
  secrets: {},
405
408
  systems: [],
406
409
  logger: makeLogger(),
410
+ consumerModuleId: 'test-consumer',
407
411
  });
408
412
 
409
413
  const result = await methods.exposeService({
@@ -26,7 +26,6 @@ describe('extractMachineProperties', () => {
26
26
  zone: 'external',
27
27
  role: 'host',
28
28
  interfaces: [],
29
- assignedModuleIds: [],
30
29
  createdAt: new Date(),
31
30
  updatedAt: new Date(),
32
31
  };
@@ -51,7 +50,6 @@ describe('extractMachineProperties', () => {
51
50
  zone: 'internal',
52
51
  role: 'host',
53
52
  interfaces: [],
54
- assignedModuleIds: [],
55
53
  createdAt: new Date(),
56
54
  updatedAt: new Date(),
57
55
  };
@@ -329,7 +329,11 @@ describe('runSweep', () => {
329
329
 
330
330
  expect(report.notified).toBe(0);
331
331
  expect(report.noPolicy).toEqual([]);
332
- expect(report.skipped.within_grace).toBe(1);
332
+ // The alert is named, not just counted — an operator asking "why was I
333
+ // not paged" is asking about a specific alert (#450).
334
+ expect(report.skipped).toEqual([
335
+ { alertKey: 'module:homebridge/check:port', reason: 'within_grace' },
336
+ ]);
333
337
  });
334
338
 
335
339
  test('a transport that cannot be loaded records the error, not just a count', async () => {
@@ -78,14 +78,20 @@ export interface SweepReport {
78
78
  */
79
79
  noPolicy: { alertKey: string; monitor: string }[];
80
80
  /**
81
- * Deliveries escalation declined, keyed by its reason (`within_grace`,
82
- * `no_eligible_route`, …).
81
+ * Deliveries escalation declined the reason AND the alert it applies to.
83
82
  *
84
- * `notifyAlert` returns the reason precisely so the caller can record it its
85
- * own contract says a silent skip is indistinguishable from a bug. Dropping it
86
- * here is what made a firing-but-undelivered alert undebuggable (#450).
83
+ * `notifyAlert` returns the reason precisely so the caller can record it: its
84
+ * own contract says a silent skip is indistinguishable from a bug, and
85
+ * dropping it here is what made a firing-but-undelivered alert undebuggable
86
+ * (#450).
87
+ *
88
+ * The alert key is carried too, because a bare `within_grace×2` still does not
89
+ * answer "why was I not paged" for the alert the operator is actually looking
90
+ * at — they cannot tell which of their live alerts each count refers to. Same
91
+ * reasoning `noPolicy` already applies, and the same failure it was fixing.
92
+ * Counts are derived at render time so there is one source for both.
87
93
  */
88
- skipped: Record<string, number>;
94
+ skipped: { alertKey: string; reason: string }[];
89
95
  /**
90
96
  * Why each failed delivery failed, as `<alert key>: <error>`.
91
97
  *
@@ -121,7 +127,7 @@ export async function runSweep(
121
127
  deferredDelivered: 0,
122
128
  failed: 0,
123
129
  noPolicy: [],
124
- skipped: {},
130
+ skipped: [],
125
131
  failures: [],
126
132
  };
127
133
 
@@ -282,7 +288,7 @@ export async function runSweep(
282
288
  report.failed++;
283
289
  report.failures.push(`${alert.key}: ${outcome.error}`);
284
290
  } else if (outcome.result === 'skipped') {
285
- report.skipped[outcome.reason] = (report.skipped[outcome.reason] ?? 0) + 1;
291
+ report.skipped.push({ alertKey: alert.key, reason: outcome.reason });
286
292
  }
287
293
  }
288
294
 
@@ -54,7 +54,6 @@ async function seedMachine(opts: {
54
54
  sshUser: 'root',
55
55
  sshKey: 'ssh-key-placeholder',
56
56
  hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 5, arch: 'amd64' },
57
- assignedModuleIds: [],
58
57
  earmarkedModule: null,
59
58
  });
60
59
  if (opts.apiOnly) {