@celilo/cli 0.27.0 → 1.1.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.
@@ -22,6 +22,8 @@
22
22
  * change and requires a v2.0 contract.
23
23
  */
24
24
 
25
+ import type { HookName } from '@celilo/capabilities';
26
+
25
27
  /**
26
28
  * Per-input/output metadata.
27
29
  *
@@ -47,8 +49,21 @@ export interface ContractHookSignature {
47
49
  * Full contract for a version: a map of canonical hook name → signature.
48
50
  * Only hook names listed here are valid; declaring a hook not in this map is
49
51
  * a manifest validation error.
52
+ *
53
+ * Keyed by `HookName` rather than `string` (celilo#821). That single change is
54
+ * what turns this table from a fourth hand-maintained list into a derivation:
55
+ * a name added to `HOOK_NAMES` without an entry here is a type error, and an
56
+ * entry here for a name that is not a hook is one too. It was `Record<string,
57
+ * …>`, which is why the drift went unnoticed for months.
58
+ *
59
+ * `on_upstream_publish` is named explicitly because it is NOT a `HookName` — an
60
+ * array of build-bus match rules rather than an invokable hook. It has a
61
+ * signature here because the executor passes its payload as env vars and the
62
+ * contract is where that is written down.
50
63
  */
51
- export type ContractHooks = Record<string, ContractHookSignature>;
64
+ export type ContractHooks = Record<HookName, ContractHookSignature> & {
65
+ on_upstream_publish: ContractHookSignature;
66
+ };
52
67
 
