@celilo/cli 0.13.2 → 0.14.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 (89) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +71 -2
  3. package/docs/ALERTING.md +298 -0
  4. package/docs/INDEX.md +103 -0
  5. package/drizzle/0016_trusted_sources.sql +10 -0
  6. package/drizzle/0017_alerting.sql +127 -0
  7. package/drizzle/meta/_journal.json +15 -1
  8. package/package.json +3 -2
  9. package/schemas/system_config.json +9 -0
  10. package/src/ansible/inventory.ts +2 -2
  11. package/src/cli/commands/alerts-act.ts +107 -0
  12. package/src/cli/commands/alerts-list.ts +62 -0
  13. package/src/cli/commands/alerts-poll.ts +129 -0
  14. package/src/cli/commands/alerts-sweep.ts +156 -0
  15. package/src/cli/commands/module-list.ts +50 -3
  16. package/src/cli/commands/monitor.ts +178 -0
  17. package/src/cli/commands/notify-config.ts +453 -0
  18. package/src/cli/commands/system-audit.ts +2 -0
  19. package/src/cli/commands/system-update.ts +1 -0
  20. package/src/cli/completion.ts +26 -0
  21. package/src/cli/generate-zsh-completion.ts +2 -0
  22. package/src/cli/index.ts +58 -0
  23. package/src/cli/tui/audit-state.ts +2 -0
  24. package/src/db/schema.ts +358 -0
  25. package/src/hooks/capability-loader.ts +158 -46
  26. package/src/hooks/capability-map-coverage.test.ts +101 -0
  27. package/src/manifest/schema.ts +60 -1
  28. package/src/services/alerting/ack.test.ts +212 -0
  29. package/src/services/alerting/ack.ts +119 -0
  30. package/src/services/alerting/builtin-monitors.test.ts +132 -0
  31. package/src/services/alerting/builtin-monitors.ts +84 -0
  32. package/src/services/alerting/builtin-source.ts +82 -0
  33. package/src/services/alerting/coverage-source.ts +38 -0
  34. package/src/services/alerting/deferral.test.ts +161 -0
  35. package/src/services/alerting/delivery-loop.test.ts +396 -0
  36. package/src/services/alerting/deploy-hooks.test.ts +125 -0
  37. package/src/services/alerting/deploy-hooks.ts +111 -0
  38. package/src/services/alerting/escalation.test.ts +207 -0
  39. package/src/services/alerting/escalation.ts +151 -0
  40. package/src/services/alerting/format.test.ts +193 -0
  41. package/src/services/alerting/format.ts +150 -0
  42. package/src/services/alerting/health-coverage.ts +81 -0
  43. package/src/services/alerting/inbound-poller.test.ts +298 -0
  44. package/src/services/alerting/inbound-poller.ts +236 -0
  45. package/src/services/alerting/inbound.test.ts +201 -0
  46. package/src/services/alerting/inbound.ts +112 -0
  47. package/src/services/alerting/interview-responder.test.ts +169 -0
  48. package/src/services/alerting/interview-responder.ts +158 -0
  49. package/src/services/alerting/keys.test.ts +155 -0
  50. package/src/services/alerting/keys.ts +190 -0
  51. package/src/services/alerting/monitors.ts +185 -0
  52. package/src/services/alerting/notification-responder.test.ts +290 -0
  53. package/src/services/alerting/notification-responder.ts +260 -0
  54. package/src/services/alerting/notifier.ts +219 -0
  55. package/src/services/alerting/people.ts +178 -0
  56. package/src/services/alerting/quiet-hours.test.ts +140 -0
  57. package/src/services/alerting/quiet-hours.ts +99 -0
  58. package/src/services/alerting/reconcile.test.ts +190 -0
  59. package/src/services/alerting/reconcile.ts +166 -0
  60. package/src/services/alerting/run-monitor.test.ts +185 -0
  61. package/src/services/alerting/run-monitor.ts +177 -0
  62. package/src/services/alerting/store.test.ts +222 -0
  63. package/src/services/alerting/store.ts +289 -0
  64. package/src/services/alerting/suppression.test.ts +228 -0
  65. package/src/services/alerting/suppression.ts +142 -0
  66. package/src/services/alerting/sweep-runner.test.ts +229 -0
  67. package/src/services/alerting/sweep-runner.ts +204 -0
  68. package/src/services/alerting/sweep.test.ts +61 -0
  69. package/src/services/alerting/sweep.ts +41 -0
  70. package/src/services/alerting/tokens.test.ts +152 -0
  71. package/src/services/alerting/tokens.ts +119 -0
  72. package/src/services/alerting/transport-loader.ts +48 -0
  73. package/src/services/aspect-runner.ts +2 -2
  74. package/src/services/audit/index.test.ts +1 -0
  75. package/src/services/audit/index.ts +3 -0
  76. package/src/services/audit/trusted-sources.test.ts +137 -0
  77. package/src/services/audit/trusted-sources.ts +124 -0
  78. package/src/services/audit/types.ts +2 -1
  79. package/src/services/firewall-reach.ts +83 -0
  80. package/src/services/health-runner.test.ts +50 -0
  81. package/src/services/health-runner.ts +116 -82
  82. package/src/services/module-deploy.ts +32 -3
  83. package/src/services/ssh-key-manager.test.ts +14 -0
  84. package/src/services/ssh-key-manager.ts +12 -0
  85. package/src/services/system-config-validator.test.ts +31 -1
  86. package/src/services/trusted-sources.test.ts +221 -0
  87. package/src/services/trusted-sources.ts +159 -0
  88. package/src/services/update/orchestrator.test.ts +1 -0
  89. package/src/templates/generator.ts +6 -29
