@celilo/cli 1.6.0 → 1.7.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 (46) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/package.json +3 -3
  5. package/src/capabilities/lookup.ts +39 -29
  6. package/src/capabilities/secret-ref.test.ts +24 -0
  7. package/src/capabilities/secret-validation.ts +50 -0
  8. package/src/capabilities/validation.test.ts +187 -2
  9. package/src/capabilities/validation.ts +53 -1
  10. package/src/cli/commands/alerts-sweep.ts +18 -0
  11. package/src/cli/commands/module-remove.ts +34 -2
  12. package/src/cli/commands/module-update.test.ts +149 -2
  13. package/src/cli/commands/module-update.ts +113 -25
  14. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  15. package/src/cli/commands/service-set-credentials.ts +115 -0
  16. package/src/cli/commands/system-migrate.ts +6 -4
  17. package/src/cli/completion.ts +16 -1
  18. package/src/cli/index.ts +9 -0
  19. package/src/db/client.ts +10 -8
  20. package/src/db/migrate.test.ts +147 -0
  21. package/src/db/migrate.ts +69 -1
  22. package/src/hooks/capability-loader.test.ts +55 -0
  23. package/src/hooks/capability-loader.ts +16 -1
  24. package/src/module/import.ts +20 -5
  25. package/src/policy/module-business-baseline.ts +0 -11
  26. package/src/services/alerting/monitors.ts +54 -2
  27. package/src/services/alerting/sweep-runner.ts +38 -1
  28. package/src/services/consumer-cleanup.ts +5 -3
  29. package/src/services/container-service.test.ts +34 -0
  30. package/src/services/container-service.ts +44 -0
  31. package/src/services/deployed-systems.test.ts +101 -0
  32. package/src/services/deployed-systems.ts +43 -11
  33. package/src/services/dns-provider-backfill.ts +30 -0
  34. package/src/services/fleet-checks.test.ts +26 -0
  35. package/src/services/fleet-checks.ts +11 -1
  36. package/src/services/module-deploy.ts +88 -41
  37. package/src/services/provider-arrival.test.ts +241 -0
  38. package/src/services/provider-arrival.ts +213 -0
  39. package/src/templates/generator.test.ts +35 -0
  40. package/src/templates/generator.ts +29 -1
  41. package/src/variables/context.test.ts +63 -0
  42. package/src/variables/context.ts +10 -2
  43. package/src/variables/declarative-derivation.test.ts +47 -8
  44. package/src/variables/declarative-derivation.ts +6 -4
  45. package/src/services/public-web-republish.test.ts +0 -189
  46. package/src/services/public-web-republish.ts +0 -84
@@ -325,7 +325,15 @@ export async function getCompletions(words: string[], current: number): Promise<
325
325
  }
326
326
 
327
327
  if (command === 'service' && currentIndex === 1) {
328
- const subcommands = ['add', 'list', 'verify', 'reconfigure', 'remove', 'config'];
328
+ const subcommands = [
329
+ 'add',
330
+ 'list',
331
+ 'verify',
332
+ 'reconfigure',
333
+ 'remove',
334
+ 'config',
335
+ 'set-credentials',
336
+ ];
329
337
  return filterSuggestions(subcommands, args[1] || '');
330
338
  }
331
339
 
@@ -356,6 +364,13 @@ export async function getCompletions(words: string[], current: number): Promise<
356
364
  return filterSuggestions(serviceIds, args[2] || '');
357
365
  }
358
366
 
367
+ // Service set-credentials - complete with service IDs
368
+ if (command === 'service' && args[1] === 'set-credentials' && currentIndex === 2) {
369
+ const services = await listContainerServices();
370
+ const serviceIds = services.map((s) => s.serviceId);
371
+ return filterSuggestions(serviceIds, args[2] || '');
372
+ }
373
+
359
374
  // Service config operations
