@celilo/cli 0.22.0 → 0.24.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 (104) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +61 -9
  3. package/drizzle/0024_module_pause.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +7 -7
  6. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  7. package/src/api/remote-client.test.ts +6 -5
  8. package/src/api/serve.ts +41 -7
  9. package/src/api-clients/proxmox.ts +34 -0
  10. package/src/cli/commands/alerts-act.ts +1 -1
  11. package/src/cli/commands/alerts-sweep.ts +2 -0
  12. package/src/cli/commands/backup-create.ts +26 -11
  13. package/src/cli/commands/backup-list.test.ts +83 -0
  14. package/src/cli/commands/backup-list.ts +67 -3
  15. package/src/cli/commands/backup-prune.ts +17 -17
  16. package/src/cli/commands/backup-sweep.ts +20 -8
  17. package/src/cli/commands/events.ts +34 -3
  18. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  19. package/src/cli/commands/firewall-interface-list.ts +123 -0
  20. package/src/cli/commands/machine-add.ts +30 -2
  21. package/src/cli/commands/module-config.test.ts +64 -2
  22. package/src/cli/commands/module-config.ts +159 -8
  23. package/src/cli/commands/module-deploy.ts +2 -2
  24. package/src/cli/commands/module-health.ts +1 -0
  25. package/src/cli/commands/module-import.ts +3 -3
  26. package/src/cli/commands/module-list.ts +12 -1
  27. package/src/cli/commands/module-pause.ts +317 -0
  28. package/src/cli/commands/module-remove.ts +78 -40
  29. package/src/cli/commands/module-status.ts +127 -4
  30. package/src/cli/commands/module-update.test.ts +1 -1
  31. package/src/cli/commands/monitor.ts +116 -19
  32. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  33. package/src/cli/commands/status.ts +25 -3
  34. package/src/cli/commands/system-migrate.ts +14 -0
  35. package/src/cli/commands/system-update.ts +4 -1
  36. package/src/cli/completion.ts +39 -9
  37. package/src/cli/fuel-gauge.ts +4 -4
  38. package/src/cli/index.ts +104 -22
  39. package/src/cli/json-output.test.ts +162 -0
  40. package/src/cli/prompts.ts +53 -74
  41. package/src/cli/service-credential.ts +3 -3
  42. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  43. package/src/cli/tui/audit-state.ts +2 -0
  44. package/src/cli/types.ts +7 -2
  45. package/src/db/schema.ts +73 -15
  46. package/src/hooks/capability-loader.ts +130 -4
  47. package/src/hooks/run-named-hook.ts +28 -0
  48. package/src/hooks/types.ts +2 -1
  49. package/src/manifest/contracts/v1.ts +16 -0
  50. package/src/manifest/schema.ts +40 -65
  51. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  52. package/src/services/alerting/cadence-migration.test.ts +155 -0
  53. package/src/services/alerting/cadence-migration.ts +90 -0
  54. package/src/services/alerting/coverage-source.ts +8 -11
  55. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  56. package/src/services/alerting/deploy-hooks.ts +11 -5
  57. package/src/services/alerting/health-cadence.test.ts +58 -0
  58. package/src/services/alerting/health-cadence.ts +128 -0
  59. package/src/services/alerting/health-coverage.ts +18 -8
  60. package/src/services/alerting/monitors.ts +50 -15
  61. package/src/services/alerting/suppression.test.ts +5 -0
  62. package/src/services/alerting/suppression.ts +18 -1
  63. package/src/services/alerting/sweep-runner.test.ts +52 -3
  64. package/src/services/alerting/sweep-runner.ts +41 -8
  65. package/src/services/audit/backup-source.ts +24 -1
  66. package/src/services/audit/backups.test.ts +95 -10
  67. package/src/services/audit/backups.ts +40 -37
  68. package/src/services/audit/interface-classification.test.ts +220 -0
  69. package/src/services/audit/interface-classification.ts +167 -0
  70. package/src/services/audit/types.ts +2 -1
  71. package/src/services/backup-age-agreement.test.ts +118 -0
  72. package/src/services/backup-create.ts +36 -30
  73. package/src/services/backup-metadata.ts +52 -1
  74. package/src/services/backup-retention.test.ts +123 -0
  75. package/src/services/backup-retention.ts +66 -5
  76. package/src/services/backup-schedule.test.ts +166 -0
  77. package/src/services/backup-schedule.ts +105 -15
  78. package/src/services/backup-staging.ts +14 -1
  79. package/src/services/backup-sweep.test.ts +22 -3
  80. package/src/services/backup-sweep.ts +15 -5
  81. package/src/services/bus-interview.ts +2 -2
  82. package/src/services/bus-secret-flow.test.ts +1 -1
  83. package/src/services/cadence.test.ts +97 -0
  84. package/src/services/cadence.ts +165 -0
  85. package/src/services/fleet-checks.ts +48 -0
  86. package/src/services/machine-detector.ts +23 -1
  87. package/src/services/module-config.ts +33 -0
  88. package/src/services/module-deploy.ts +1 -1
  89. package/src/services/module-pause-observability.test.ts +224 -0
  90. package/src/services/module-pause-quiescence.test.ts +163 -0
  91. package/src/services/module-pause.test.ts +573 -0
  92. package/src/services/module-pause.ts +544 -0
  93. package/src/services/remove-guard.test.ts +175 -0
  94. package/src/services/remove-guard.ts +109 -0
  95. package/src/services/storage-providers/s3.test.ts +96 -13
  96. package/src/services/storage-providers/s3.ts +48 -15
  97. package/src/services/terminal-responder.ts +16 -16
  98. package/src/services/update/dep-graph.test.ts +33 -4
  99. package/src/services/update/dep-graph.ts +39 -17
  100. package/src/services/zone-detector.test.ts +34 -3
  101. package/src/services/zone-detector.ts +32 -49
  102. package/src/test-utils/cli.ts +15 -14
  103. package/src/test-utils/integration-guard.ts +26 -0
  104. package/src/test-utils/setup-test-db.ts +13 -23
@@ -21,13 +21,13 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
21
21
 
22
22
  ## Network fabric (DNS / firewall / DHCP)
23
23
 