53
68
  /**
54
69
  * Contract v1.0 — current canonical hook signatures.
@@ -70,6 +85,23 @@ export const V1_HOOKS: ContractHooks = {
70
85
  inputs: {},
71
86
  outputs: {},
72
87
  },
88
+ /**
89
+ * A module that CONSUMED one of this module's capabilities is being removed.
90
+ * The provider withdraws whatever it minted on that consumer's behalf
91
+ * (openspec/changes/consumer-removal-cleanup, D1).
92
+ *
93
+ * `consumer` is the only input and there are no outputs. Deliberately NOT
94
+ * accompanied by the list of capabilities the consumer used: a provider that
95
+ * cannot answer "what do I hold for this module" without being told has a
96
+ * different defect — the consumer's id was never recorded at the point of the
97
+ * call — and telling it at removal time only papers over that.
98
+ */
99
+ on_consumer_removed: {
100
+ inputs: {
101
+ consumer: { required: true },
102
+ },
103
+ outputs: {},
104
+ },
73
105
  health_check: {
74
106
  inputs: {},
75
107
  outputs: {},
@@ -1,3 +1,4 @@
1
+ import type { HookName } from '@celilo/capabilities';
1
2
  import { z } from 'zod';
2
3
  import { NETWORK_ZONES } from '../db/schema';
3
4
  import {
@@ -332,6 +333,52 @@ export const UpstreamPublishHookSchema = z.object({
332
333
  timeout: z.number().positive().optional(),
333
334
  });
334
335
 
336
+ /**
337
+ * The manifest schema for every invokable lifecycle hook, derived from the one
338
+ * list (celilo#821).
339
+ *
340
+ * `satisfies Record<HookName, …>` is doing the enforcement, in both directions:
341
+ * a name in `HOOK_NAMES` with no entry here is a type error, and an entry here
342
+ * that is not a `HookName` is a type error too. That is why this is a literal
343
+ * rather than something built with `Object.fromEntries` — a computed object
344
+ * would widen the keys to `string` and take `ModuleManifest['hooks']` down with
345
+ * it, trading one silent drift for another.
346
+ *
347
+ * Per-hook prose lives in `contracts/v1.ts`, which documents the same names.
348
+ * Two copies of that commentary is how they came to disagree.
349
+ */
350
+ const LIFECYCLE_HOOK_SCHEMAS = {
351
+ container_created: LifecycleHookSchema.optional(),
352
+ on_install: LifecycleHookSchema.optional(),
353
+ on_uninstall: LifecycleHookSchema.optional(),
354
+ on_consumer_removed: LifecycleHookSchema.optional(),
355
+ health_check: HealthCheckHookSchema.optional().describe(
356
+ "Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
357
+ ),
358
+ validate_config: LifecycleHookSchema.optional(),
359
+ on_backup: LifecycleHookSchema.optional(),
360
+ on_backup_analyze: LifecycleHookSchema.optional(),
361
+ on_restore: LifecycleHookSchema.optional(),
362
+ on_system_event: LifecycleHookSchema.optional(),
363
+ reconcile_routes: LifecycleHookSchema.optional(),
364
+ refresh_registrations: LifecycleHookSchema.optional(),
365
+ reassert_dhcp_dns: LifecycleHookSchema.optional(),
366
+ reconcile_clients: LifecycleHookSchema.optional(),
367
+ } satisfies Record<HookName, z.ZodTypeAny>;
368
+
369
+ /**
370
+ * What `manifest.hooks` accepts: every lifecycle hook, plus
371
+ * `on_upstream_publish`, which is deliberately not a `HookName` — an ARRAY of
372
+ * build-bus match rules dispatched by the receiver daemon rather than a hook
373
+ * `runNamedHook` can invoke. It is spread in here rather than living in
374
+ * `LIFECYCLE_HOOK_SCHEMAS` so the `satisfies` above stays exact.
375
+ * See [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
376
+ */
377
+ const HOOK_SCHEMAS = {
378
+ ...LIFECYCLE_HOOK_SCHEMAS,
379
+ on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
380
+ };
381
+
335
382
  /**
336
383
  * Machine resource recommendations
337
384
  * Module declares recommended machine resources (CPU, memory, disk, storage)
@@ -626,64 +673,7 @@ export const ModuleManifestSchema = z
626
673
  })
627
674
  .optional(),
628
675
 
629
- hooks: z
630
- .object({
631
- container_created: LifecycleHookSchema.optional(),
632
- on_install: LifecycleHookSchema.optional(),
633
- on_uninstall: LifecycleHookSchema.optional(),
634
- health_check: HealthCheckHookSchema.optional().describe(
635
- "Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
636
- ),
637
- validate_config: LifecycleHookSchema.optional(),
638
- on_backup: LifecycleHookSchema.optional(),
639
- on_backup_analyze: LifecycleHookSchema.optional(),
640
- on_restore: LifecycleHookSchema.optional(),
641
- /**
642
- * Per-system lifecycle hook. A dns_internal provider declares this
643
- * to (de)register a single host's A records when celilo's bridge
644
- * delivers a system.created/destroyed event. Inputs (hostname,
645
- * target_ip, op) come from the contract — see contracts/v1.ts and
646
- * [[openspec/specs/internal-dns-split-horizon/spec.md]] D5.
647
- */
648
- on_system_event: LifecycleHookSchema.optional(),
649
- /**
650
- * Reconcile the provider's running config from a celilo registry on a
651
- * change event. The caddy `public_web` provider declares this to
652
- * re-render its Caddyfile from web_routes when a consumer registers or
653
- * unregisters a route (ISS-0035). See
654
- * [[openspec/specs/public-web-provider-reconcile/spec.md]].
655
- */
656
- reconcile_routes: LifecycleHookSchema.optional(),
657
- /**
658
- * Periodic re-assertion of a dns_registrar provider's registered
659
- * records. The framework injects the provider's dns_registrations
660
- * ledger rows as the `registrations` input; the hook re-sends each
661
- * one to the underlying DNS API and fails loudly if any cannot be
662
- * re-asserted. Providers subscribe it to a `timer.tick.*` event.
663
- * Part of the dns_registrar capability contract — see
664
- * designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B3).
665
- */
666
- refresh_registrations: LifecycleHookSchema.optional(),
667
- /**
668
- * Periodic re-assertion of the resolver a dns_internal provider
669
- * hands out over DHCP. Some routers regenerate that value from
670
- * their own upstream list on a timer, silently undoing what
671
- * on_install set. The hook reads the device before writing, so a
672
- * quiet minute costs one query. Subscribe it to `timer.tick.1m` —
673
- * the tick interval IS the worst-case window in which a renewing
674
- * client can be handed the wrong resolver. See celilo#739.
675
- */
676
- reassert_dhcp_dns: LifecycleHookSchema.optional(),
677
- reconcile_clients: LifecycleHookSchema.optional(),
678
- /**
679
- * Build-bus upstream publish hooks. Array (a module can react
680
- * to multiple upstream packages with different actions). See
681
- * [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
682
- */
683
- on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
684
- })
685
- .strict()
686
- .optional(),
676
+ hooks: z.object(HOOK_SCHEMAS).strict().optional(),
687
677
 