360
375
  if (command === 'service' && args[1] === 'config' && currentIndex === 2) {
361
376
  const operations = ['get', 'set'];
package/src/cli/index.ts CHANGED
@@ -109,6 +109,7 @@ import { handleServiceConfigSet } from './commands/service-config-set';
109
109
  import { handleServiceList } from './commands/service-list';
110
110
  import { handleServiceReconfigure } from './commands/service-reconfigure';
111
111
  import { handleServiceRemove } from './commands/service-remove';
112
+ import { handleServiceSetCredentials } from './commands/service-set-credentials';
112
113
  import { handleServiceVerify } from './commands/service-verify';
113
114
  import { handleStatus } from './commands/status';
114
115
  import { handleSubscribersAdd } from './commands/subscribers-add';
@@ -721,6 +722,7 @@ Subcommands:
721
722
  Options:
722
723
  --zone <zone> Filter by network zone
723
724
  verify <service-id> Re-verify a container service connection
725
+ set-credentials <service-id> Update a provider endpoint or API credential
724
726
  reconfigure <service-id> Re-run configuration interview (change template, storage, etc.)
725
727
  remove <id> Remove a container service
726
728
  Options:
@@ -751,6 +753,9 @@ Examples:
751
753
  # Verify a service connection
752
754
  celilo service verify proxmox-home-lab
753
755
 
756
+ # Move a Proxmox endpoint while retaining its existing token
757
+ celilo service set-credentials proxmox-home-lab --api-url https://10.77.20.50:8006
758
+
754
759
  # Get service configuration
755
760
  celilo service config get proxmox-home-lab
756
761
  celilo service config get proxmox-home-lab name
@@ -1750,6 +1755,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1750
1755
  return handleServiceVerify(parsed.args, parsed.flags);
1751
1756
  }
1752
1757
 
1758
+ if (parsed.subcommand === 'set-credentials') {
1759
+ return handleServiceSetCredentials(parsed.args, parsed.flags);
1760
+ }
1761
+
1753
1762
  if (parsed.subcommand === 'reconfigure') {
1754
1763
  return handleServiceReconfigure(parsed.args, parsed.flags);
1755
1764
  }
package/src/db/client.ts CHANGED
@@ -4,8 +4,8 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { BUSY_TIMEOUT_MS, ensureWalMode } from '@celilo/event-bus/wal';
6
6
  import { drizzle } from 'drizzle-orm/bun-sqlite';
7
- import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
8
7
  import { getDbPath } from '../config/paths';
8
+ import { runMigrationsOn } from './migrate';
9
9
  import * as schema from './schema';
10
10
 