@@ -24,6 +24,7 @@ import type {
24
24
  PortForwardStore,
25
25
  RouteOps,
26
26
  RouteReadView,
27
+ TrustedSourceStore,
27
28
  } from '@celilo/capabilities';
28
29
  import { and, eq } from 'drizzle-orm';
29
30
  import type { DbClient } from '../db/client';
@@ -35,6 +36,14 @@ import { getModuleSystems } from '../services/deployed-systems';
35
36
  import { withDnsInternalLedger } from '../services/dns-internal-records';
36
37
  import { withDnsRegistrationLedger } from '../services/dns-registrations';
37
38
  import { buildPortForwardStore } from '../services/port-forwards';
39
+ import {
40
+ TRUSTED_SUBNETS_CONFIG_KEY,
41
+ type TrustedSubnetEntry,
42
+ buildTrustedSourceStore,
43
+ composeTrustedSubnets,
44
+ listTrustedSourcesFor,
45
+ parseOperatorTrustedSubnets,
46
+ } from '../services/trusted-sources';
38
47
  import { resolveComputedFields } from '../variables/computed/evaluate';
39
48
  import { containsComputedMarker } from '../variables/computed/marker';
40
49
  import { buildProviderLookup } from '../variables/computed/provider-lookup';
@@ -54,37 +63,46 @@ import { loadHookConfigMap } from './load-hook-config';
54
63
  * upstream-chain wiring takes a second factory argument (`upstreamFirewall`)
55
64
  * that doesn't fit the `defineCapabilityFunction` shape.
56
65
  */