688
678
  build: z
689
679
  .object({
@@ -19,6 +19,17 @@ export interface UndeployedModule {
19
19
  id: string;
20
20
  /** Lifecycle state from the modules table (IMPORTED, VALIDATED, …). */
21
21
  state: string;
22
+ /**
23
+ * Why it is in ERROR, verbatim from the modules table.
24
+ *
25
+ * Surfaced because the headline used to be the hardcoded "previous deploy
26
+ * failed", and that became a FALSE statement once a provider could be marked
27
+ * ERROR for failing to withdraw a removed consumer's state
28
+ * (openspec/changes/consumer-removal-cleanup, D6) — no deploy was involved,
29
+ * and the operator reading the finding has no other way to learn which
30
+ * consumer it was.
31
+ */
32
+ errorMessage?: string | null;
22
33
  }
23
34
 
24
35
  export interface UndeployedModulesAuditDeps {
@@ -40,11 +51,17 @@ export async function auditUndeployedModules(
40
51
  if (TRANSIENT_STATES.has(m.state)) continue;
41
52
 
42
53
  if (m.state === 'ERROR') {
54
+ const reason = m.errorMessage?.trim();
43
55
  findings.push({
44
56
  category: 'undeployed_modules',
45
57
  severity: 'blocked',
46
58
  code: 'module_in_error_state',
47
- message: `${m.id}: previous deploy failed (state: ERROR)`,
59
+ // The recorded reason when there is one. A module reaches ERROR from
60
+ // more than one place now, and naming the wrong cause sends the
61
+ // operator to the wrong evidence.
62
+ message: reason
63
+ ? `${m.id}: ${reason} (state: ERROR)`
64
+ : `${m.id}: previous deploy failed (state: ERROR)`,
48
65
  details:
49
66
  'Investigate the prior failure (check `celilo module status` and the' +
50
67
  ' module logs) before retrying — re-deploying without diagnosing the' +
@@ -0,0 +1,347 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { DbClient } from '../db/client';
6
+ import type { ModuleManifest } from '../manifest/schema';
7
+ import { setupTestDatabase } from '../test-utils/setup-test-db';
8
+ import {
9
+ type CleanupTarget,
10
+ type ProviderRow,
11
+ type ProviderState,
12
+ planConsumerCleanup,
13
+ runConsumerCleanup,
14
+ } from './consumer-cleanup';
15
+
16
+ /**
17
+ * The pure half of the removal dispatch
18
+ * (openspec/changes/consumer-removal-cleanup). Which providers get told, and
19
+ * which are skipped and why, is all decided here — so it is all assertable
20
+ * without a database, a hook runner, or a deployed anything.
21
+ */
22
+
23
+ function manifest(input: { requires?: string[]; optional?: string[] }): ModuleManifest {
24
+ return {
25
+ requires: { capabilities: (input.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
26
+ optional: { capabilities: (input.optional ?? []).map((name) => ({ name, version: '1.0.0' })) },
27
+ } as unknown as ModuleManifest;
28
+ }
29
+
30
+ const deployed = (...ids: string[]): ProviderState[] =>
31
+ ids.map((moduleId) => ({ moduleId, state: 'VERIFIED' }));
32
+
33
+ const provides = (...pairs: Array<[string, string]>): ProviderRow[] =>
34
+ pairs.map(([moduleId, capabilityName]) => ({ moduleId, capabilityName }));
35
+
36
+ describe('planConsumerCleanup', () => {
37
+ it('collects providers of BOTH requires and optional capabilities', () => {
38
+ // The same set `remove-guard.ts` counts as a dependency edge. A capability
39
+ // consumed optionally still had state minted for it — `technitium` consumes
40
+ // `dhcp_server` under `optional:`, and the two definitions disagreeing has
41
+ // already been silently harmful once.
42
+ const plan = planConsumerCleanup(
43
+ 'consumer',
44
+ manifest({ requires: ['public_web'], optional: ['firewall'] }),
45
+ provides(['caddy', 'public_web'], ['iptables', 'firewall']),
46
+ deployed('caddy', 'iptables'),
47
+ );
48
+
49
+ expect(plan.map((t) => t.providerId)).toEqual(['caddy', 'iptables']);
50
+ });
51
+
52
+ it('tells EVERY provider of one capability (the chained-firewall case)', () => {
53
+ // `capabilities` has no uniqueness on the name: `firewall` deliberately has
54
+ // several rows, an edge provider plus inner layers, and each holds its own
55
+ // rules for the departing consumer (D3).
56
+ const plan = planConsumerCleanup(
57
+ 'consumer',
58
+ manifest({ requires: ['firewall'] }),
59
+ provides(['iptables', 'firewall'], ['greenwave', 'firewall']),
60
+ deployed('iptables', 'greenwave'),
61
+ );
62
+
63
+ expect(plan.map((t) => t.providerId)).toEqual(['greenwave', 'iptables']);
64
+ });
65
+
66
+ it('tells a provider of two consumed capabilities exactly ONCE', () => {
67
+ // Dispatching per capability would run the same withdrawal twice (D1) — the
68
+ // hook is a full converge, so the second run is at best wasted and at worst
69
+ // re-applies a ruleset over a half-applied one.
70
+ const plan = planConsumerCleanup(
71
+ 'consumer',
72
+ manifest({ requires: ['source_forge', 'registry_publish'] }),
73
+ provides(['forgejo', 'source_forge'], ['forgejo', 'registry_publish']),
74
+ deployed('forgejo'),
75
+ );
76
+
77
+ expect(plan).toHaveLength(1);
78
+ expect(plan[0].providerId).toBe('forgejo');
79
+ expect(plan[0].capabilityNames).toEqual(['registry_publish', 'source_forge']);
80
+ });
81
+
82
+ it('sorts by provider id, so dispatch order is deterministic', () => {
83
+ const plan = planConsumerCleanup(
84
+ 'consumer',
85
+ manifest({ requires: ['firewall'] }),
86
+ provides(['zebra', 'firewall'], ['axon', 'firewall'], ['iptables', 'firewall']),
87
+ deployed('zebra', 'axon', 'iptables'),
88
+ );
89
+
90
+ expect(plan.map((t) => t.providerId)).toEqual(['axon', 'iptables', 'zebra']);
91
+ });
92
+
93
+ it('marks a PAUSED provider skipped rather than dispatching to it', () => {
94
+ // A paused module does not run non-lifecycle hooks (D7). Recording the skip
95
+ // is what lets the caller say WHICH provider is still holding state.
96
+ const plan = planConsumerCleanup(
97
+ 'consumer',
98
+ manifest({ requires: ['public_web'] }),
99
+ provides(['caddy', 'public_web']),
100
+ [{ moduleId: 'caddy', state: 'PAUSED' }],
101
+ );
102
+
103
+ expect(plan).toEqual([
104
+ { providerId: 'caddy', capabilityNames: ['public_web'], skip: 'paused' },
105
+ ]);
106
+ });
107
+
108
+ it('marks a never-deployed provider skipped', () => {
109
+ // Capabilities are registered at IMPORT, so the table routinely names
110
+ // providers that have never resolved anything and hold nothing (D8).
111
+ for (const state of ['IMPORTED', 'VALIDATED', 'CONFIGURED']) {
112
+ const plan = planConsumerCleanup(
113
+ 'consumer',
114
+ manifest({ requires: ['public_web'] }),
115
+ provides(['caddy', 'public_web']),
116
+ [{ moduleId: 'caddy', state }],
117
+ );
118
+ expect(plan[0].skip).toBe('not-deployed');
119
+ }
120
+ });
121
+
122
+ it('skips a provider with no module row at all rather than dispatching blind', () => {
123
+ const plan = planConsumerCleanup(
124
+ 'consumer',
125
+ manifest({ requires: ['public_web'] }),
126
+ provides(['caddy', 'public_web']),
127
+ [],
128
+ );
129
+
130
+ expect(plan[0].skip).toBe('not-deployed');
131
+ });
132
+
133
+ it('is empty for a module that consumes nothing', () => {
134
+ expect(
135
+ planConsumerCleanup(
136
+ 'consumer',
137
+ manifest({}),
138
+ provides(['caddy', 'public_web']),
139
+ deployed('caddy'),
140
+ ),
141
+ ).toEqual([]);
142
+ });
143
+
144
+ it('never asks the departing module to withdraw its own state', () => {
145
+ // A module can both provide and require a capability. Its own `on_uninstall`
146
+ // owns its teardown; being handed itself as a provider would run a converge
147
+ // on a module that is mid-removal.
148
+ const plan = planConsumerCleanup(
149
+ 'caddy',
150
+ manifest({ requires: ['firewall'] }),
151
+ provides(['caddy', 'firewall'], ['iptables', 'firewall']),
152
+ deployed('caddy', 'iptables'),
153
+ );
154
+
155
+ expect(plan.map((t) => t.providerId)).toEqual(['iptables']);
156
+ });
157
+
158
+ it('ignores providers of capabilities the consumer never declared', () => {
159
+ const plan = planConsumerCleanup(
160
+ 'consumer',
161
+ manifest({ requires: ['public_web'] }),
162
+ provides(['caddy', 'public_web'], ['namecheap', 'dns_registrar']),
163
+ deployed('caddy', 'namecheap'),
164
+ );
165
+
166
+ expect(plan.map((t) => t.providerId)).toEqual(['caddy']);
167
+ });
168
+ });
169
+
170
+ describe('runConsumerCleanup — the paused skip is reported, not silent', () => {
171
+ let dir: string;
172
+ let db: DbClient;
173
+
174
+ beforeEach(async () => {
175
+ dir = mkdtempSync(join(tmpdir(), 'cc-'));
176
+ const dbPath = join(dir, 'celilo.db');
177
+ process.env.CELILO_DB_PATH = dbPath;
178
+ db = await setupTestDatabase(dbPath);
179
+ });
180
+ afterEach(() => {
181
+ db.$client.close();
182
+ process.env.CELILO_DB_PATH = undefined;
183
+ rmSync(dir, { recursive: true, force: true });
184
+ });
185
+
186
+ /**
187
+ * Asserted here rather than through the CLI: `module remove`'s output is
188
+ * clack-formatted and its stderr is discarded by the integration harness, so
189
+ * a substring match there would prove nothing about the message. This is the
190
+ * layer where the text actually exists.
191
+ */
192
+ it('warns, naming the provider AND the consumer whose state it keeps (D7)', async () => {
193
+ const messages: string[] = [];
194
+ const logger = {
195
+ info: () => {},
196
+ warn: (m: string) => messages.push(m),
197
+ error: () => {},
198
+ success: () => {},
199
+ };
200
+
201
+ const result = await runConsumerCleanup(
202
+ 'departing',
203
+ [{ providerId: 'caddy', capabilityNames: ['public_web'], skip: 'paused' }],
204
+ db,
205
+ logger,
206
+ );
207
+
208
+ expect(result.skipped).toEqual([{ providerId: 'caddy', reason: 'paused' }]);
209
+ expect(result.failures).toEqual([]);
210
+ expect(messages).toHaveLength(1);
211
+ expect(messages[0]).toContain('caddy');
212
+ expect(messages[0]).toContain('departing');
213
+ expect(messages[0]).toContain('public_web');
214
+ });
215
+
216
+ it('says nothing about a provider that was never deployed', async () => {
217
+ // Unlike a pause, this is not a state an operator chose and can undo — the
218
+ // provider holds nothing, so there is nothing to report.
219
+ const messages: string[] = [];
220
+ const logger = {
221
+ info: () => {},
222
+ warn: (m: string) => messages.push(m),
223
+ error: () => {},
224
+ success: () => {},
225
+ };
226
+
227
+ const result = await runConsumerCleanup(
228
+ 'departing',
229
+ [{ providerId: 'caddy', capabilityNames: ['public_web'], skip: 'not-deployed' }],
230
+ db,
231
+ logger,
232
+ );
233
+
234
+ expect(result.skipped).toEqual([{ providerId: 'caddy', reason: 'not-deployed' }]);
235
+ expect(messages).toEqual([]);
236
+ });
237
+ });
238
+
239
+ describe('runConsumerCleanup — dispatch', () => {
240
+ let dir: string;
241
+ let db: DbClient;
242
+
243
+ beforeEach(async () => {
244
+ dir = mkdtempSync(join(tmpdir(), 'ccd-'));
245
+ const dbPath = join(dir, 'celilo.db');
246
+ process.env.CELILO_DB_PATH = dbPath;
247
+ db = await setupTestDatabase(dbPath);
248
+ });
249
+ afterEach(() => {
250
+ db.$client.close();
251
+ process.env.CELILO_DB_PATH = undefined;
252
+ rmSync(dir, { recursive: true, force: true });
253
+ });
254
+
255
+ const capture = () => {
256
+ const warnings: string[] = [];
257
+ return {
258
+ warnings,
259
+ logger: {
260
+ info: () => {},
261
+ warn: (m: string) => warnings.push(m),
262
+ error: () => {},
263
+ success: () => {},
264
+ },
265
+ };
266
+ };
267
+
268
+ const target = (providerId: string): CleanupTarget => ({
269
+ providerId,
270
+ capabilityNames: ['firewall'],
271
+ });
272
+
273
+ /**
274
+ * `runNamedHook` reports a paused module as SUCCESS. Taking that at face value
275
+ * would log a withdrawal that never happened — the silence this change exists
276
+ * to end — so the flag is read rather than assumed. Reachable when a module is
277
+ * paused between the plan and this dispatch.
278
+ */
279
+ it('a provider paused between plan and dispatch is skipped, not counted as withdrawn', async () => {
280
+ const { warnings, logger } = capture();
281
+
282
+ const result = await runConsumerCleanup(
283
+ 'departing',
284
+ [target('iptables')],
285
+ db,
286
+ logger,
287
+ async () => ({
288
+ success: true,
289
+ outputs: {},
290
+ duration: 0,
291
+ skippedPaused: true,
292
+ }),
293
+ );
294
+
295
+ expect(result.notified).toEqual([]);
296
+ expect(result.skipped).toEqual([{ providerId: 'iptables', reason: 'paused' }]);
297
+ expect(result.failures).toEqual([]);
298
+ expect(warnings[0]).toContain('iptables');
299
+ expect(warnings[0]).toContain('departing');
300
+ });
301
+
302
+ it('a provider with no such hook is not reported as having withdrawn anything', async () => {
303
+ const { logger } = capture();
304
+
305
+ const result = await runConsumerCleanup(
306
+ 'departing',
307
+ [target('namecheap')],
308
+ db,
309
+ logger,
310
+ async () => ({
311
+ success: true,
312
+ outputs: {},
313
+ duration: 0,
314
+ notDefined: true,
315
+ }),
316
+ );
317
+
318
+ // It mints nothing per consumer, so succeeding IS the right answer — but it
319
+ // did not withdraw anything, and must not claim to.
320
+ expect(result.notified).toEqual([]);
321
+ expect(result.failures).toEqual([]);
322
+ });
323
+
324
+ it('continues past a failure, and records it on the failing provider only (D13)', async () => {
325
+ const { logger } = capture();
326
+ const told: string[] = [];
327
+
328
+ const result = await runConsumerCleanup(
329
+ 'departing',
330
+ [target('axon'), target('iptables')],
331
+ db,
332
+ logger,
333
+ async (providerId) => {
334
+ told.push(providerId);
335
+ return providerId === 'axon'
336
+ ? { success: false, outputs: {}, duration: 0, error: 'router said no' }
337
+ : { success: true, outputs: {}, duration: 0 };
338
+ },
339
+ );
340
+
341
+ // Both were told, in plan order — stopping early would leave MORE providers
342
+ // holding state for a module about to disappear.
343
+ expect(told).toEqual(['axon', 'iptables']);
344
+ expect(result.failures).toEqual([{ providerId: 'axon', error: 'router said no' }]);
345
+ expect(result.notified).toEqual(['iptables']);
346
+ });
347
+ });