24
- - **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**.
24
+ - **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS + DHCP address-pool bounds via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**. Two device behaviours shape this driver and are not shared by **greenwave** (celilo#739): the router **regenerates** `Pool.1.DNSServers` from its own upstream resolver list every ~15 min and on any config commit, so `setDhcpDns` also writes `Device.DNS.X_AXON_CustomServer` — the *input* to that computation, which survives — and **technitium re-asserts** the pool value on `timer.tick.1m`. Optional `dhcp_pool_start`/`dhcp_pool_end` bound the leased range; set them below the addresses IPAM allocates, because **IPAM has no knowledge of the router's DHCP pool** and nothing otherwise stops the router leasing an address celilo already assigned.
25
25
  - **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
26
26
  - **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **provides:** `firewall`.
27
27
  - **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
28
28
  - **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
29
29
  - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
30
- - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
30
+ - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **Declares `network.control-plane-vpn.subnet` BEFORE it creates the interface** — the first side effect of `on_install`, before any command touches the box. The ordering is load-bearing, not tidiness: the firewall classifies every interface on its host and one no declaration accounts for is `alien`, so creating `wg0` first would leave a window in which a converge — a timer, the registry-poll CD, any unrelated module deploy — could shut the admin VPN down as an intruder's interface. The module declares the NETWORK, never the device: only the firewall decides what an interface is for. `health_check` asserts the declaration still holds, because the other checks all pass while it is missing. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
31
31
 
32
32
  ## Public edge (ingress / identity)
33
33
 
@@ -25,7 +25,10 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
25
25
 
26
26
  - **IPAM (IP/VMID allocation)** — `apps/celilo/src/ipam/allocator.ts` — `allocateIPFromSubnet`, `allocateVMID`, `reserveIP`/`unreserveIP`, `inferZoneFromIP`, `getAllocation`. Auto-wrapper: `apps/celilo/src/ipam/auto-allocator.ts` — `allocateForModule` / `deallocateForModule`.
27
27
  - **Infrastructure selection (container-service vs machine pool)** — `apps/celilo/src/services/machine-pool.ts` (`getMachineByHostname`, `addMachine`, `assignModuleToMachine`) and `apps/celilo/src/services/container-service.ts` (`getContainerServiceByName`, `addContainerService`, `verifyContainerService`). Provider API clients: `apps/celilo/src/api-clients/proxmox.ts`, `apps/celilo/src/api-clients/digitalocean.ts`.
28
- - **Zone detection / system config** — `apps/celilo/src/services/zone-detector.ts` — `detectZoneFromIp` reads `network.<zone>.subnet` from the `systemConfig` table.
28
+ - **Zone detection / system config** — `apps/celilo/src/services/zone-detector.ts` — `detectZoneFromIp` reads `network.<zone>.subnet` from the `systemConfig` table and returns `NetworkZone | 'unknown'`. It answers CONTAINMENT ONLY. It used to return `'external'` on no-match, which conflated "no declared subnet contains this" with "the internet can route to this" — on a firewall with five RFC1918 legs that reported four of them as facing the internet. `'unknown'` is the honest answer; the caller resolves it (see `machine add`: publicly routable → `external`, otherwise fail asking for `--zone`). The subnet-backed zone list is derived from `NETWORK_ZONES` minus `external`, which has no subnet and must never be given one.
29
+ - **Interface classification** — `packages/capabilities/src/interface-classification.ts` — THE shared classifier, used by the backend and every firewall provider module so the two cannot drift apart again. `isPubliclyRoutable(ip)` is a property of the address alone (false for RFC 1918, RFC 6598 carrier-grade NAT, loopback, link-local, multicast, reserved). `classifyInterfaces(interfaces, zones)` assigns each interface `zone → external → alien`, first match winning, where `external` is the RESIDUAL — routable and claimed by no declared zone — and is never subnet-matched. `externalEdge()` returns none/single/**ambiguous** rather than silently picking the first public address. `defaultRouteFinding()` enforces the invariant that the default route leaves through `internal` or `external`. **`subnetContains(cidr, ip)` lives here and is the ONLY implementation** — three existed and disagreed (the backend's mishandled `/0`); the other two are deleted, not aliased. Design: `openspec/changes/firewall-interface-classification/design.md`.
30
+ - **Declared networks (classification input)** — `readDeclaredNetworks(db)` in `apps/celilo/src/hooks/capability-loader.ts` — every `network.<name>.subnet` in system config, which is what an interface is attributed against. Read from the CONFIG, not from `NETWORK_ZONES`: celilo declares networks that are not placement zones (`network.control-plane-vpn.subnet`, written by `wireguard` before it brings `wg0` up). Injected into the firewall capability as a LIVE reader (`declaredNetworks`), alongside the trusted-source store and for the same reason — a declaration written during a hook run must be visible to the converge that follows it in that same run, which a snapshot taken at capability-build time cannot be.
31
+ - **Firewall interface audit** — `apps/celilo/src/services/audit/interface-classification.ts` — reports per-firewall classification in `celilo audit`: alien interfaces by name and address (drift), and the blocking findings a converge refuses on — a carrier-grade NAT leg, an ambiguous external edge, a default route on the wrong leg.
29
32
  - **Zone taxonomy (canonical list)** — `apps/celilo/src/db/schema.ts` — `NETWORK_ZONES` is the single array; `NetworkZone` is DERIVED from it. Never hand-maintain a second copy: a duplicate that dropped a member made zone validation return null and silently fall back to a wrong-but-valid zone.
30
33
  - **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
31
34
  - **A deployed system's zone** — `apps/celilo/src/services/deployed-systems.ts` — for machine-pool deploys the zone recorded is the ZONE OF THE MACHINE, not `requires.system.zone` (which is only the minimum used to *select* a host, as with sizing). Three writers must agree: `recordDeployedSystemForModule`, `backfillModuleSystems`, and `apps/celilo/src/variables/context.ts` — the last runs latest and will overwrite the others.
@@ -170,8 +173,26 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
170
173
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
171
174
  - **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium).
172
175
  - **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--abandoned] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`); `list` shows only what holds the lock, abandoned rows are summarised unless `--abandoned`. Abandoned rows are reclaimed hourly by the `celilo-operations-sweep` bus subscriber (`timer.tick.1h` → `celilo module operations clear`, armed by `ensureOperationsSweepSubscriber` from module registration and `celilo system migrate`). `clear` MARKS rows failed rather than deleting them, and that is load-bearing: the `abandoned_operations` audit reads exactly those released rows to notice one module's operation dying over and over.
176
+ - **Module pause / unpause (control-plane quiescence)** — `apps/celilo/src/services/module-pause.ts` — pure `planPause`/`planUnpause` producing an ordered plan, `executePause`/`executeUnpause` performing it, plus `listPausedModules`/`pausedAmong`/`formatPausedDuration`/`describePausedModule` (the ONE place an age is formatted). CLI: `apps/celilo/src/cli/commands/module-pause.ts` (`celilo module pause|unpause <id> [--cascade] [--stop-infra] [--reason] [--dry-run] [--yes]`). Pausing takes a module out of the CONTROL plane — no dispatched events, no timer hooks, no health checks, alerts suppressed — while leaving the DATA plane running, because capability consumption is deploy-time: every consumer calls `firewall`/`dhcp_server` from `on_install` and nothing calls it while serving. Config, secrets, IPAM/VMID and placement are preserved; `on_uninstall` does NOT run. Quiescence is enforced in two places: pausing drops the module's bus subscriptions (`unregisterModuleSubscriptions`), and `run-named-hook.ts` refuses any non-lifecycle hook for a PAUSED module (`skippedPaused`), which catches the paths that skip the bus — `events resync-subscriptions`, a restore that starts events.db empty, aspect fan-out, public-web republish. `on_install`/`on_uninstall` are exempt by hook NAME (not a caller flag): unpause redeploys through `on_install`, and removing a paused provider needs `on_uninstall`. Unpause always REDEPLOYS (`deployModule`) — that is what rebinds a consumer to a replacement provider and recreates provider-local state from the consumers that own it — and re-registers subscriptions, which a plain deploy does not do. A failed unpause restores `PAUSED` rather than leaving the module live and mis-bound. Cascade order reuses `services/update/dep-graph.ts` unchanged (pause = consumers first, unpause = providers first) and is computed from the GRAPH, never from which modules are currently paused, so a cascade walks THROUGH already-done members and is resumable. See `openspec/changes/module-pause-lifecycle/`.
177
+ - **Provider-removal guard** — `apps/celilo/src/services/remove-guard.ts` — `findRemovalBlockers`/`describeRemovalRefusal`, called from `apps/celilo/src/cli/commands/module-remove.ts`. A PAUSED module is not a dependent (unpause cannot return it to service without a redeploy, and a redeploy re-resolves capabilities), which is what makes a provider swap possible at all. A dependent is one declaring the capability under `requires` **or** `optional` — the same relation `dep-graph.ts` uses, so the guard and the cascade agree on the set; the guard previously read `requires` alone, which let a removal silently orphan `technitium`'s `optional` `dhcp_server`. Refusals name each blocker AND which declaration makes it one. It deliberately does NOT exempt a dependent because another provider of the same capability exists (celilo#683).
173
178
  - **Backup artifact encryption** — `apps/celilo/src/services/backup-cipher.ts` — `encryptFileToFile`/`decryptFileToFile`, file-in/file-out and streamed, used by every backup writer (`backup-create.ts`) and reader (`backup-restore.ts`, `restore-from-file.ts`). Do NOT route artifacts through `secrets/encryption.ts`: that API is string-in/string-out for short DB values, and feeding it a tar cost base64 (1.33x) then hex (2x) then `JSON.stringify` — ~9x the artifact in memory, which OOM-killed forgejo's 774 MB backup, and a hard ~805 MB ceiling from the max string length that no amount of RAM raises. On-disk format is `magic "CELILOBK" (8) | version (1) | iv (16) | ciphertext | GCM tag (16)`; the tag is a trailer because it does not exist until the last byte is encrypted. `decryptFileToFile` still reads the pre-2026-07 JSON-envelope artifacts, discriminating on the magic bytes — the envelope's own `schemaVersion` cannot serve, as it lives inside the encrypted tar.
174
179
 
180
+ ### Paused modules are conspicuous
181
+
182
+ A pause switches OFF the alerting that would otherwise report the module as
183
+ down, so the paused-ness itself is the signal. Four surfaces, none optional:
184
+ `celilo module list` / `celilo status` render `PAUSED (3d)` with the reason;
185
+ `checkPausedModules` in `apps/celilo/src/services/fleet-checks.ts` makes ANY
186
+ paused module a `system doctor` FAILURE (no threshold — a pause is a degraded
187
+ state, and a threshold is just something to tune until the detector stops
188
+ firing); `findSuppressor` in `apps/celilo/src/services/alerting/suppression.ts`
189
+ attributes the module's suppressed alerts to the pause rather than dropping them
190
+ anonymously; and `fleetWarnings()` in `apps/celilo/src/api/serve.ts` stamps a
191
+ warning naming every paused module and its age onto EVERY management-API result
192
+ (`ResultMessage.warnings`), including on commands unrelated to those modules.
193
+ That last one is the important half: it does not depend on the operator choosing
194
+ to look, which is how a forgotten pause actually gets found.
195
+
175
196
  ## Generation & templating
176
197
 
177
198
  - **Generator** — `apps/celilo/src/templates/generator.ts` — `generateTemplates` (orchestration), plus Terraform/Ansible file handling.
@@ -199,7 +220,10 @@ is currently wrong, and routes carry the message to a person's phone. Design:
199
220
  - **Alert identity** — `apps/celilo/src/services/alerting/keys.ts` — the key grammar (`module:<id>[/check:<name>]`, `builtin:<check>[/<kind>:<target>]`) that makes "the same problem" the same alert across runs. `moduleAlertKey`, `moduleCheckAlertKey`, `builtinAlertKey`, `parseAlertKey`.
200
221
  - **Reconciliation** — `apps/celilo/src/services/alerting/reconcile.ts` (`reconcile`) — a successful run's failing-key set is authoritative and resolution is by SET DIFFERENCE (absent ⇒ resolved). A run whose outcome is `error` resolves NOTHING and fires a module-level alert instead: the false-all-clear guard.
201
222
  - **Monitor execution** — `apps/celilo/src/services/alerting/run-monitor.ts` (`runOneMonitor`) + `sweep.ts` (`selectDueMonitors`) + `builtin-monitors.ts` / `health-coverage.ts` (the built-in checks and the "module with no health check" coverage check).
202
- - **Scheduled audit categories (`builtin_check` monitors)** — `apps/celilo/src/services/alerting/builtin-source.ts` — `SCHEDULABLE_BUILTIN_CHECKS` is the list of `celilo system audit` categories cheap enough to run every sweep: `machines_reachable`, `backups`, `disk_space`, `abandoned_operations`, and `public_dns` (`apps/celilo/src/services/audit/abandoned-operations.ts` ≥3 abandonments of the same (module, operation) in 7d, the fingerprint of an operation being killed mid-flight). Everything else in the audit needs the whole world injected (proxmox, terraform, registry) and is not schedulable. Enable one with `celilo monitor add backups --interval 1h`. Targets are tab-completable `completion.ts` reads `SCHEDULABLE_BUILTIN_CHECKS` directly rather than a hand-copied list, so a newly-schedulable check is completable immediately.
223
+ - **Health-check cadence (one accessor)** — `apps/celilo/src/services/alerting/health-cadence.ts` — `effectiveHealthCheckCadence(manifest, override)`, `isScheduled`, `loadModuleHealthCadences`, `reconcileModuleWatchState`. The manifest's `hooks.health_check.interval` SUGGESTS; the operator's `health_check_interval` decides; both resolve at read time. `null` means nobody named a cadence (a coverage gap); `'manual'` means the operator opted out (a decision raises no coverage finding, and resolves the module's live alerts on the way down, since nothing will report on them again).
224
+ - **⚠️ `monitors.intervalMinutes` and `monitors.enabled` are `builtin_check`-only.** A `module_hook` row carries severity, escalation policy and `lastRunAt`; its cadence and whether it is watched resolve through the accessor above. The columns' meaning depending on `kind` is a named smell (design.md D8) — the alternatives are a cached resolved value that rots, or splitting the table, which needs a synthetic monitor identity for `alerts.monitorId`. Gates: `sweep-runner.test.ts` asserts a module row's stored values are NOT consulted; `cadence-migration.test.ts` asserts the same through `loadModuleHealthCadences`.
225
+ - **Carrying an existing fleet over** — `apps/celilo/src/services/alerting/cadence-migration.ts` (`migrateMonitorCadences`), run from `celilo system migrate` (the `.deb` postinst runs it on every apt upgrade). A monitor row whose cadence diverges from its manifest gets that cadence written as an override; a disabled one gets `manual`. Not bookkeeping: without it the upgrade that ships read-time resolution silently reverts every hand-set cadence to the author's suggestion and resumes watching modules an operator deliberately disabled. Idempotent — writes only where no override exists.
226
+ - **Scheduled audit categories (`builtin_check` monitors)** — `apps/celilo/src/services/alerting/builtin-source.ts` — `SCHEDULABLE_BUILTIN_CHECKS` is the list of `celilo system audit` categories cheap enough to run every sweep: `machines_reachable`, `backups`, `disk_space`, `abandoned_operations`, and `public_dns` (`apps/celilo/src/services/audit/abandoned-operations.ts` — ≥3 abandonments of the same (module, operation) in 7d, the fingerprint of an operation being killed mid-flight). Everything else in the audit needs the whole world injected (proxmox, terraform, registry) and is not schedulable. Enable one with `celilo monitor add backups --interval 1h`, and re-cadence it later with `celilo monitor set-interval backups 6h` (in place, because the monitor id owns the alert history). `monitor set-interval`/`enable`/`disable` REFUSE a module target and name `celilo module config set <m> health_check_interval` — two ways to set one module's cadence would disagree about what `module status` shows. Targets are tab-completable — `completion.ts` reads `SCHEDULABLE_BUILTIN_CHECKS` directly rather than a hand-copied list, so a newly-schedulable check is completable immediately.
203
227
  - **Disk-space check** — `apps/celilo/src/services/audit/disk-space.ts` (`auditDiskSpace`, pure over measurements) + `apps/celilo/src/services/disk-probe.ts` (`probeDiskUsage`). Thresholds: `drift` at 85%, `blocked` at 95% — early enough to act on, since a check that fires at exhaustion reports an outage rather than preventing one. ⚠️ **The local management box is MEASURED, not exempted.** `probeMachines()` deliberately reports the local box reachable without probing it (celilo has no SSH key for itself, and the question is meaningless there); copying that shortcut into a disk check would skip the host most likely to fill — the one that stages backups, caches modules and writes the logs, and the one that DID fill. Local reads `statfs`; remote runs `df -P /` over the same bounded SSH. `percentUsed` matches `df`'s capacity semantics (excludes root-reserved blocks) so an alert and an operator's own `df` agree. An unmeasurable host yields a `todo` finding — recorded, never paged, because `machines_reachable` is already paging for that host. Findings are subjected on the **hostname**, not the machine UUID, because suppression resolves a machine's ancestor key from the hostname (see #596, where `machines_reachable` gets this wrong and its alerts therefore never suppress anything). The `backups` roster comes from `apps/celilo/src/services/audit/backup-source.ts` (`loadBackupAuditInfo`), shared with `celilo system audit` so both judge the same fleet.
204
228
  - **Public-DNS reachability check (the only check with an OFF-FLEET vantage)** — `apps/celilo/src/services/audit/public-dns.ts` (`auditPublicDns`, pure over an injected probe and the previous run's counters) + `apps/celilo/src/services/public-dns-probe.ts` (the probe) + `audit/public-dns-source.ts` (ledger names + the `public_dns_evidence` counters). Every other check in celilo looks from INSIDE, behind a split-horizon resolver that deliberately answers with an in-zone address — correct for its purpose, and why all of them reported healthy for the nine days of celilo#626. This one resolves every `dns_registrations` FQDN through an **off-fleet resolver** (`public_dns.resolver`, default `1.1.1.1`) and compares it against the address the fleet appears to come from per an independent **echo service** (`public_dns.echo_url`, default `https://api.ipify.org`). Three properties are load-bearing: `assertOffFleetResolver` REFUSES a resolver matching `dns.primary`/`dns.fallback` (a check that quietly used the fleet's resolver would pass forever — the original bug one layer up); the expectation never comes from the registrar's own response (self-agreement, and Namecheap returns `ErrCount 0` for `www` updates it does not apply); and a divergence is a finding only once it OUTLIVES the record's own TTL, measured from the last assert, or it would page on every ISP re-lease. Missing evidence is counted rather than read as success — one undetermined run is silent, N consecutive ones are their own finding (`public_dns_evidence`), which is the hole celilo-website's isitup.org probe demonstrated live. Codes: `public_dns_stale`, `public_dns_missing`, `public_dns_companion_unclaimed`, `public_dns_unverifiable`. Spec: `openspec/specs/public-dns-reachability/spec.md`.
205
229
  - **The sweep** — `apps/celilo/src/services/alerting/sweep-runner.ts` (`runSweep`) — the ordered pass that makes alerting run by itself: run due monitors → promote past-grace alerts → re-evaluate suppression → flush quiet-hours deferrals → notify. Driven by `celilo alerts sweep` on `timer.tick.5m`. Never throws for one bad monitor.
@@ -216,18 +240,32 @@ is currently wrong, and routes carry the message to a person's phone. Design:
216
240
 
217
241
  ## Backups
218
242
 
219
- Creation, scheduling and freshness. A module declares an `on_backup` hook and a
220
- `backup.schedule`; celilo runs it on that cadence and alerts when it stops.
243
+ Creation, scheduling and freshness. A module declares an `on_backup` hook and
244
+ SUGGESTS a `backup.schedule`; the operator's override decides; celilo runs it on
245
+ the resolved cadence and alerts when it stops.
221
246
 
222
- - **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules`, `isBackupDue`. Storage destinations: `backup-storage.ts`. Retention: `backup-retention.ts` (`pruneBackupsForModule`). Restore: `backup-restore.ts`.
223
- - **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest)`. An absent `backup.schedule` means `daily`, NOT `manual`; opting out takes an explicit `manual`. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
247
+ - **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules` (returns each module's operator config alongside its manifest, because every caller has to resolve a policy out of the two together), `isBackupDue`. Storage destinations: `backup-storage.ts`. Restore: `backup-restore.ts`.
248
+ - **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest, override)`. The manifest SUGGESTS; the operator's `backup_schedule` row in `module_configs` decides; absent from both means `daily`, NOT `manual` (opting out takes an explicit `manual`). Resolution happens at READ time — nothing is materialised at install or deploy — so a corrected manifest reaches every install that has not overridden. The one-argument form was DELETED rather than kept as an overload (Rule 3.9): it would let an un-updated reader compile clean while silently ignoring overrides. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
249
+ - **The cadence type** — `apps/celilo/src/services/cadence.ts` — `Cadence` (`{minutes}` | `'manual'`), `parseCadence` / `formatCadence` / `cadenceMs`, and `cadenceSchema({floorMinutes})`. One spelling set for every cadence in celilo: a named period (`hourly`/`daily`/`weekly`/`monthly`), a duration (`6h`, `90m`, `3d`), or `manual`. ⚠️ **The floors are DERIVED from the sweep ticks, never written down**: this file owns `BACKUP_SWEEP_PATTERN` / `ALERTING_SWEEP_PATTERN` and computes `BACKUP_CADENCE_FLOOR_MINUTES` / `MONITOR_INTERVAL_FLOOR_MINUTES` from them (the sweeps import their pattern from here), so changing a tick moves what it can serve in the same edit. A cadence finer than its sweep's tick is REFUSED, not coerced — accepting it leaves the operator believing they configured something that can silently never happen.
250
+ - **Retention (one accessor)** — `apps/celilo/src/services/backup-retention.ts` — `effectiveBackupRetention(manifest, configs)`, `prunesNothing`, `identifyExpiredBackups`, `pruneBackupsForModule`. Two INDEPENDENT dimensions (copies, age), each resolved override → manifest → **unbounded**. ⚠️ **An unset dimension is unbounded, never a default bound.** `backup.retention` is an optional block, so a manifest omitting it prunes nothing at all and its inner `count: 7` / `max_age_days: 30` defaults never apply; if setting one dimension let the other fall back to those, an operator asking to keep 3 copies would silently arm a 30-day deletion on a module that had been keeping everything. Unbounded is `Infinity`, which `identifyExpiredBackups` needs no special case for. Gate: `services/backup-retention.test.ts`. The four sites that used to read `manifest.backup.retention` directly (`cli/commands/backup-sweep.ts`, `backup-create.ts`, `backup-prune.ts` twice) all go through the accessor — that duplication is what let the schedule readers drift.
224
251
  - **The sweep** — `apps/celilo/src/services/backup-sweep.ts` — `runBackupSweep` (the pass that makes backups run by themselves: reclaim orphaned staging → for each eligible module, is its declared cadence due → back it up → apply declared retention) + `ensureBackupSweepSubscriber`. Driven by `celilo backup sweep` on `timer.tick.1h` — the coarsest tick that can still serve an `hourly` cadence. Armed from BOTH `registerModuleSubscriptions` (any module declaring an `on_backup` hook, so it appears on install or `module update`) AND `celilo system migrate`, which the `.deb` postinst runs on every apt upgrade — registering only from module install/update meant a corrected budget never reached an existing fleet, since the row already exists and module updates can be weeks apart. A run refused by the in-flight operation lock is a skip retried next tick, never a failure.
225
252
  - **⚠️ The sweep's budget is stated, never inherited** — `BACKUP_SWEEP_TIMEOUT_MS` (4h) and `BACKUP_SWEEP_MAX_ATTEMPTS` (1) in `backup-sweep.ts`. The event bus defaults to `timeout_ms: 60000` / `max_attempts: 3`, and inheriting them made scheduled backups structurally impossible: one forgejo backup measured ~5.5 minutes (1.3 GB result, 3.9 GB peak staging), so the dispatcher SIGTERMed it at 60s — three times an hour, for days. The retry count is half the bug, not a detail: an impossible pass retried 3x strands 3x the staging (27 GB in 5.7 hours on celilo-mgr). The hourly tick IS the retry.
226
253
  - **Staging reclamation** — `apps/celilo/src/services/backup-staging.ts` — `reapOrphanedStaging`, `stagingDirFor` (the single source of truth for `/tmp/celilo-backup-<record.id>`, shared with `backup-create.ts` so writer and reaper cannot drift). `backup-create.ts` removes its staging in a `finally`, which is correct and NOT enough: a `finally` never runs when the process is killed by a signal — dispatcher timeout, OOM, Ctrl-C, reboot — and those strand the LARGEST directories. So reclamation is kill-mode agnostic by construction: it asks "is anyone still using this?", answered from the `backups.pid` column plus `isPidRunnable`, both of which outlive the process. A directory is removed only when its owner is provably gone (record absent, record terminal, pid dead, or past `STAGING_TTL_MS` = 6h — the TTL is the only check surviving pid reuse). A live backup is always kept; an unrecognised name is ignored, never deleted. Reclaiming a record that still claimed `in_progress` also marks it failed with `ABANDONED_BACKUP_MESSAGE`, so `celilo backup list` stops showing phantom in-flight backups and the `backups` drift check cannot read a dead attempt as a fresh backup.
227
- - **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`) — `backup_missing` / `backup_stale` drift findings against the same declared cadence.
254
+ - **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`, `backupStaleThresholdMs`) — `backup_missing` / `backup_stale` drift findings against the same EFFECTIVE cadence, loaded (with each module's override) by `audit/backup-source.ts`. The stale threshold is a formula, `cadence + max(1h, cadence × 0.1)`, not a lookup table: a table cannot answer for `6h`. An effective cadence of `manual` short-circuits BEFORE the never-backed-up check — a module the operator opted out of used to be reported as missing a backup forever, remediated only by the thing they declined.
255
+ - **⚠️ Both age measurements read `backups.completedAt`** — `loadBackupHistory` (`backup-metadata.ts`) and `latestSuccessfulBackupByModule` (`audit/backup-source.ts`). The run path used to measure from `startedAt` and the audit from `completedAt`, so the two disagreed by the duration of the backup itself. Gate: `services/backup-age-agreement.test.ts`.
228
256
  - **CLI** — `apps/celilo/src/cli/commands/` — `backup-sweep.ts`, `backup-create.ts` (also `celilo module backup`), `backup-list.ts`, `backup-restore.ts`, `backup-prune.ts`, `backup-delete.ts`, `backup-import.ts`, `backup-pull.ts`, `backup-name.ts`.
229
257
  - **Storage destinations CLI** — `storage-add-local.ts`, `storage-add-s3.ts`, `storage-list.ts`, `storage-verify.ts`, `storage-set-default.ts`, `storage-set-path.ts` (relocate a local destination, migrating existing archives unless `--no-migrate`), `storage-remove.ts`. Any credential change goes through `updateStorageCredentials` in `backup-storage.ts`, which clears the verification stamp — a `✓ Verified` must never describe a destination it was not measured against (#566).
230
258
 
259
+ ## Per-module operator policy
260
+
261
+ How celilo TREATS a module — its backup cadence, its health-check cadence, its
262
+ upgrade controls — as opposed to how the module configures itself.
263
+
264
+ - **The keys** — `FRAMEWORK_CONFIG_KEYS` in `apps/celilo/src/cli/commands/module-config.ts` — keys EVERY module accepts whether or not its manifest declares them: `auto_upgrade`, `upgrade_policy`, `backup_schedule`, `health_check_interval`, `backup_retention_count`, `backup_retention_max_age_days`. Each carries `{schema, why}`: a Zod schema, and the reason a wrong value is REFUSED rather than coerced. That reason is per key on purpose — the substance is what a wrong value would silently do (a cadence typo falls back to the manifest's suggestion; an `upgrade_policy` typo falls back to `by-semver`, which skips the pre-deploy backup on a patch), and no validator knows that.
265
+ - **Storage** — rows in `module_configs`, unique on `(module_id, key)`, cascading on module removal. Nothing deletes them on `module update`, and `backup-create.ts` archives them into backup envelopes while `backup-restore.ts` restores them — so an override survives update, upgrade and restore with no new table and no migration.
266
+ - **Operator surface** — `celilo module config set|get|unset <module> <key> [value]`. `unset` (→ `deleteModuleConfig` in `services/module-config.ts`) is what returns a module to following its manifest; without it an operator who once set a value could never go back, and later manifest corrections would stop reaching them permanently. Unsetting an absent key reports that and SUCCEEDS — it states a desired end state. All three are MCP tools with no bespoke code, generated from `packages/core/src/command-registry.ts`.
267
+ - **Display** — `formatCadencePolicy` in `apps/celilo/src/cli/commands/module-status.ts` shows the effective value AND its source, naming the manifest's suggestion when an override is in effect. The stored override alone does not say what it was changed from; the effective value alone does not say who chose it.
268
+
231
269
  ## Persistence
232
270
 
233
271
  - **DB schema** — `apps/celilo/src/db/schema.ts`. Client: `apps/celilo/src/db/client.ts`. Migration runner: `apps/celilo/src/db/migrate.ts`. Migrations: `apps/celilo/drizzle/`.
@@ -238,6 +276,20 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
238
276
  - **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`). **Exactly one dispatcher per bus**: `runDispatcher` refuses to start while another dispatcher's process is alive (`assertSoleDispatcher` in `dispatcher.ts`, liveness via `bus.liveDispatchers()` — `kill(pid,0)`, not heartbeat age, since a tick blocks for as long as its slowest handler). The exclusion is here rather than in the systemd unit because a stranded dispatcher can sit outside the unit's cgroup where `KillMode` cannot reach it (#580). `bus.health()` reports `dispatcherCount`/`dispatchers` and a `duplicate_dispatcher` status; `checkDispatcher` (`services/fleet-checks.ts`) fails on more than one. **Supervision** (`services/events-daemon.ts`): the unit is named `celilo-events.service` in BOTH the user and system scope, so the two are indistinguishable in every operator-facing string — `install-daemon` (which defaults to **user** scope) therefore refuses when the other scope's unit exists, and `checkDispatcher` compares the live pid against each installed unit's `MainPID` (`unitMainPid`) rather than merely testing that a unit file exists, since a file nobody is running is not supervision (#610).
239
277
  - **Dispatcher supervision (install / restart)** — `apps/celilo/src/services/events-daemon.ts` — `installDaemon`/`planDaemonInstall`/`uninstallDaemon`/`readInstalledUnit` write the systemd unit or launchd plist without touching supervisor state, and `restartDaemon` (+ pure `orphanDispatcherPids`, `resolveRestartScope`, `supervisorCommands`) is the one verb that DOES cycle it. CLI: `celilo events install-daemon|uninstall-daemon|show-daemon|restart-daemon [--system]`. `restart-daemon` exists because the dispatcher runs the code it LOADED: celilo-mgr sat 9 days on event-bus v0.1.8 after apt installed v0.2.0, running the very bug the release fixed (celilo#604). Two things make it non-trivial and both are load-bearing: (1) it stops any live dispatcher the supervisor does not own first — an ORPHAN (PPID 1) is invisible to `systemctl restart`, and `assertSoleDispatcher` then crash-loops the unit while the old code keeps serving; (2) it verifies on the BUS that a NEW pid is live reporting `BUS_VERSION`, never off systemctl's exit code, which returns 0 into exactly that crash loop. System scope shells `sudo systemctl` (the unit is root-owned; celilo runs unprivileged), covered by the scoped `/etc/sudoers.d/celilo-events-restart` conffile that `celilo-bootstrap` ships — a unit test asserts the argv and the grant cannot drift apart.
240
278
 
279
+ ## Terminal UI (`@celilo/cli-display`)
280
+
281
+ Every CLI-facing primitive celilo renders with. Lives in `packages/cli-display/src/` so module code running in-process (capability functions, hook scripts) can reach it via `@celilo/capabilities`' re-export, and so `@celilo/core` can use it without depending on `@celilo/cli`. `@clack/prompts` was removed tree-wide in celilo#699; nothing here wraps a third-party prompt library.
282
+
283
+ **The stream contract:** stdout carries a command's RESULT and nothing else — no glyph, no box-drawing prefix, no ANSI. Everything else (prompts, progress, banners, log lines, diagnostics) goes to **stderr**. This is what lets `celilo module list | grep '^caddy '` and `celilo events tail | jq` work without a repair step; the previous renderer put chrome on stdout, which cost a full e2e run when a `│ ` prefix read as a missing module (celilo#695) and made ten JSON commands unparseable (celilo#698).
284
+
285
+ - **Prompts** — `packages/cli-display/src/prompt.ts` — `text`, `password`, `confirm`, `select`, `multiselect`, plus `CANCEL`/`isCancel` and `isInteractive`. Raw-mode keypress handling with cursor movement, submit-time validation, and per-keystroke tokenization (one stdin chunk can carry a whole paste). **Refuses a non-TTY stdin** with `PromptUnavailableError` instead of hanging on it or taking the next newline as a considered answer — both real past failures. New interactive decisions must NOT be added here; they belong on the event-bus interview, which a headless responder can answer.
286
+ - **Message chrome** — `packages/cli-display/src/messages.ts` — `intro`, `outro`, `note`, `cancel`, and `log.{success,error,warn,info,message,step}`. All to stderr; routed into the active `ProgressDisplay` when one is set, so the two renderers never fight over the cursor.
287
+ - **Colour** — `packages/cli-display/src/colors.ts` — `colorEnabled()`, `paint()`, and the `colors` token object. Gated per use on `NO_COLOR` → `FORCE_COLOR` → `stderr.isTTY`, so colour never reaches a pipe, a file, or protocol mode.
288
+ - **Progress display** — `packages/cli-display/src/progress-display.ts` — `ProgressDisplay`: a flat append-only stream with a time gutter and one status-glyph vocabulary in `render` mode, or structured `[progress:start|done|fail|sub]` markers in `protocol` mode (what the Remote API server emits and the client re-renders locally).
289
+ - **Active-display singleton** — `packages/cli-display/src/active-display.ts` — `getActiveDisplay`/`setActiveDisplay`; how a long-running command lets everything down its call chain route output through one display.
290
+ - **celilo-side wrapper** — `apps/celilo/src/cli/prompts.ts` — `celiloIntro`/`celiloOutro`, `promptText`/`promptPassword`/`promptConfirm`, `showNote`, `log`. Roughly 160 `log.*` call sites route through this one module, which is why swapping the underlying implementation was a one-file change.
291
+ - **FuelGauge** — `apps/celilo/src/cli/fuel-gauge.ts` — the Cylon-style indicator for operations over ~3s (ESC to background, ^C to cancel); delegates to the active `ProgressDisplay` when one is set.
292
+
241
293
  ## Remote API (drive the CLI over the wire)
242
294
 
243
295
  Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <host> celilo …`. Typed, streamed, per-operation authz, mid-run interviews. Design: `openspec/changes/replace-ssh-cli-api/proposal.md`.
@@ -246,7 +298,7 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
246
298
  - **Wire protocol** — `packages/core/src/protocol.ts` (`@celilo/core`) — versioned NDJSON tagged union (`command`/`progress`/`log`/`result`/`error`/`interview`/`answer`) + `translateOutputLine`.
247
299
  - **Registry serialization** — `apps/celilo/src/cli/commands/commands-json.ts` — `celilo commands --json` prints the full `COMMANDS` tree as JSON; the live source of truth the MCP fetches to generate its tool surface (so it mirrors whatever celilo version the server runs). `service list --json` similarly exposes configured providers for MCP auto-detect. `module list --json` prints the module roster (id/version/state) as stable JSON — the backbone the MCP composite troubleshooting tools correlate `audit --json` findings against.
248
300
  - **Server** — `apps/celilo/src/api/serve.ts` (`apiServeMode`); the `celilo api-serve --principal=<id>` sshd forced-command entry point (dispatched in `apps/celilo/src/cli/index.ts`). Authorizes per principal, runs the command as a protocol-mode child, streams output, audits to stderr.
249
- - **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via clack).
301
+ - **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via the `@celilo/cli-display` prompts). Refuses to prompt on a non-TTY stdin, replying `unanswerable` rather than submitting a default as if a human had chosen it.
250
302
  - **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
251
303
  - **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
252
304
  - **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
@@ -259,6 +311,6 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
259
311
  - **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
260
312
  - **Run wrapper** — `infra/scripts/cele2e-run.sh` lives in the separate `infra/` clone, **not** in this repo. See the cele2e section of the repo-root `CLAUDE.md` for the operator workflow.
261
313
  - **Signal simulators** — three containers, three jobs. `docker/Dockerfile.signal-cli` runs the REAL unlinked daemon (`network().withSignalCli()`, reachable at `signal-cli.lab`) so `e2e/tests/signal-contract.test.ts` can re-check celilo's understanding of the JSON-RPC surface against the actual binary — the pebble pattern. `docker/Dockerfile.signal-sim` runs `simulators/signal-cli/server.ts` (`withSignalSim()`, `signal-sim.lab`), the drivable stand-in with a control surface (`/_control/inbound`, `/_control/sent`, `/_control/unlink`). `/_control/inbound` from the LINKED account's own number emits a `syncMessage.sentMessage` transcript rather than a `dataMessage`, because that is the only shape the real daemon delivers a note-to-self in — and a note-to-self is what every reply is in the default single-operator setup (#460). `docker/Dockerfile.signal-release` (`withSignalRelease()`, `signal-release.lab`) serves the signal-cli release tarball so the module's deploy-time download resolves inside the sealed network — the download is served, never skipped. The libsignal aarch64 native is compiled at `build-infra` time from the module's own recipe (`packages/e2e/scripts/stage-libsignal.ts` → `modules/signal/build/`) and staged into the apt-repo pool, so the recipe is exercised for real on every rebuild while the deploy stays fast and the network stays sealed.
262
- - **Public-boundary NAT model** — `packages/e2e/config/routing/` — exactly ONE NAT sits between the fleet and the simulated internet: the customer firewall (`fw-main` in `direct-internet`, `fw-isp` in the two-layer default), which MASQUERADEs to `100.100.0.100`. The ISP edge `fw-ext` ROUTES the customer's `100.100.0.0/24` and must never re-NAT it (`-s 100.100.0.0/24 -j RETURN` ahead of its MASQUERADE) — an ISP does not NAT a subscriber that already holds a public address. This is load-bearing, not cosmetic: Namecheap-style DDNS registers the SOURCE address when the caller omits `ip=`, which is how celilo registers public names since #464/#466, so a second NAT here publishes the simulator's own address for every public hostname and quietly breaks ACME, inbound reach, and seeded apex records. Constants: `externalWanIp()` / `externalWanSubnet()` in `src/types.ts`. The corollary: every simulator on `internet-external` must default-route via fw-ext (`100.64.0.1`) so it can reply to the customer's public address — Docker's bridge gateway has no path across networks. `config/routing/public-sim-entrypoint.sh` is the shared two-liner; the DNS hierarchy, pebble, isitup and celilo-website-sim already carried it, and npm-registry / registry / apt-repo / minio / cpanel-host only appeared to work because the double NAT put fw-ext's on-link address in the source field.
314
+ - **Public-boundary NAT model** — `packages/e2e/config/routing/` — exactly ONE NAT sits between the fleet and the simulated internet: the customer firewall (`fw-main` in `direct-internet`, `fw-isp` in the two-layer default), which MASQUERADEs to `203.0.113.100`. The ISP edge `fw-ext` ROUTES the customer's `203.0.113.0/24` and must never re-NAT it (`-s 203.0.113.0/24 -j RETURN` ahead of its MASQUERADE) — an ISP does not NAT a subscriber that already holds a public address. This is load-bearing, not cosmetic: Namecheap-style DDNS registers the SOURCE address when the caller omits `ip=`, which is how celilo registers public names since #464/#466, so a second NAT here publishes the simulator's own address for every public hostname and quietly breaks ACME, inbound reach, and seeded apex records. Constants: `externalWanIp()` / `externalWanSubnet()` in `src/types.ts`. The corollary: every simulator on `internet-external` must default-route via fw-ext (`100.64.0.1`) so it can reply to the customer's public address — Docker's bridge gateway has no path across networks. `config/routing/public-sim-entrypoint.sh` is the shared two-liner; the DNS hierarchy, pebble, isitup and celilo-website-sim already carried it, and npm-registry / registry / apt-repo / minio / cpanel-host only appeared to work because the double NAT put fw-ext's on-link address in the source field.
263
315
  - **Simulated address plan** — `packages/e2e/src/types.ts` — `SIM_PRIVATE_SUPERNET` (`10.226.0.0/16`), `zoneIp(zone, host)`, `ZONE_SUBNETS`, `ZONE_GATEWAYS`. Every simulated PRIVATE zone is derived from this one table; the compose generator, `zone-classifier.ts` and the harness's `system init` all read it, so renumbering the whole sim is a one-line change. The sim deliberately does NOT reuse a real fleet's zone /24s (#539): the previous plan was byte-identical to production's, so a stack leaked on celilo's own forgejo-builder — which lives in the real dmz — claimed the builder's own subnet and blackholed every containerized CI job's route to the forge for ~135s. Teardown cannot prevent that (a SIGKILL runs no handler), so the addresses moved instead; the second, better reason is that a suite which only passes on production's exact octets is asserting one site's address plan rather than celilo's behaviour. `src/address-plan.test.ts` is the recurrence gate — it fails if a zone leaves the supernet, or if any retired fleet prefix reappears anywhere in `packages/e2e`, `e2e/tests` or `modules/*/e2e`.
264
316
  - **MCP server (agent-driven e2e)** — `packages/mcp-server/src/index.ts` — stdio MCP server exposing `start_run`/`run_status`/`run_result`/`stop_run` (detached cele2e runs read off the event bus, no ANSI scraping) + `env_check` (docker VM, run-lock, shared-infra, mgmt image CLI version, netapps). Dev/ops tool, `private`, not shipped to consumers.
@@ -0,0 +1,20 @@
1
+ -- Module pause/unpause (openspec/changes/module-pause-lifecycle).
2
+ --
3
+ -- Purely additive: `state` gains a new legal value 'PAUSED' (no constraint to
4
+ -- alter — the column is free text typed only in TypeScript), plus two nullable
5
+ -- columns. Every existing module is simply not paused, so there is nothing to
6
+ -- backfill.
7
+ --
8
+ -- `paused_at` is not decoration. A pause suppresses the alerting that would
9
+ -- otherwise report the module as down, so the paused-ness itself has to be
10
+ -- loud; the DURATION is what distinguishes a maintenance window from an outage
11
+ -- someone forgot about, and `state` alone cannot answer it (design D7).
12
+ --
13
+ -- Note for rollback: an older CLI reading state='PAUSED' hits a value outside
14
+ -- its union and fails to recognise it, rather than reading the module as live.
15
+ -- Loud beats silent — but it still has no `unpause`, so unpause everything
16
+ -- before downgrading.
17
+
18
+ ALTER TABLE `modules` ADD `paused_at` integer;--> statement-breakpoint
19
+ ALTER TABLE `modules` ADD `pause_reason` text;--> statement-breakpoint
20
+ CREATE INDEX `modules_state_idx` ON `modules` (`state`);
@@ -169,6 +169,13 @@
169
169
  "when": 1783900000000,
170
170
  "tag": "0023_public_dns_evidence",
171
171
  "breakpoints": true
172
+ },
173
+ {
174
+ "idx": 24,
175
+ "version": "6",
176
+ "when": 1784000000000,
177
+ "tag": "0024_module_pause",
178
+ "breakpoints": true
172
179
  }
173
180
  ]
174
- }
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,12 +56,12 @@
56
56
  "check:schema": "bun run scripts/export-manifest-schema.ts --check"
57
57
  },
58
58
  "dependencies": {
59
- "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^1.0.1",
61
- "@celilo/cli-display": "^0.1.10",
62
- "@celilo/core": "^0.6.0",
63
- "@celilo/event-bus": "^0.4.0",
64
- "@clack/prompts": "^1.1.0",
59
+ "@aws-sdk/client-s3": "^3.1109.0",
60
+ "@aws-sdk/lib-storage": "^3.1101.0",
61
+ "@celilo/capabilities": "^1.2.0",
62
+ "@celilo/cli-display": "^0.2.0",
63
+ "@celilo/core": "^0.8.0",
64
+ "@celilo/event-bus": "^0.5.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
67
67
  "ink": "^7.0.1",
@@ -111,7 +111,11 @@ describe('Container Services and Machine Pool CLI Integration', () => {
111
111
  // Remove service with --force flag to skip confirmation
112
112
  const output = runCli(ctx.cli, 'service remove service-to-remove --force');
113
113
 
114
- expect(output).toContain('removed');
114
+ // The command's RESULT, which is what stdout carries. This used to assert
115
+ // on the lowercase "removed" of the `celiloOutro` banner, which clack put
116
+ // on stdout while putting the result on stderr; celilo#699 swapped those
117
+ // so stdout is the data channel and the banner is chrome on stderr.
118
+ expect(output).toContain('Removed service: service-to-remove');
115
119
 
116
120
  // Verify deletion
117
121
  const services = await db.select().from(containerServices).all();
@@ -236,7 +240,9 @@ describe('Container Services and Machine Pool CLI Integration', () => {
236
240
  // Remove machine with --force flag to skip confirmation
237
241
  const output = runCli(ctx.cli, 'machine remove remove-me --force');
238
242
 
239
- expect(output).toContain('removed');
243
+ // See the service-remove case above: stdout now carries the result, not
244
+ // the outro banner (celilo#699).
245
+ expect(output).toContain('Removed machine: remove-me');
240
246
 
241
247
  // Verify deletion
242
248
  const machinesList = await db.select().from(machines).all();
@@ -93,11 +93,12 @@ test('renders a forwarded interview and sends the answer back', async () => {
93
93
  /**
94
94
  * Regression for the fabricated "operator declined".
95
95
  *
96
- * The default renderer prompts with clack, which reads keypresses off stdin
97
- * whether or not stdin is a terminal. Driven over the MCP (stdin = the JSON-RPC
98
- * stream) the next newline submitted the prompt at its `initialValue` — the
99
- * question's `defaultValue` — so a breaking update nobody saw came back as a
100
- * considered "no". With no terminal the client must say it cannot answer.
96
+ * The default renderer prompts on the terminal. The renderer it replaced read
97
+ * keypresses off stdin whether or not stdin was a terminal, so driven over the
98
+ * MCP (stdin = the JSON-RPC stream) the next newline submitted the prompt at
99
+ * its `initialValue` — the question's `defaultValue` — and a breaking update
100
+ * nobody saw came back as a considered "no". With no terminal the client must
101
+ * say it cannot answer.
101
102
  *
102
103
  * And it must say so as `unanswerable`, NOT as an `answer` of any shape: an
103
104
  * `answer` is what consumes the query and destroys a question nobody decided
package/src/api/serve.ts CHANGED
@@ -27,8 +27,10 @@ import {
27
27
  } from '@celilo/core';
28
28
  import { parseArguments } from '../cli/parser';
29
29
  import { getEventBusPath } from '../config/paths';
30
+ import { getDb } from '../db/client';
30
31
  import { isAuthorized } from '../services/api-access';
31
32
  import { EVENT_TYPES } from '../services/bus-interview';
33
+ import { describePausedModule, listPausedModules } from '../services/module-pause';
32
34
  import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
33
35
  import {
34
36
  SessionWriter,
@@ -50,6 +52,38 @@ function send(msg: ServerMessage): void {
50
52
  process.stdout.write(`${JSON.stringify(msg)}\n`);
51
53
  }
52
54
 
55
+ /**
56
+ * The fleet-level warnings stamped onto every terminal result (design D7).
57
+ *
58
+ * Deliberately unconditional and deliberately on UNRELATED commands: a pause
59
+ * suppresses its module's alerting, so the paused-ness itself is the only
60
+ * remaining signal, and a forgotten pause is found incidentally rather than by
61
+ * someone choosing to look. One indexed query (`modules_state_idx`), so this
62
+ * stays cheap enough to run on every call.
63
+ *
64
+ * Never throws: a broken or missing DB must not turn a working command into a
65
+ * failure over a warning.
66
+ */
67
+ function fleetWarnings(): string[] {
68
+ try {
69
+ const paused = listPausedModules(getDb());
70
+ if (paused.length === 0) return [];
71
+ return [
72
+ `${paused.length} module(s) PAUSED: ${paused.map((m) => describePausedModule(m)).join(', ')}. A paused module is quiesced and its alerts are suppressed. Unpause with "celilo module unpause <id>".`,
73
+ ];
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /** Terminal result, with the fleet warnings attached. */
80
+ function resultMessage(success: boolean, exitCode: number): ServerMessage {
81
+ const warnings = fleetWarnings();
82
+ return warnings.length > 0
83
+ ? { type: 'result', success, exitCode, warnings }
84
+ : { type: 'result', success, exitCode };
85
+ }
86
+
53
87
  function audit(principal: string, op: string, decision: string, exitCode?: number): void {
54
88
  const suffix = exitCode === undefined ? '' : ` exit=${exitCode}`;
55
89
  process.stderr.write(
@@ -107,7 +141,7 @@ async function runCommand(argv: string[], forward: (msg: ServerMessage) => void)
107
141
 
108
142
  await Promise.all([pumpLines(child.stdout, forward), pumpLines(child.stderr, forward)]);
109
143
  const exitCode = await child.exited;
110
- forward({ type: 'result', success: exitCode === 0, exitCode });
144
+ forward(resultMessage(exitCode === 0, exitCode));
111
145
  return exitCode;
112
146
  }
113
147
 
@@ -119,7 +153,7 @@ async function handleAttach(principal: string, sessionId: string): Promise<void>
119
153
  const record = readSession(sessionId);
120
154
  if (!record) {
121
155
  send({ type: 'error', error: `no such session: ${sessionId}` });
122
- send({ type: 'result', success: false, exitCode: 1 });
156
+ send(resultMessage(false, 1));
123
157
  return;
124
158
  }
125
159
  if (record.principal !== principal) {
@@ -127,7 +161,7 @@ async function handleAttach(principal: string, sessionId: string): Promise<void>
127
161
  type: 'error',
128
162
  error: `permission denied: session ${sessionId} belongs to another principal`,
129
163
  });
130
- send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
164
+ send(resultMessage(false, EXIT_PERMISSION_DENIED));
131
165
  audit(principal, `attach:${sessionId}`, 'deny');
132
166
  return;
133
167
  }
@@ -167,7 +201,7 @@ function handleCancel(principal: string, sessionId: string): void {
167
201
  const record = readSession(sessionId);
168
202
  if (!record) {
169
203
  send({ type: 'error', error: `no such session: ${sessionId}` });
170
- send({ type: 'result', success: false, exitCode: 1 });
204
+ send(resultMessage(false, 1));
171
205
  return;
172
206
  }
173
207
  if (record.principal !== principal) {
@@ -175,7 +209,7 @@ function handleCancel(principal: string, sessionId: string): void {
175
209
  type: 'error',
176
210
  error: `permission denied: session ${sessionId} belongs to another principal`,
177
211
  });
178
- send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
212
+ send(resultMessage(false, EXIT_PERMISSION_DENIED));
179
213
  audit(principal, `cancel:${sessionId}`, 'deny');
180
214
  return;
181
215
  }
@@ -185,7 +219,7 @@ function handleCancel(principal: string, sessionId: string): void {
185
219
  emittedBy: `api:${principal}`,
186
220
  });
187
221
  audit(principal, `cancel:${sessionId}`, 'allow');
188
- send({ type: 'result', success: true, exitCode: 0 });
222
+ send(resultMessage(true, 0));
189
223
  }
190
224
 
191
225
  export async function apiServeMode(principal: string): Promise<void> {
@@ -251,7 +285,7 @@ export async function apiServeMode(principal: string): Promise<void> {
251
285
 
252
286
  if (!(await isAuthorized(principal, command, subcommand))) {
253
287
  send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
254
- send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
288
+ send(resultMessage(false, EXIT_PERMISSION_DENIED));
255
289
  audit(principal, label, 'deny');
256
290
  return;
257
291
  }
@@ -636,6 +636,40 @@ export class ProxmoxClient {
636
636
  if (!result.success) return result;
637
637
  return { success: true, data: findNodeForVmid(result.data, vmid) };
638
638
  }
639
+
640
+ /**
641
+ * Power a guest down or up. Used by `module pause --stop-infra` (design D2),
642
+ * where stopping the box is opt-in rather than what a pause means.
643
+ *
644
+ * `shutdown` rather than `stop` on the way down: it asks the guest to go
645
+ * quietly instead of pulling its power, and a pause is a planned operation,
646
+ * not a fault. Proxmox returns a task id; callers that need to know it
647
+ * finished poll with `pollTaskUntilDone`.
648
+ */
649
+ async setGuestPower(
650
+ vmid: number,
651
+ kind: 'lxc' | 'qemu',
652
+ power: 'shutdown' | 'start',
653
+ ): Promise<ProxmoxResult<string>> {
654
+ const node = await this.nodeForVmid(vmid);
655
+ if (!node.success) return node;
656
+ if (!node.data) {
657
+ return { success: false, message: `No Proxmox node hosts vmid ${vmid}` };
658
+ }
659
+ return makeProxmoxPost<string>(
660
+ this.credentials,
661
+ `/nodes/${node.data}/${kind}/${vmid}/status/${power}`,
662
+ {},
663
+ );
664
+ }
665
+
666
+ /** Current run state of a guest (`running`, `stopped`, …). */
667
+ async guestStatus(vmid: number): Promise<ProxmoxResult<string | null>> {
668
+ const result = await this.clusterResources();
669
+ if (!result.success) return result;
670
+ const guest = result.data.find((r) => r.vmid === vmid);
671
+ return { success: true, data: guest?.status ?? null };
672
+ }
639
673
  }
640
674
 
641
675
  /**
@@ -7,7 +7,6 @@
7
7
  */
8
8
 
9
9
  import { getDb } from '../../db/client';
10
- import { parseIntervalMinutes } from '../../manifest/schema';
11
10
  import {
12
11
  acknowledgeAlert,
13
12
  findLiveAlertByKey,
@@ -15,6 +14,7 @@ import {
15
14
  silenceAlert,
16
15
  } from '../../services/alerting/ack';
17
16
  import { findPerson, listPeople } from '../../services/alerting/people';
17
+ import { parseIntervalMinutes } from '../../services/cadence';
18
18
  import type { CommandResult } from '../types';
19
19
 
20
20
  function resolveKey(key: string | undefined) {
@@ -21,6 +21,7 @@ import type { SuppressionTopology } from '../../services/alerting/suppression';
21
21
  import { runSweep } from '../../services/alerting/sweep-runner';
22
22
  import { getModuleSystems } from '../../services/deployed-systems';
23
23
  import { runModuleHealthCheck } from '../../services/health-runner';
24
+ import { listPausedModules } from '../../services/module-pause';
24
25
  import type { CommandResult } from '../types';
25
26
 
26
27
  /** Grace window before a newly-fired alert may notify. */
@@ -65,6 +66,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
65
66
  },
66
67
  loadTopology: () => loadTopology(db),
67
68
  loadDeployWindowModules: () => modulesInDeployWindow(db),
69
+ loadPausedModules: () => new Set(listPausedModules(db).map((m) => m.id)),
68
70
  isSuppressible: (alert) => suppressibleByMonitor.get(alert.monitorId) ?? true,
69
71
  notifyDepsFor: (alert) => buildNotifyDeps(db, alert, new Date()),
70
72
  now: () => new Date(),
@@ -10,7 +10,17 @@ import {
10
10
  isBackupDue,
11
11
  } from '../../services/backup-create';
12
12
  import { formatSize } from '../../services/backup-metadata';
13
- import { pruneBackupsForModule } from '../../services/backup-retention';
13
+ import {
14
+ effectiveBackupRetention,
15
+ pruneBackupsForModule,
16
+ prunesNothing,
17
+ } from '../../services/backup-retention';
18
+ import {
19
+ BACKUP_SCHEDULE_CONFIG_KEY,
20
+ effectiveBackupSchedule,
21
+ } from '../../services/backup-schedule';
22
+ import { formatCadence } from '../../services/cadence';
23
+ import { configOverride } from '../../services/module-config';
14
24
  import { celiloIntro, celiloOutro } from '../prompts';
15
25
  import type { CommandResult } from '../types';
16
26
 
@@ -56,12 +66,20 @@ export async function handleBackupCreate(
56
66
  if (eligible.length > 0) {
57
67
  console.log('\nChecking module backup schedules...');
58
68
 
59
- for (const { module: mod, manifest } of eligible) {
60
- const schedule = manifest.backup?.schedule ?? 'manual';
69
+ for (const { module: mod, manifest, configs } of eligible) {
70
+ // Through the shared accessor, like every other reader. This site
71
+ // used to read the manifest directly AND default an absent cadence
72
+ // to `manual` — the opposite of what the accessor has always said —
73
+ // so `celilo backup create` quietly skipped modules the hourly sweep
74
+ // was backing up daily.
75
+ const schedule = effectiveBackupSchedule(
76
+ manifest,
77
+ configOverride(configs, BACKUP_SCHEDULE_CONFIG_KEY),
78
+ );
61
79
  const due = force || isBackupDue(mod.id, schedule);
62
80
 
63
81
  if (!due) {
64
- console.log(` ${mod.id}: skipping (not due, schedule: ${schedule})`);
82
+ console.log(` ${mod.id}: skipping (not due, schedule: ${formatCadence(schedule)})`);
65
83
  moduleSkipCount++;
66
84
  continue;
67
85
  }
@@ -77,13 +95,10 @@ export async function handleBackupCreate(
77
95
  console.log(` → ${result.storagePath}`);
78
96
  moduleBackupCount++;
79
97
 
80
- // Auto-prune per retention policy
81
- const retention = manifest.backup?.retention;
82
- if (retention) {
83
- const pruneResult = await pruneBackupsForModule(mod.id, {
84
- count: retention.count,
85
- maxAgeDays: retention.max_age_days,
86
- });
98
+ // Auto-prune per the effective retention policy
99
+ const policy = effectiveBackupRetention(manifest, configs);
100
+ if (!prunesNothing(policy)) {
101
+ const pruneResult = await pruneBackupsForModule(mod.id, policy);
87
102
  if (pruneResult.deleted > 0) {
88
103
  console.log(` ✓ Pruned ${pruneResult.deleted} old backup(s)`);
89
104
  }