57
- const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> = {
58
- dns_registrar: {
59
- script: 'scripts/register-host.ts',
60
- legacyFactoryName: 'default',
61
- },
62
- firewall: {
63
- script: 'scripts/firewall-functions.ts',
64
- legacyFactoryName: 'createFirewall',
65
- },
66
- // public_web: handled via framework implementation (createPublicWeb from @celilo/capabilities)
67
- idp: {
68
- script: 'scripts/idp-functions.ts',
69
- legacyFactoryName: 'createIdp',
70
- },
71
- source_forge: {
72
- script: 'scripts/source-forge-functions.ts',
73
- legacyFactoryName: 'createForgejoSourceForge',
74
- },
75
- registry_publish: {
76
- script: 'scripts/registry-publish-functions.ts',
77
- legacyFactoryName: 'default',
78
- },
79
- dhcp_server: {
80
- script: 'scripts/dhcp-server-functions.ts',
81
- legacyFactoryName: 'default',
82
- },
83
- dns_internal: {
84
- script: 'scripts/dns-internal-functions.ts',
85
- legacyFactoryName: 'default',
86
- },
87
- };
66
+ export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> =
67
+ {
68
+ dns_registrar: {
69
+ script: 'scripts/register-host.ts',
70
+ legacyFactoryName: 'default',
71
+ },
72
+ firewall: {
73
+ script: 'scripts/firewall-functions.ts',
74
+ legacyFactoryName: 'createFirewall',
75
+ },
76
+ // public_web: handled via framework implementation (createPublicWeb from @celilo/capabilities)
77
+ idp: {
78
+ script: 'scripts/idp-functions.ts',
79
+ legacyFactoryName: 'createIdp',
80
+ },
81
+ source_forge: {
82
+ script: 'scripts/source-forge-functions.ts',
83
+ legacyFactoryName: 'createForgejoSourceForge',
84
+ },
85
+ registry_publish: {
86
+ script: 'scripts/registry-publish-functions.ts',
87
+ legacyFactoryName: 'default',
88
+ },
89
+ dhcp_server: {
90
+ script: 'scripts/dhcp-server-functions.ts',
91
+ legacyFactoryName: 'default',
92
+ },
93
+ dns_internal: {
94
+ script: 'scripts/dns-internal-functions.ts',
95
+ legacyFactoryName: 'default',
96
+ },
97
+ notification: {
98
+ script: 'scripts/notification.ts',
99
+ legacyFactoryName: 'default',
100
+ },
101
+ external_web: {
102
+ script: 'scripts/publish-functions.ts',
103
+ legacyFactoryName: 'default',
104
+ },
105
+ };
88
106
 
89
107
  /**
90
108
  * Load capability function interfaces for a consuming module
@@ -199,6 +217,7 @@ export async function loadCapabilityFunctions(
199
217
  db,
200
218
  logger,
201
219
  debugLog,
220
+ consumingModuleId,
202
221
  );
203
222
  if (firewallInterface) {
204
223
  result[capName] = firewallInterface;
@@ -277,7 +296,13 @@ export async function loadCapabilityFunctions(
277
296
  systems: getModuleSystems(capability.moduleId, db),
278
297
  logger,
279
298
  });
280
- result[capName] = withLedger(capabilityInterface);
299
+ // Stamp here too, not only on the legacy path: a consumer that cannot
300
+ // get what it needs must be able to name WHICH provider could not give
301
+ // it. Missing this made the wireguard module report "Firewall provider
302
+ // 'unknown' does not support trusted-source registration" against
303
+ // greenwave — true, useless, and a violation of the contract's
304
+ // requirement to name the provider.
305
+ result[capName] = withLedger(stampProvider(capabilityInterface, capability.moduleId));
281
306
  debugLog(`${capName}: loaded via defineCapabilityFunction`);
282
307
  continue;
283
308
  }
@@ -294,6 +319,10 @@ export async function loadCapabilityFunctions(
294
319
  // port-forward store — the chain path isn't taken when there's one provider.
295
320
  capName === 'firewall' ? buildPortForwardStore(db) : undefined,
296
321
  capName === 'firewall' ? loadFirewallZones(db) : undefined,
322
+ // Bound to the CONSUMING module so a trusted-source registration is
323
+ // attributable — reach into every tier must never be anonymous.
324
+ capName === 'firewall' ? buildTrustedSourceStore(db, consumingModuleId) : undefined,
325
+ capability.moduleId,
297
326
  );
298
327
 
299
328
  if (capabilityInterface) {
@@ -539,6 +568,16 @@ export async function loadCapabilityFunctions(
539
568
  * factories are now branded `defineCapabilityFunction` outputs that the
540
569
  * caller invokes directly with the new context.
541
570
  */