11
11
  /**
@@ -83,15 +83,17 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
83
83
  // idempotent: it applies every migration newer than the latest recorded in
84
84
  // `__drizzle_migrations` and no-ops once current.
85
85
  //
86
- // One-time caveat (ISS-0100): an existing DB from the hand-list era has a
87
- // frozen `__drizzle_migrations` watermark; it must be remediated by hand
88
- // (stamp the watermark to the latest migration + create any missing table)
89
- // BEFORE this code opens it, or migrate() re-runs already-applied migrations
90
- // and throws. `celilo system doctor` (checkSchemaDrift) detects the drift.
86
+ // A DB from the hand-list era has a frozen `__drizzle_migrations` watermark,
87
+ // so drizzle re-runs already-applied migrations and throws (celilo#169).
88
+ // runMigrationsOn repairs that itself where the schema is already complete.
89
+ // It has to happen HERE and not only in `celilo system migrate`, because
90
+ // that command reaches its own repair through getDb() this line — and so
91
+ // would die before getting there. A PARTIALLY applied schema still throws
92
+ // and still needs a human. `celilo system doctor` (checkSchemaDrift) detects
93
+ // the drift.
91
94
  if (!readonly) {
92
95
  try {
93
- const migrationsFolder = findMigrationsFolder();
94
- migrate(db, { migrationsFolder });
96
+ runMigrationsOn(db);
95
97
  } catch (error) {
96
98
  console.error('Failed to run migrations:', error);
97
99
  throw error;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The frozen-watermark recurrence gate (celilo#169).
3
+ *
4
+ * The state under test is one no correct deploy can produce, so it is seeded
5
+ * by hand rather than reached: a database written by a celilo from the
6
+ * imperative hand-list era, where schema changes were applied directly and
7
+ * `__drizzle_migrations` never recorded them. Its ledger therefore remembers
8
+ * an old migration while the tables and columns of every later one are already
9
+ * present.
10
+ *
11
+ * That is the carve-out CLAUDE.md draws around seeding state: deploying
12
+ * anything cannot reproduce this row, because current celilo has recorded
13
+ * every migration it applied since ISS-0100 made drizzle authoritative. A
14
+ * suite that starts from an empty database can only ever prove the forward
15
+ * invariant, and says nothing about the installed base.
16
+ *
17
+ * What made it a hazard: drizzle's migrator is watermark-only. It re-runs
18
+ * every migration newer than the newest ledger row, so the first `ALTER TABLE
19
+ * ... ADD` dies on `duplicate column name`, the transaction rolls back, and
20
+ * the throw happens inside `createDbClient` — so EVERY celilo command on that
21
+ * box fails at database open, not just a migrate. celilo-mgr was remediated by
22
+ * hand once. This gate is what stops the next one needing a runbook.
23
+ */
24
+
25
+ import { afterEach, describe, expect, test } from 'bun:test';
26
+ import { rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { type DbClient, createDbClient } from './client';
30
+ import { runMigrationsOn } from './migrate';
31
+ import { findSchemaDrift } from './schema-introspection';
32
+
33
+ describe('runMigrationsOn — a database from the hand-list era', () => {
34
+ const paths: string[] = [];
35
+
36
+ const freshDb = (): { db: DbClient; path: string } => {
37
+ const path = join(tmpdir(), `celilo-migrate-test-${Bun.nanoseconds()}.db`);
38
+ paths.push(path);
39
+ return { db: createDbClient({ path }), path };
40
+ };
41
+
42
+ afterEach(() => {
43
+ for (const path of paths.splice(0)) {
44
+ for (const suffix of ['', '-wal', '-shm']) {
45
+ rmSync(`${path}${suffix}`, { force: true });
46
+ }
47
+ }
48
+ });
49
+
50
+ /**
51
+ * Freeze the ledger to its oldest entry, leaving the schema fully applied.
52
+ * This is the hand-list-era shape: the objects exist, the ledger has
53
+ * forgotten who made them.
54
+ */
55
+ const freezeWatermark = (db: DbClient): void => {
56
+ db.$client.run(
57
+ 'DELETE FROM `__drizzle_migrations` WHERE created_at > (SELECT MIN(created_at) FROM `__drizzle_migrations`)',
58
+ );
59
+ };
60
+
61
+ const appliedCount = (db: DbClient): number =>
62
+ db.$client.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`').get()
63
+ ?.c ?? 0;
64
+
65
+ test('converges instead of dying on the first already-applied statement', () => {
66
+ const { db } = freshDb();
67
+ const migrationCount = appliedCount(db);
68
+ freezeWatermark(db);
69
+ expect(appliedCount(db)).toBe(1);
70
+
71
+ // Red before the ledger repair: drizzle re-runs 0001 and throws
72
+ // "duplicate column name: role", leaving the ledger frozen.
73
+ expect(() => runMigrationsOn(db)).not.toThrow();
74
+
75
+ expect(appliedCount(db)).toBe(migrationCount);
76
+ });
77
+
78
+ test('leaves the schema whole, so the drift detector goes green', () => {
79
+ const { db } = freshDb();
80
+ freezeWatermark(db);
81
+
82
+ runMigrationsOn(db);
83
+
84
+ const drift = findSchemaDrift(db.$client);
85
+ expect(drift.missingTables).toEqual([]);
86
+ expect(drift.missingColumns).toEqual([]);
87
+ });
88
+
89
+ test('is idempotent — a second pass applies nothing and still converges', () => {
90
+ const { db } = freshDb();
91
+ freezeWatermark(db);
92
+ runMigrationsOn(db);
93
+ const afterBaseline = appliedCount(db);
94
+
95
+ runMigrationsOn(db);
96
+
97
+ expect(appliedCount(db)).toBe(afterBaseline);
98
+ });
99
+
100
+ /**
101
+ * The repair must not become a blanket "assume it already ran". It fires only
102
+ * where the answer is unambiguous — the declared schema is entirely present,
103
+ * so every migration plainly did run and the ledger is what is wrong. A
104
+ * PARTIALLY applied schema is the genuinely hard case, and the one celilo-mgr
105
+ * was actually in: it carried 0011's column and not 0010's table. Stamping
106
+ * there would record migrations that never ran and bury the missing schema
107
+ * for good, so it keeps failing and a human decides.
108
+ */
109
+ test('refuses to stamp when the schema is only partly there', () => {
110
+ const { db } = freshDb();
111
+ freezeWatermark(db);
112
+ db.$client.run('DROP TABLE `dns_registrations`');
113
+
114
+ expect(() => runMigrationsOn(db)).toThrow();
115
+ // The ledger is left exactly as found, so the drift is still diagnosable.
116
+ expect(appliedCount(db)).toBe(1);
117
+ });
118
+
119
+ /**
120
+ * The path that actually matters. Every celilo command opens the database
121
+ * through createDbClient, which migrates on open, so a frozen watermark
122
+ * failed there rather than anywhere an operator could aim a fix at — and
123
+ * `celilo system migrate`, the command whose whole job is repairing this,
124
+ * reached its own repair through the same open and died first.
125
+ */
126
+ test('repairs on database open, not just when migrate is called by hand', () => {
127
+ const { db, path } = freshDb();
128
+ const migrationCount = appliedCount(db);
129
+ freezeWatermark(db);
130
+ db.$client.close();
131
+
132
+ const reopened = createDbClient({ path });
133
+
134
+ expect(appliedCount(reopened)).toBe(migrationCount);
135
+ expect(findSchemaDrift(reopened.$client).missingTables).toEqual([]);
136
+ });
137
+
138
+ test('a healthy database is untouched by the fallback', () => {
139
+ const { db } = freshDb();
140
+ const before = appliedCount(db);
141
+
142
+ runMigrationsOn(db);
143
+
144
+ expect(appliedCount(db)).toBe(before);
145
+ expect(findSchemaDrift(db.$client).missingTables).toEqual([]);
146
+ });
147
+ });
package/src/db/migrate.ts CHANGED
@@ -1,14 +1,82 @@
1
+ import type { Database } from 'bun:sqlite';
1
2
  import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
3
+ import { readMigrationFiles } from 'drizzle-orm/migrator';
2
4
  import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
5
+ import { findSchemaDrift } from './schema-introspection';
6
+
7
+ /**
8
+ * Record every migration past the ledger's watermark as applied, running none
9
+ * of them. Only safe when the caller has already established that the schema
10
+ * the code declares is entirely present.
11
+ *
12
+ * Replaying the statements instead would be wrong, and quietly so. Migrations
13
+ * are not idempotent: `0021_dns_registration_consumers` rebuilds a table by
14
+ * copying it aside, `DROP TABLE`-ing the original and renaming the copy over
15
+ * it. Run that against a schema that is already current and it drops a live
16
+ * table. Skipping the statements that fail with "already exists" does not save
17
+ * you either, because `DROP` and `INSERT ... SELECT` do not fail that way.
18
+ *
19
+ * So the ledger is corrected and the schema is left alone.
20
+ */
21
+ function stampLedgerAsApplied(sqlite: Database, migrationsFolder: string): void {
22
+ const newest = sqlite
23
+ .query<{ created_at: number }, []>(
24
+ 'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1',
25
+ )
26
+ .get();
27
+ const watermark = Number(newest?.created_at ?? 0);
28
+
29
+ sqlite.run('BEGIN');
30
+ try {
31
+ for (const migration of readMigrationFiles({ migrationsFolder })) {
32
+ if (migration.folderMillis <= watermark) continue;
33
+ sqlite.run('INSERT INTO `__drizzle_migrations` ("hash", "created_at") VALUES (?, ?)', [
34
+ migration.hash,
35
+ migration.folderMillis,
36
+ ]);
37
+ }
38
+ sqlite.run('COMMIT');
39
+ } catch (error) {
40
+ sqlite.run('ROLLBACK');
41
+ throw error;
42
+ }
43
+ }
3
44
 
4
45
  /**
5
46
  * Apply pending drizzle migrations to an open DB. Idempotent — drizzle applies
6
47
  * only migrations newer than the latest recorded in `__drizzle_migrations`.
7
48
  * The single migration mechanism (ISS-0100); createDbClient also calls this
8
49
  * shape on open (auto-migrate).
50
+ *
51
+ * The stock migrator runs first and handles every database celilo has written
52
+ * since ISS-0100, so a healthy box takes exactly the path it took before.
53
+ *
54
+ * It fails on one database celilo did not write: one from the imperative
55
+ * hand-list era, where schema changes were applied directly and
56
+ * `__drizzle_migrations` never recorded them. Its ledger remembers an old
57
+ * migration while the objects of every later one are already there, and
58
+ * drizzle — being watermark-only — re-runs them and dies on the first
59
+ * `ALTER TABLE ... ADD`. The throw happens inside `createDbClient`, so every
60
+ * celilo command on that box fails at database open. celilo-mgr, the one box
61
+ * in that state, was remediated by hand (celilo#169); this is so the next one
62
+ * is not.
63
+ *
64
+ * The repair is only attempted when the schema the code declares is ALREADY
65
+ * COMPLETE, because that is the one case with an unambiguous answer: every
66
+ * migration has plainly run, so the ledger is what is wrong. A database missing
67
+ * some of it is the genuinely hard case — celilo-mgr had 0011's column and not
68
+ * 0010's table — and there is no safe automatic answer, so it keeps failing
69
+ * with drizzle's own error and a human decides.
9
70
  */
10
71
  export function runMigrationsOn(db: DbClient): void {
11
- migrate(db, { migrationsFolder: findMigrationsFolder() });
72
+ const migrationsFolder = findMigrationsFolder();
73
+ try {
74
+ migrate(db, { migrationsFolder });
75
+ } catch (error) {
76
+ const drift = findSchemaDrift(db.$client);
77
+ if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) throw error;
78
+ stampLedgerAsApplied(db.$client, migrationsFolder);
79
+ }
12
80
  }
13
81
 
14
82
  /**
@@ -29,6 +29,17 @@ export default function registerHost(context) {
29
29
  }
30
30
  `;
31
31
 
32
+ const TEST_DHCP_SERVER_MODULE = `
33
+ export default function createDhcpServer(context) {
34
+ return {
35
+ async setDnsServers() {},
36
+ async getDnsServers() { return [context.config.marker]; },
37
+ async setDomainName() {},
38
+ async getDomainName() { return context.config.marker; },
39
+ };
40
+ }
41
+ `;
42
+
32
43
  describe('Capability Loader', () => {
33
44
  let db: DbClient;
34
45
  let tempDir: string;
@@ -97,6 +108,50 @@ describe('Capability Loader', () => {
97
108
  expect(result.dns_registrar).toBeTruthy();
98
109
  });
99
110
 
111
+ test('prefers a provider explicitly scoped to the well-known capability zone', async () => {
112
+ for (const moduleId of ['upstream-dhcp', 'internal-dhcp']) {
113
+ const modulePath = join(tempDir, moduleId);
114
+ const scriptsDir = join(modulePath, 'scripts');
115
+ mkdirSync(scriptsDir, { recursive: true });
116
+ writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE);
117
+ db.$client.run(
118
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${modulePath}', '{}')`,
119
+ );
120
+ upsertModuleConfig(db, moduleId, 'marker', moduleId);
121
+ }
122
+
123
+ // Insert the zone-agnostic upstream provider first to prove selection is
124
+ // policy-driven rather than database-order-driven.
125
+ db.$client.run(
126
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`,
127
+ );
128
+ db.$client.run(
129
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('internal-dhcp', 'dhcp_server', '1.0.0', '{}', '["internal"]', unixepoch())`,
130
+ );
131
+
132
+ const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger);
133
+ const dhcp = result.dhcp_server as { getDomainName(): Promise<string> };
134
+ expect(await dhcp.getDomainName()).toBe('internal-dhcp');
135
+ });
136
+
137
+ test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
138
+ const modulePath = join(tempDir, 'upstream-dhcp');
139
+ const scriptsDir = join(modulePath, 'scripts');
140
+ mkdirSync(scriptsDir, { recursive: true });
141
+ writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE);
142
+ db.$client.run(
143
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('upstream-dhcp', 'upstream-dhcp', '1.0.0', '${modulePath}', '{}')`,
144
+ );
145
+ db.$client.run(
146
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`,
147
+ );
148
+ upsertModuleConfig(db, 'upstream-dhcp', 'marker', 'upstream-dhcp');
149
+
150
+ const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger);
151
+ const dhcp = result.dhcp_server as { getDomainName(): Promise<string> };
152
+ expect(await dhcp.getDomainName()).toBe('upstream-dhcp');
153
+ });
154
+
100
155
  test('injects a read-only web_routes view into the public_web provider own hooks (ISS-0035)', async () => {
101
156
  const modulePath = join(tempDir, 'caddy');
102
157
  mkdirSync(modulePath, { recursive: true });
@@ -27,6 +27,8 @@ import type {
27
27
  TrustedSourceStore,
28
28
  } from '@celilo/capabilities';
29
29
  import { and, eq } from 'drizzle-orm';
30
+ import { selectCapabilityProvider } from '../capabilities/lookup';
31
+ import { WELL_KNOWN_CAPABILITIES } from '../capabilities/well-known';
30
32
  import type { DbClient } from '../db/client';
31
33
  import {
32
34
  NETWORK_ZONES,
@@ -279,7 +281,20 @@ export async function loadCapabilityFunctions(
279
281
  continue;
280
282
  }
281
283
 
282
- const capability = allProviders[0];
284
+ // A well-known capability's required zone is also its runtime selection
285
+ // context. Prefer a provider that explicitly serves that zone, then fall
286
+ // back to a zone-agnostic provider. Without this, multiple DHCP providers
287
+ // (for example an upstream router plus an internal LAN server) are selected
288
+ // by database insertion order and a consumer can reconfigure the wrong
289
+ // network boundary.
290
+ const selectionZone = WELL_KNOWN_CAPABILITIES[capName]?.required_zone;
291
+ const capability = selectCapabilityProvider(allProviders, selectionZone);
292
+ if (!capability) {
293
+ debugLog(
294
+ `${capName}: no provider serves required zone ${selectionZone ?? '(unspecified)'}, skipping`,
295
+ );
296
+ continue;
297
+ }
283
298
  debugLog(`${capName}: found provider module ${capability.moduleId}`);
284
299
 
285
300
  const providerModule = db
@@ -300,7 +300,8 @@ export function moduleExists(moduleId: string, db = getDb()): boolean {
300
300
  * Validate well-known capabilities
301
301
  *
302
302
  * Policy function - checks if module's well-known capabilities are valid:
303
- * 1. No other module provides the same well-known capability (uniqueness)
303
+ * 1. No other module provides the same well-known capability in an
304
+ * overlapping scope (zone-aware uniqueness)
304
305
  * 2. Module's zone matches capability's required zone (zone enforcement)
305
306
  *
306
307
  * @param manifest - Module manifest
@@ -321,16 +322,30 @@ export async function validateWellKnownCapabilities(
321
322
 
322
323
  const wellKnown = getWellKnownCapability(capability.name);
323
324
 
324
- // Check 1: Capability uniqueness - only one module can provide this capability
325
+ // Check 1: Capability uniqueness within an overlapping scope. An explicit
326
+ // zone-scoped provider may coexist with a zone-agnostic fallback because
327
+ // lookup deterministically prefers the explicit match. Two agnostic
328
+ // providers, or two explicit providers sharing a zone, remain ambiguous.
325
329
  const existingCapability = await db
326
330
  .select()
327
331
  .from(capabilities)
328
332
  .where(eq(capabilities.capabilityName, capability.name))
329
333
  .all();
330
334
 
331
- if (existingCapability.length > 0) {
332
- const conflictingModule = existingCapability[0];
333
- return `Well-known capability '${capability.name}' is already provided by module '${conflictingModule.moduleId}'. A home lab can only have one module providing this capability. Remove '${conflictingModule.moduleId}' before importing this module.`;
335
+ const newZones = capability.zones ?? null;
336
+ const conflictingModule = existingCapability.find((candidate) => {
337
+ const existingZones = candidate.zones ?? null;
338
+
339
+ if (newZones === null || existingZones === null) {
340
+ return newZones === null && existingZones === null;
341
+ }
342
+
343
+ return newZones.some((zone) => existingZones.includes(zone));
344
+ });
345
+
346
+ if (conflictingModule) {
347
+ const scope = newZones ? ` zone(s) ${newZones.join(', ')}` : ' the zone-agnostic scope';
348
+ return `Well-known capability '${capability.name}' is already provided in${scope} by module '${conflictingModule.moduleId}'. Remove '${conflictingModule.moduleId}' or use a non-overlapping explicit zone scope before importing this module.`;
334
349
  }
335
350
 
336
351
  // Check 2: Zone enforcement - module must be in the correct zone
@@ -248,12 +248,6 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
248
248
  count: 2,
249
249
  why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
250
250
  },
251
- {
252
- file: 'apps/celilo/src/services/public-web-republish.ts',
253
- capability: 'public_web',
254
- count: 1,
255
- why: "S2 — encodes caddy's redeploy behaviour; generalise to a provider-declared re-assert signal (#945)",
256
- },
257
251
  {
258
252
  file: 'apps/celilo/src/services/zone-policy.ts',
259
253
  capability: 'public_web',
@@ -391,11 +385,6 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
391
385
  capability: 'firewall',
392
386
  why: 'S8 — core reaching into one provider implementation (#941)',
393
387
  },
394
- {
395
- file: 'apps/celilo/src/services/public-web-republish.ts',
396
- capability: 'public_web',
397
- why: "S2 — named for one provider's redeploy behaviour (#945)",
398
- },
399
388
  ];
400
389
 
401
390
  export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [
@@ -214,7 +214,59 @@ export function ensureInboundSubscriber(bus: SubscriberRegistrar): void {
214
214
  });
215
215
  }
216
216
 
217
- export function deleteMonitor(db: DbClient, monitorId: string, now: Date): void {
218
- resolveMonitorAlerts(db, monitorId, now);
217
+ /** A live alert about to be deleted along with the monitor that owns it. */
218
+ export interface DroppedAlert {
219
+ key: string;
220
+ message: string;
221
+ }
222
+
223
+ /**
224
+ * Delete a monitor, and report the live alerts that go with it.
225
+ *
226
+ * The alerts are DELETED, not resolved. `alerts.monitorId` is
227
+ * `on delete cascade`, so the row goes the moment the monitor does. This used
228
+ * to call `resolveMonitorAlerts` first; that write was unreachable — the very
229
+ * next statement dropped the same rows, and nothing read them in between, so
230
+ * the resolved state existed for the duration of one statement. Deleted rather
231
+ * than restored, because resolving properly would need the alert rows to
232
+ * outlive their monitor, which the FK forbids and which no reader wants:
233
+ * `resolvedAt` is only ever read as a liveness predicate (`ack.ts`), never
234
+ * reported. Do not put the call back without changing the FK first.
235
+ *
236
+ * What IS worth keeping is what was lost, which is why the live alerts are
237
+ * returned. A monitor dropped while holding a firing alert takes a real
238
+ * failure and the coverage of it away together, and a caller that only names
239
+ * the monitor cannot tell an operator what stopped being watched.
240
+ */
241
+ export function deleteMonitor(db: DbClient, monitorId: string): DroppedAlert[] {
242
+ const dropped = db
243
+ .select({ key: alerts.key, message: alerts.message })
244
+ .from(alerts)
245
+ .where(and(eq(alerts.monitorId, monitorId), isNotNull(alerts.activeKey)))
246
+ .all();
247
+
219
248
  db.delete(monitors).where(eq(monitors.id, monitorId)).run();
249
+ return dropped;
250
+ }
251
+
252
+ /**
253
+ * Drop the `module_hook` monitor belonging to a module that is going away.
254
+ *
255
+ * Called from the removal path rather than expressed as a foreign key, because
256
+ * `monitors.target` cannot carry one: it holds a module id for `module_hook`
257
+ * and an audit check name for `builtin_check`, and SQLite has no conditional
258
+ * reference. Splitting the table to get the constraint would need a synthetic
259
+ * monitor identity for `alerts.monitorId` — the trade the file header already
260
+ * weighs and declines.
261
+ *
262
+ * Deleting cascades the monitor's alerts away (`alerts.monitorId` is
263
+ * `on delete cascade`), which is what releases anything they were suppressing.
264
+ * They are deleted, not resolved — see `deleteMonitor`. Returns what was live
265
+ * so the caller can say what stopped being watched, or null if the module had
266
+ * no monitor.
267
+ */
268
+ export function deleteMonitorForModule(db: DbClient, moduleId: string): DroppedAlert[] | null {
269
+ const monitor = findMonitor(db, 'module_hook', moduleId);
270
+ if (!monitor) return null;
271
+ return deleteMonitor(db, monitor.id);
220
272
  }
@@ -23,6 +23,7 @@ import type { DbClient } from '../../db/client';
23
23
  import type { Alert, Monitor } from '../../db/schema';
24
24
  import { MONITOR_INTERVAL_FLOOR_MINUTES } from '../cadence';
25
25
  import { isScheduled, loadModuleHealthCadences } from './health-cadence';
26
+ import { type DroppedAlert, deleteMonitor } from './monitors';
26
27
  import type { NotifyDeps, NotifyOutcome } from './notifier';
27
28
  import { deliverDeferred, notifyAlert } from './notifier';
28
29
  import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
@@ -102,6 +103,19 @@ export interface SweepReport {
102
103
  * former, because nothing ever reached the daemon.
103
104
  */
104
105
  failures: string[];
106
+ /**
107
+ * Modules whose `module_hook` monitor was dropped because the module itself
108
+ * is gone, and the live alerts each one took with it.
109
+ *
110
+ * The alerts are DELETED by the monitor's cascade, not resolved, so a firing
111
+ * check and the coverage of it disappear in the same instant with nothing
112
+ * else recording either. A bare module name is not enough to act on: the row
113
+ * that mattered on the fleet was holding `Cannot reach router: Router login
114
+ * failed`, and `1 stranded-dropped (greenwave)` would not have told anyone
115
+ * that a router had stopped being checked. Same reason `noPolicy`, `skipped`
116
+ * and `failures` all name their subject rather than counting it.
117
+ */
118
+ strandedDropped: { moduleId: string; alerts: DroppedAlert[] }[];
105
119
  }
106
120
 
107
121
  /**
@@ -129,6 +143,7 @@ export async function runSweep(
129
143
  noPolicy: [],
130
144
  skipped: [],
131
145
  failures: [],
146
+ strandedDropped: [],
132
147
  };
133
148
 
134
149
  // 1. Run due monitors.
@@ -139,8 +154,30 @@ export async function runSweep(
139
154
  // manifest could never reach an existing install (design.md D2/D8). A
140
155
  // `builtin_check` has no module and no manifest, so its row is the config.
141
156
  const cadences = loadModuleHealthCadences(db);
157
+
158
+ // Reconcile away monitors whose module is gone, before anything tries to run
159
+ // them. `loadModuleHealthCadences` holds every module, so a miss here means
160
+ // no module row — and a `module_hook` monitor without one is unschedulable
161
+ // forever: its cadence resolves to null, `isScheduled` says false, and the
162
+ // sweep never selects it again. Anything it left firing would sit there with
163
+ // no path back to `resolved`, suppressing every alert it is an ancestor of
164
+ // (celilo#1029).
165
+ //
166
+ // The removal path deletes the monitor itself, so this only fires for rows a
167
+ // celilo without that fix left behind, or a removal that died between the two
168
+ // deletes. Same stance `setMonitorEnabled` already takes: a monitor that will
169
+ // never report again must not hold live alerts.
170
+ const stranded = monitors.filter((m) => m.kind === 'module_hook' && !cadences.has(m.target));
171
+ for (const monitor of stranded) {
172
+ report.strandedDropped.push({
173
+ moduleId: monitor.target,
174
+ alerts: deleteMonitor(db, monitor.id),
175
+ });
176
+ }
177
+ const active = stranded.length > 0 ? monitors.filter((m) => !stranded.includes(m)) : monitors;
178
+
142
179
  const due = selectDueMonitors(
143
- monitors.map((m) => {
180
+ active.map((m) => {
144
181
  if (m.kind !== 'module_hook') {
145
182
  return {
146
183
  id: m.id,
@@ -31,10 +31,12 @@ import { deleteTrustedSourcesForModule } from './trusted-sources';
31
31
  * nothing minted on anyone's behalf. Capabilities are registered at IMPORT, not
32
32
  * deploy, so the `capabilities` table routinely names providers that were never
33
33
  * deployed. The same predicate `remove-guard.ts` uses to decide a module is not
34
- * a dependent — the guard and the cleanup must keep ONE definition of a live
35
- * provider (D8).
34
+ * a dependent — the guard, the cleanup and the provider-arrival backfill must
35
+ * keep ONE definition of a live module (D8). Exported rather than re-spelled:
36
+ * `module-remove.ts` had its own copy of the literal, and `provider-arrival.ts`
37
+ * would have been a third.
36
38
  */
37
- const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']);
39
+ export const PRE_DEPLOY_STATES = new Set(['IMPORTED', 'VALIDATED', 'CONFIGURED']);
38
40
 
39
41
  export type CleanupSkipReason = 'paused' | 'not-deployed';
40
42