571
+ /**
572
+ * Stamp the provider module id onto a capability interface, so a consumer that
573
+ * cannot get what it needs can name WHICH provider could not supply it (see
574
+ * `requireTrustedSources`). Non-function properties survive `wrapWithLogging`.
575
+ */
576
+ function stampProvider(iface: unknown, providerModuleId?: string): unknown {
577
+ if (!iface || typeof iface !== 'object' || !providerModuleId) return iface;
578
+ return Object.assign(iface, { providerModuleId });
579
+ }
580
+
542
581
  function buildCapabilityInterface(
543
582
  capabilityName: string,
544
583
  factory: (...args: unknown[]) => unknown,
@@ -546,6 +585,8 @@ function buildCapabilityInterface(
546
585
  _secrets: Record<string, string>,
547
586
  store?: PortForwardStore,
548
587
  zones?: FirewallZones,
588
+ trustedSourceStore?: TrustedSourceStore,
589
+ providerModuleId?: string,
549
590
  ): unknown {
550
591
  if (capabilityName === 'firewall') {
551
592
  // iptables firewall factory: NAT config + the injected port-forward store
@@ -557,15 +598,20 @@ function buildCapabilityInterface(
557
598
  if (!store) {
558
599
  throw new Error('firewall capability requires an injected port-forward store');
559
600
  }
560
- return factory(
601
+ const iface = factory(
561
602
  {
562
603
  firewallIp: config.firewall_ip as string,
563
604
  natIp: config.nat_ip as string,
564
605
  zoneTiers: zones?.zoneTiers ?? [],
565
606
  trustedSubnets: zones?.trustedSubnets ?? [],
607
+ controlPlaneSubnet: zones?.controlPlaneSubnet,
566
608
  },
567
609
  store,
610
+ undefined, // no upstream — the chain path handles that
611
+ undefined, // logger is applied by wrapWithLogging at the loader site
612
+ trustedSourceStore,
568
613
  );
614
+ return stampProvider(iface, providerModuleId);
569
615
  }
570
616
  return null;
571
617
  }
@@ -661,8 +707,10 @@ const ZONE_TIER_ORDER = ['dmz', 'app', 'secure'] as const;
661
707
  interface FirewallZones {
662
708
  /** Ordered segmented tiers (dmz→app→secure adjacency) for the data-plane matrix. */
663
709
  zoneTiers: Array<{ name: string; subnet: string }>;
664
- /** Subnets that reach EVERY tier — celilo's control plane (see loadControlPlaneSubnet). */
710
+ /** Subnets that reach EVERY tier — the composed set (see loadTrustedSubnets). */
665
711
  trustedSubnets: string[];
712
+ /** celilo's control-plane network, as a DESTINATION for trusted sources. */
713
+ controlPlaneSubnet?: string;
666
714
  }
667
715
 
668
716
  /** The module that IS celilo's control plane; its network is what we trust. */
@@ -699,10 +747,51 @@ export function loadControlPlaneSubnet(db: DbClient): string | undefined {
699
747
  return undefined;
700
748
  }
701
749
 
750
+ /**
751
+ * The composed trusted-subnet set for one firewall, with each subnet's ORIGIN —
752
+ * celilo's derived control plane, the sources modules registered, and any
753
+ * explicit operator override.
754
+ *
755
+ * This used to be the derived control-plane subnet and nothing else, with no
756
+ * contribution point: infrastructure whose need was zone-wide REACH rather than
757
+ * port exposure had no way into the registry, so a converge — correctly
758
+ * rebuilding the ruleset from what it knew — removed rules nothing had claimed.
759
+ *
760
+ * With nothing registered and no override the result is the derived subnet
761
+ * alone, so a fleet without such infrastructure renders exactly as before.
762
+ *
763
+ * `firewallIp` includes that firewall's REGISTRATIONS — the full composed view,
764
+ * for REPORTING (which networks hold zone-wide reach, and who claimed each).
765
+ * Omit it for the render input: there the provider unions the live registry at
766
+ * converge time, and folding a snapshot in here as well would give one value two
767
+ * sources of truth, one of which cannot shrink.
768
+ */
769
+ export function loadTrustedSubnets(db: DbClient, firewallIp?: string): TrustedSubnetEntry[] {
770
+ // Fall back to the internal subnet when celilo-mgmt's location can't be
771
+ // determined (e.g. installs predating celilo-mgmt-as-a-module). Losing control-
772
+ // plane trust outright would brick celilo's own fleet management, so absent
773
+ // information preserves today's behaviour; the gap is REPORTED separately.
774
+ const controlPlaneSubnet = loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal');
775
+ // Registrations are keyed by firewall. With no firewall named, contribute
776
+ // NONE rather than unioning every firewall's registrations — trust registered
777
+ // against one firewall is not trust granted by another.
778
+ const registered = firewallIp ? listTrustedSourcesFor(db, firewallIp) : [];
779
+ const overrideRow = db
780
+ .select()
781
+ .from(systemConfig)
782
+ .where(eq(systemConfig.key, TRUSTED_SUBNETS_CONFIG_KEY))
783
+ .get();
784
+ return composeTrustedSubnets({
785
+ controlPlaneSubnet,
786
+ registered,
787
+ operatorOverride: parseOperatorTrustedSubnets(overrideRow?.value ?? undefined),
788
+ });
789
+ }
790
+
702
791
  /**
703
792
  * Read the firewall zone matrix inputs from system config (network.<zone>.subnet):
704
- * the segmented tiers [dmz, app, secure] and the control-plane subnet that reaches
705
- * all of them. Zones with no configured subnet are omitted — their traffic stays
793
+ * the segmented tiers [dmz, app, secure] and the trusted subnets that reach all
794
+ * of them. Zones with no configured subnet are omitted — their traffic stays
706
795
  * denied (fail-closed).
707
796
  */
708
797
  function loadFirewallZones(db: DbClient): FirewallZones {
@@ -711,12 +800,21 @@ function loadFirewallZones(db: DbClient): FirewallZones {
711
800
  const subnet = readZoneSubnet(db, zone);
712
801
  if (subnet) zoneTiers.push({ name: zone, subnet });
713
802
  }
714
- // Fall back to the internal subnet when celilo-mgmt's location can't be
715
- // determined (e.g. installs predating celilo-mgmt-as-a-module). Losing control-
716
- // plane trust outright would brick celilo's own fleet management, so absent
717
- // information preserves today's behaviour; the gap is REPORTED separately.
718
- const controlPlane = loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal');
719
- return { zoneTiers, trustedSubnets: controlPlane ? [controlPlane] : [] };
803
+ // The BASE set only derived control plane + operator override. Module
804
+ // registrations are DELIBERATELY excluded: the provider unions them at
805
+ // converge time from the live store.
806
+ //
807
+ // Injecting them here too would give one value two sources of truth, and the
808
+ // snapshot is taken when the capability is built — before any hook runs. A
809
+ // hook that WITHDRAWS a registration then converges would have the stale
810
+ // snapshot put the subnet straight back, so the set could grow but never
811
+ // shrink. That is exactly what happened: `on_uninstall` withdrew the VPN's
812
+ // trusted source, logged success, and the reach rules were re-rendered anyway.
813
+ return {
814
+ zoneTiers,
815
+ trustedSubnets: loadTrustedSubnets(db).map((e) => e.subnet),
816
+ controlPlaneSubnet: loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal'),
817
+ };
720
818
  }
721
819
 
722
820
  async function buildFirewallChain(
@@ -732,13 +830,15 @@ async function buildFirewallChain(
732
830
  db: DbClient,
733
831
  logger: HookLogger,
734
832
  debugLog: (msg: string) => void,
833
+ consumingModuleId: string,
735
834
  ): Promise<unknown> {
736
835
  // The shared-core port-forward registry, injected into every firewall provider
737
836
  // in the chain so exposeService/converge reconcile against the one canonical
738
837
  // store (openspec/changes/unified-management-no-ssh/proposal.md).
739
838
  const store = buildPortForwardStore(db);
740
- // Zone matrix for the default-DROP posture same set for every layer in the chain.
741
- const zones = loadFirewallZones(db);
839
+ // Trusted-source registry, bound to the CONSUMING module so a registration is
840
+ // attributable. Only the layers that render their own ruleset receive it.
841
+ const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
742
842
 
743
843
  // Find the provider with has_external (the leaf — has direct internet access)
744
844
  const hasExternal = allProviders.find((p) => {
@@ -783,6 +883,7 @@ async function buildFirewallChain(
783
883
  systems: getModuleSystems(hasExternal.moduleId, db),
784
884
  logger,
785
885
  });
886
+ leafFirewall = stampProvider(leafFirewall, hasExternal.moduleId);
786
887
  debugLog(
787
888
  `firewall chain: built leaf via defineCapabilityFunction from ${hasExternal.moduleId}`,
788
889
  );
@@ -794,7 +895,9 @@ async function buildFirewallChain(
794
895
  leafConfig,
795
896
  leafSecrets,
796
897
  store,
797
- zones,
898
+ loadFirewallZones(db),
899
+ trustedSourceStore,
900
+ hasExternal.moduleId,
798
901
  );
799
902
  if (leafFirewall) {
800
903
  leafFirewall = wrapWithLogging(leafFirewall as object, logger, 'firewall');
@@ -850,6 +953,10 @@ async function buildFirewallChain(
850
953
  // celilo's ProgressDisplay instead of dumping to stderr. Older
851
954
  // iptables modules ignore it — the factory's signature is
852
955
  // backward-compatible.
956
+ // Zone matrix for the default-DROP posture. The same BASE set for every
957
+ // layer; each layer's own registrations are unioned in by its provider at
958
+ // converge time, from the live store.
959
+ const zones = loadFirewallZones(db);
853
960
  const downstreamFirewall = provFactory(
854
961
  {
855
962
  firewallIp,
@@ -857,15 +964,20 @@ async function buildFirewallChain(
857
964
  dryRun,
858
965
  zoneTiers: zones.zoneTiers,
859
966
  trustedSubnets: zones.trustedSubnets,
967
+ controlPlaneSubnet: zones.controlPlaneSubnet,
860
968
  },
861
969
  store,
862
970
  currentUpstream,
863
971
  logger,
972
+ trustedSourceStore,
864
973
  );
865
974
 
866
975
  debugLog(`firewall chain: wired ${provider.moduleId} → ${hasExternal.moduleId}`);
867
976
  // Wrap each downstream layer with auto-logging.
868
- currentUpstream = wrapWithLogging(downstreamFirewall as object, logger, 'firewall');
977
+ currentUpstream = stampProvider(
978
+ wrapWithLogging(downstreamFirewall as object, logger, 'firewall'),
979
+ provider.moduleId,
980
+ );
869
981
  }
870
982
 
871
983
  return currentUpstream;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Recurrence gate: every capability with a FUNCTION implementation is loadable.
3
+ *
4
+ * `CAPABILITY_MODULE_MAP` in capability-loader.ts is a hand-maintained table of
5
+ * capability name → the script that implements it. A module can ship a perfectly
6
+ * good `defineCapabilityFunction`, register the capability on deploy — and still
7
+ * be unreachable, because the loader skips anything absent from that table.
8
+ *
9
+ * The failure is silent and inverted: consumers get "does not provide the
10
+ * notification capability" for a module that demonstrably provides it. It cost
11
+ * the notification transport a full round-trip to find, so it gets a gate.
12
+ *
13
+ * Scoped to FUNCTION capabilities on purpose. A capability that provides only
14
+ * `data:` (`apt_publish`, `celilo_event_bus`) is read through `$capability:`
15
+ * variables and has nothing to load — the map would be the wrong place for it.
16
+ */
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { readFileSync, readdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { parse } from 'yaml';
22
+ import { CAPABILITY_MODULE_MAP } from './capability-loader';
23
+
24
+ /** Capabilities the framework implements itself, with no provider script. */
25
+ const FRAMEWORK_OWNED = new Set(['public_web']);
26
+
27
+ /** Capability names a module implements as callable functions. */
28
+ function functionCapabilitiesIn(moduleDir: string): Set<string> {
29
+ const found = new Set<string>();
30
+ const scriptsDir = join(MODULES_DIR, moduleDir, 'scripts');
31
+
32
+ let entries: string[];
33
+ try {
34
+ entries = readdirSync(scriptsDir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
35
+ } catch {
36
+ return found;
37
+ }
38
+
39
+ for (const file of entries) {
40
+ const src = readFileSync(join(scriptsDir, file), 'utf-8');
41
+ // The declaration form is `capability: '<name>'` inside
42
+ // defineCapabilityFunction — matching it directly avoids inferring
43
+ // implementation from a filename.
44
+ if (!src.includes('defineCapabilityFunction')) continue;
45
+ for (const match of src.matchAll(/capability:\s*'([a-z_]+)'/g)) found.add(match[1]);
46
+ }
47
+
48
+ return found;
49
+ }
50
+
51
+ const MODULES_DIR = join(import.meta.dir, '../../../../modules');
52
+
53
+ interface ManifestShape {
54
+ provides?: { capabilities?: { name?: string }[] };
55
+ }
56
+
57
+ function providedCapabilities(): Map<string, string[]> {
58
+ const byCapability = new Map<string, string[]>();
59
+
60
+ for (const entry of readdirSync(MODULES_DIR, { withFileTypes: true })) {
61
+ if (!entry.isDirectory()) continue;
62
+ let manifest: ManifestShape;
63
+ try {
64
+ manifest = parse(readFileSync(join(MODULES_DIR, entry.name, 'manifest.yml'), 'utf-8'));
65
+ } catch {
66
+ continue; // Not a module directory.
67
+ }
68
+
69
+ const implemented = functionCapabilitiesIn(entry.name);
70
+ for (const capability of manifest.provides?.capabilities ?? []) {
71
+ if (!capability.name || !implemented.has(capability.name)) continue;
72
+ const providers = byCapability.get(capability.name) ?? [];
73
+ providers.push(entry.name);
74
+ byCapability.set(capability.name, providers);
75
+ }
76
+ }
77
+
78
+ return byCapability;
79
+ }
80
+
81
+ describe('recurrence gate: function capabilities are loadable', () => {
82
+ const provided = providedCapabilities();
83
+
84
+ test('scans a non-trivial set of modules (sanity — the scan actually ran)', () => {
85
+ expect(provided.size).toBeGreaterThan(2);
86
+ });
87
+
88
+ test('every function capability a module implements is in CAPABILITY_MODULE_MAP', () => {
89
+ const missing: string[] = [];
90
+ for (const [capability, providers] of provided) {
91
+ if (FRAMEWORK_OWNED.has(capability)) continue;
92
+ if (capability in CAPABILITY_MODULE_MAP) continue;
93
+ missing.push(`${capability} (provided by ${providers.join(', ')})`);
94
+ }
95
+
96
+ expect(
97
+ missing,
98
+ `These capabilities ship a defineCapabilityFunction but are absent from CAPABILITY_MODULE_MAP, so loadCapabilityFunctions will never return them:\n ${missing.join('\n ')}`,
99
+ ).toEqual([]);
100
+ });
101
+ });
@@ -272,6 +272,65 @@ export const LifecycleHookSchema = z.object({
272
272
  timeout: z.number().positive().optional(),
273
273
  });
274
274
 
275
+ /**
276
+ * Floor for a module's suggested `health_check` interval.
277
+ *
278
+ * The monitor sweep rides the event bus's fixed `timer.tick.5m`
279
+ * (packages/event-bus/src/timer.ts), so an interval below five minutes
280
+ * cannot be honoured — it would silently round up. Rejecting it at manifest
281
+ * validation is better than accepting a promise celilo can't keep.
282
+ */
283
+ export const MONITOR_INTERVAL_FLOOR_MINUTES = 5;
284
+
285
+ const DURATION_PATTERN = /^(\d+)(m|h|d)$/;
286
+
287
+ /**
288
+ * Parse a duration string (`15m`, `1h`, `1d`) to whole minutes.
289
+ * Returns null when the string is not a well-formed duration.
290
+ */
291
+ export function parseIntervalMinutes(value: string): number | null {
292
+ const match = DURATION_PATTERN.exec(value);
293
+ if (!match) return null;
294
+ const amount = Number.parseInt(match[1], 10);
295
+ if (!Number.isFinite(amount) || amount <= 0) return null;
296
+ const unit = match[2];
297
+ if (unit === 'm') return amount;
298
+ if (unit === 'h') return amount * 60;
299
+ return amount * 60 * 24;
300
+ }
301
+
302
+ /**
303
+ * `health_check` accepts everything a lifecycle hook does, plus an optional
304
+ * `interval` — the module author's SUGGESTED monitoring cadence.
305
+ *
306
+ * It is only a suggestion: the operator's `monitors` row is the effective
307
+ * schedule and survives module upgrades (openspec/changes/add-alerting
308
+ * design D3). A module that omits it is simply not monitored until an
309
+ * operator creates a monitor by hand.
310
+ */
311
+ export const HealthCheckHookSchema = LifecycleHookSchema.extend({
312
+ interval: z
313
+ .string()
314
+ .optional()
315
+ .superRefine((value, ctx) => {
316
+ if (value === undefined) return;
317
+ const minutes = parseIntervalMinutes(value);
318
+ if (minutes === null) {
319
+ ctx.addIssue({
320
+ code: z.ZodIssueCode.custom,
321
+ message: `Invalid health_check interval "${value}". Use a duration like "15m", "1h", or "1d".`,
322
+ });
323
+ return;
324
+ }
325
+ if (minutes < MONITOR_INTERVAL_FLOOR_MINUTES) {
326
+ ctx.addIssue({
327
+ code: z.ZodIssueCode.custom,
328
+ message: `health_check interval "${value}" is below the ${MONITOR_INTERVAL_FLOOR_MINUTES}m floor — the monitor sweep runs on a ${MONITOR_INTERVAL_FLOOR_MINUTES}-minute grid and cannot run it more often. Use "${MONITOR_INTERVAL_FLOOR_MINUTES}m" or longer.`,
329
+ });
330
+ }
331
+ }),
332
+ });
333
+
275
334
  /**
276
335
  * Build-bus upstream-publish hook. Fires when a publish event lands
277
336
  * on the local event bus from the receiver daemon
@@ -535,7 +594,7 @@ export const ModuleManifestSchema = z
535
594
  container_created: LifecycleHookSchema.optional(),
536
595
  on_install: LifecycleHookSchema.optional(),
537
596
  on_uninstall: LifecycleHookSchema.optional(),
538
- health_check: LifecycleHookSchema.optional(),
597
+ health_check: HealthCheckHookSchema.optional(),
539
598
  validate_config: LifecycleHookSchema.optional(),
540
599
  on_backup: LifecycleHookSchema.optional(),
541
600
  on_backup_analyze: LifecycleHookSchema.optional(),