@celilo/cli 1.4.0 → 1.6.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 (55) hide show
  1. package/CELILO_SUBSYSTEMS.md +18 -4
  2. package/MODULE_PRIMITIVES.md +19 -6
  3. package/drizzle/0026_module_integrity_version.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/src/cli/commands/module-audit.ts +5 -2
  7. package/src/cli/commands/module-update.test.ts +90 -2
  8. package/src/cli/commands/module-update.ts +112 -6
  9. package/src/cli/commands/module-verify.ts +77 -13
  10. package/src/cli/commands/system-audit.ts +17 -0
  11. package/src/cli/commands/system-doctor.ts +78 -2
  12. package/src/cli/commands/system-update.ts +33 -3
  13. package/src/cli/index.ts +2 -2
  14. package/src/cli/tui/audit-state.ts +11 -3
  15. package/src/cli/tui/audit-tui.tsx +10 -4
  16. package/src/cli/tui/icons.ts +9 -2
  17. package/src/cli/tui/modals/analyzing.tsx +3 -0
  18. package/src/db/schema.ts +5 -0
  19. package/src/manifest/json-schema-roundtrip.test.ts +12 -4
  20. package/src/manifest/schema.ts +23 -0
  21. package/src/module/import.ts +36 -35
  22. package/src/module/packaging/audit.ts +103 -28
  23. package/src/module/packaging/build.ts +41 -53
  24. package/src/module/packaging/classify-module-path.test.ts +104 -0
  25. package/src/module/packaging/extract.ts +31 -3
  26. package/src/module/packaging/generated-plane.test.ts +79 -0
  27. package/src/module/packaging/generated-plane.ts +134 -0
  28. package/src/module/packaging/host-plane.test.ts +132 -0
  29. package/src/module/packaging/host-plane.ts +135 -0
  30. package/src/module/packaging/package-rules.ts +62 -0
  31. package/src/policy/module-script-scan.test.ts +164 -0
  32. package/src/policy/module-script-scan.ts +143 -0
  33. package/src/policy/no-hand-built-ssh.test.ts +22 -62
  34. package/src/services/audit/cli-version.test.ts +6 -2
  35. package/src/services/audit/cli-version.ts +20 -6
  36. package/src/services/audit/detect-without-converge.test.ts +91 -0
  37. package/src/services/audit/detect-without-converge.ts +81 -0
  38. package/src/services/audit/disk-space.test.ts +5 -2
  39. package/src/services/audit/disk-space.ts +5 -3
  40. package/src/services/audit/health.test.ts +39 -0
  41. package/src/services/audit/index.test.ts +7 -1
  42. package/src/services/audit/index.ts +12 -0
  43. package/src/services/audit/module-integrity.test.ts +146 -0
  44. package/src/services/audit/module-integrity.ts +113 -0
  45. package/src/services/audit/module-versions.ts +4 -1
  46. package/src/services/audit/schema.test.ts +7 -2
  47. package/src/services/audit/schema.ts +19 -1
  48. package/src/services/audit/terraform-plan.ts +17 -2
  49. package/src/services/audit/types.test.ts +29 -0
  50. package/src/services/audit/types.ts +30 -4
  51. package/src/services/module-deploy.ts +21 -0
  52. package/src/services/restore-from-file.ts +4 -0
  53. package/src/services/update/orchestrator.test.ts +2 -0
  54. package/src/templates/copy-role-files.test.ts +69 -0
  55. package/src/templates/generator.ts +23 -1
@@ -29,7 +29,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
29
29
  - **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.
30
30
  - **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`.
31
31
  - **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 holds networks that are not placement zones (`network.control-plane-vpn.subnet`, which `wireguard` requires and reads). Injected into the firewall capability as a LIVE reader (`declaredNetworks`) — that liveness was a mitigation for values written mid-run by a module hook, which `network-declaration` removes; see that spec before assuming a snapshot is still unsafe.
32
- - **Network requirement + ensure (celilo owns the namespace)** — `apps/celilo/src/services/network-ensure.ts` (`ensureRequiredNetworks`), `NetworkRequirementSchema` / `getRequiredNetworkNames` in `apps/celilo/src/manifest/schema.ts`. A module declares `requires.networks: [{name}]` — a NAME, never a value; the schema is `.strict()` so a `subnet:` on the requirement is rejected with a message saying why. The deploy calls `ensureRequiredNetworks` in its interview phase, BEFORE generation and before any hook, and asks over the generic bus interview (`askText`, so it is answerable headless) for anything undefined. Which attributes a network has is celilo's answer, taken from `apps/celilo/schemas/system_config.json`: that file declares `network.<n>.gateway` for the routed segments and omits it for `control-plane-vpn`, so a gateway is never asked for a network that has none. Well-known names carry a `suggested` range there — deliberately NOT `default`, which `getDefaultConfiguration()` would seed at `system init`. Spec: `openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md`.
32
+ - **Network requirement + ensure (celilo owns the namespace)** — `apps/celilo/src/services/network-ensure.ts` (`ensureRequiredNetworks`), `NetworkRequirementSchema` / `getRequiredNetworkNames` in `apps/celilo/src/manifest/schema.ts`. A module declares `requires.networks: [{name}]` — a NAME, never a value; the schema is `.strict()` so a `subnet:` on the requirement is rejected with a message saying why. The deploy calls `ensureRequiredNetworks` in its interview phase, BEFORE generation and before any hook, and asks over the generic bus interview (`askText`, so it is answerable headless) for anything undefined. Which attributes a network has is celilo's answer, taken from `apps/celilo/schemas/system_config.json`: that file declares `network.<n>.gateway` for the routed segments and omits it for `control-plane-vpn`, so a gateway is never asked for a network that has none. Well-known names carry a `suggested` range there — deliberately NOT `default`, which `getDefaultConfiguration()` would seed at `system init`. Spec: `openspec/specs/network-declaration/spec.md`.
33
33
  - **The network write path (closed) + celilo's own discovery** — `celilo system apply-config` (`apps/celilo/src/cli/commands/system-apply-config.ts`) REFUSES the whole `network.` namespace, `network.bridge` excepted (a Proxmox bridge name is not addressing, and it is the one network key with a schema default). That is the automation surface a module hook shells out to, so closing it there is what makes "networks are celilo's" an authority rather than a convention every module has to remember. The refusal names the alternative — declare the network, read it with `$system:` — because a bare rejection sends a module author hunting for a typo. The one write that legitimately needed the surface moved INTO celilo: `celilo system discover-network` (`apps/celilo/src/services/network-discovery.ts`, `cli/commands/system-discover-network.ts`) parses the box's own `ip route` and records `network.internal.*` — or `network.secure-mgmt.*` when the box is off the internal LAN (#300). `celilo-mgmt` calls it and decides nothing; it used to parse and write this itself. Idempotent and never overwrites addressing already set. Recurrence gate: `test-integration/module/no-module-writes-networks.test.ts`.
34
34
  - **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.
35
35
  - **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.
@@ -96,7 +96,8 @@ Module hooks reach a remote box ONLY through these typed primitives
96
96
  (exported from `packages/capabilities/src/index.ts`).
97
97
 
98
98
  - **`remoteExec`** — the ONE ssh seam (`ssh <user>@<target> <cmd>`); everything else builds on it. `user`/`port`/`identityFile` on the target default to `root`/22/the agent key, so a fleet call is unchanged; an OFF-FLEET account (`external_web`) sets them.
99
- - **`probe`** — read-only health checks: `systemd` / `http` / `command`.
99
+ - **`probe`** — read-only health checks over SSH: `systemd` / `command`. It carried a third `http` kind that SSHed in and ran `curl`; the production `ubuntu-22.04-standard` LXC ships none, and a missing binary was indistinguishable from a dead service. Deleted — `probeHttp` replaces it (`openspec/changes/probe-http-from-management/`).
100
+ - **`probeHttp`** — HTTP health check run FROM the management server over `fetch`; nothing on the target. Takes `port` + `path`, never a URL, and builds the address from the target's `ipv4_address` — so a check cannot name `localhost` and silently probe celilo-mgr's own port. Redirects observed, not followed (caddy's 308 is a healthy answer). Typed failure: `'unreachable'` vs `'status'`.
100
101
  - **`serviceCtl`** — systemctl start/stop/restart/reload/enable/disable.
101
102
  - **`runAppCommand` / `runAppCommandWithSecret`** — escape-hatch on-box command; the secret variant feeds the secret on **stdin** (`$SECRET`), never argv.
102
103
  - **`streamBackup` / `streamRestore` / `fetchFile` / `pushFile`** — binary-safe streaming via local shell redirect/pipe.
@@ -181,7 +182,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
181
182
  - **Inbound** (`reconcileAspectsForSystems`): every approved aspect applied to systems that have just come into existence. Called from `module-deploy.ts` between `waitForSSH` and `executeAnsible` (so a module's playbook and `on_install` see a correctly configured host) and from `machine-add.ts`. Eligibility is `applicable_zones` + approval; the aspect's `triggers` list is deliberately NOT consulted, so convergence is not opt-in per manifest. A failure here IS fatal to the deploy that created the system — the aspect is a prerequisite of that host — while a failure on `machine add` is only a warning. PAUSED providers are skipped, which is the escape hatch for a wedged aspect. Nothing is rolled back: rows, guest and IPAM allocation persist and a re-run converges on the same system. Fixes celilo#902, where a system provisioned after its provider deployed silently never received the aspect. See `openspec/changes/aspect-fanout-new-systems/`.
182
183
  - **Coverage verification** — `verifyAspectCoverage` + `celilo system doctor --deep [--fix]`. Answers "is any system missing an aspect its zone entitles it to" WITHOUT stored state: the entitled set is `planAspectFanOut` itself, and coverage is measured by evaluating the role against the host in Ansible check mode (`executeAnsible({check:true})` + `parseAnsibleRecap`). Three outcomes, not two — `changed=0,skipped=0` applied, `changed>0` missing, **`skipped>0` unknown**, because check mode SKIPS a task it cannot evaluate and a `command`/`shell` role would otherwise report clean having never run.
183
184
  - **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.
184
- - **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/`.
185
+ - **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/specs/module-pause/spec.md`.
185
186
  - **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).
186
187
  - **Consumer-removal cleanup (every provider is told)** — `apps/celilo/src/services/consumer-cleanup.ts` — pure `planConsumerCleanup` + `loadConsumerCleanupPlan` + `runConsumerCleanup`, called from `apps/celilo/src/cli/commands/module-remove.ts` after `on_uninstall` and before `terraform destroy`. A capability is two-sided: the consumer asks, the provider mints something in ITS world (a caddy site block, a DNAT rule, an OIDC client at authentik, a registered CI runner), and removal only ever touched one side — the FK cascade dropped celilo's row, so the provider's next converge had no way to learn the thing existed. This dispatches the `on_consumer_removed` hook to every provider of every capability the departing module declared under `requires` OR `optional` (the same relation `remove-guard.ts` counts as a dependency edge), **once per provider** rather than once per capability, sorted by provider id. The hook receives one input, `consumer`, and NOTHING else: a provider that cannot answer "what do I hold for this module" without being told has a different defect — the consumer's id was never recorded at mint time. It replaces `services/web-route-cleanup.ts`, which did the same job for exactly one capability, by name, from core. Semantics that are easy to get backwards: **a failed withdrawal never blocks the removal** — the consumer goes and the failing PROVIDER is marked `ERROR` with the departing consumer named in `error_message` (surfaced as a `blocked` finding by `services/audit/undeployed-modules.ts`), because the hook is a full converge and after a failure the provider's state is unknown rather than "one thing missed". **Dispatch continues past a failure**, so one broken provider cannot leave the others holding state. A PAUSED provider is skipped with a warning naming what it keeps (`run-named-hook.ts` refuses non-lifecycle hooks on a paused module, and `on_consumer_removed` must NOT join `LIFECYCLE_HOOKS`), and a never-deployed one is skipped silently. Providers implementing it: caddy, caddy-internal, iptables, greenwave, axon, authentik, forgejo, generic-cpanel-hosting-provider. See `openspec/changes/consumer-removal-cleanup/`.
187
188
  - **Firewall registry ownership** — `apps/celilo/src/services/port-forwards.ts` + `apps/celilo/src/services/trusted-sources.ts`. Both stores are bound to the CONSUMING module and stamp `registered_by` themselves; a caller cannot supply it, so a registration is never attributed to the wrong module. Both writes are **declarative**: `replace()` states a consumer's COMPLETE set for a target, so a port or subnet it previously registered and now omits is withdrawn (celilo#855 — before this, a module that exposed `:8080` and redeployed exposing `:9090` kept both, forever). The owner is IN both unique indexes, not merely beside them: two consumers wanting the same forward are two ROWS, so one leaving cannot delete a rule the other still needs; `modules/iptables/scripts/ruleset-renderer.ts` dedupes on the rule tuple so the pair renders once. Neither column is a FK, so `runConsumerCleanup` deletes these rows explicitly after every provider has converged without them.
@@ -226,13 +227,26 @@ to look, which is how a forgotten pause actually gets found.
226
227
 
227
228
  ## Packaging, registry & publish
228
229
 
229
- - **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts`.
230
+ - **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts`, `package-rules.ts`, `generated-plane.ts`, `host-plane.ts` (see **Module integrity** below).
230
231
  - **Publish driver** — `scripts/publish.ts` shims to `apps/celilo/src/cli/commands/publish/` (workspace npm packages via `bun publish` + module registry; preflight stale-version/stale-manifest gates).
231
232
  - **Registry publish-token admin (append-safe)** — `apps/celilo/src/cli/commands/registry-token.ts` — `celilo registry token add/rm <token>` read-modify-write the celilo-registry `publish_tokens` secret (newline-separated bootstrap/admin list). Avoids the `module secret set` full-overwrite that clobbered other holders. Runs on-mgr where the master key lives; runtime-minted scoped tokens are handled separately by the registry-server.
232
233
  - **Contributor identity tokens (idp-issued, per-user)** — `apps/celilo/src/cli/commands/token.ts` — `celilo token obtain|list|revoke` mints/lists/revokes per-user API tokens via the `idp` capability (`create_token`/`list_tokens`/`revoke_token` on authentik). A module contributor authenticates publishes AS THEMSELVES (SECURE_MODULE_PUBLISH.md §6) — no admin/shared token on their machine; the token feeds `celilo author init`. Shown once at mint; the idp stores it hashed, celilo persists nothing. Runs on-mgr where the idp provider lives. Distinct from `registry token add/rm` (raw bootstrap list, different trust model).
233
234
  - **Registry token verification (opaque + idp introspection)** — `packages/registry-server/src/auth.ts` (`TokenAuth` — opaque SHA-256 publish tokens, admin/per-package scope) + `packages/registry-server/src/introspection.ts` (`IntrospectionVerifier` — RFC 7662 verify-bridge, SECURE_MODULE_PUBLISH.md §5[D-A]). `authorizePackage()` in `server.ts` tries the opaque set first (unchanged), then, for a token unknown to it, `identify()`s it via the idp introspection endpoint using the registry's confidential OIDC client creds (`OIDC_INTROSPECTION_ENDPOINT`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, provisioned on install — ce-7aa), reading `{active, sub, groups, exp}`. Fails CLOSED on any introspection error; never logs tokens/secrets. Instant revocation: revoke at the idp → next publish sees `active:false` → 401.
234
235
  - **Registry module-owner table (hybrid group + owner authz — ce-1ch, D-C)** — `packages/registry-server/src/module-owner-store.ts` (`ModuleOwnerStore` — JSON-persisted `{moduleName, ownerSub, claimedAt, sourceGroup}`, `REGISTRY_OWNERS_FILE`/`dataDir/module-owners.json`). The verified `groups` claim gates *whether* an identity may publish (`REGISTRY_ADMIN_GROUP`→publish/reassign anything; `REGISTRY_PUBLISHER_GROUP`, default `celilo-authors`, configurable→claim+publish owned); the owner table gates *which names*. First-publish-claims: the first verified publisher of an unclaimed name owns it; a *different* publisher is then DENIED (confused-deputy defense — Author-A cannot publish Author-B's module). Admin HTTP endpoints `GET /api/v1/modules/owners`, `GET|POST /api/v1/modules/owners/{name}` (reassign). Operator front door: `celilo registry owner list|show|set` (`apps/celilo/src/cli/commands/registry-owner.ts`), admin token resolved from the local `publish_tokens` bootstrap list.
235
236
 
237
+ ## Module integrity (the four places a module exists)
238
+
239
+ A module exists in four places at once, and celilo verifies the correspondence
240
+ between them. Design: `openspec/changes/module-integrity-rigor/design.md`.
241
+
242
+ - **The one classifier** — `apps/celilo/src/module/packaging/package-rules.ts` — `classifyModulePath(relPath)` returns `package` / `derived` / `unknown` and is the SINGLE answer to what belongs to a module. `build.ts#shouldExclude`, `extract.ts#scanDirectory`, `import.ts#copyModuleFiles`, `module-update.ts` and `audit.ts` all route through it. Composes with, and does not restate, `includeNodeModulesPath` (ISS-0046).
243
+ - **The versioned baseline** — `moduleIntegrity` in `apps/celilo/src/db/schema.ts` — checksums PLUS the `version` they describe. Upserted by `module import` and written by `module-update.ts#updateOne`, so it tracks the installed version instead of freezing at first import. `version` is nullable: NULL means "written before celilo stamped versions", which verify reports rather than papers over.
244
+ - **Plane 1, installed tree vs baseline** — `apps/celilo/src/module/packaging/audit.ts` — `auditModule(moduleId, db, { deep })`. The entry point for `module verify`.
245
+ - **Plane 2, generated project vs installed tree** — `apps/celilo/src/module/packaging/generated-plane.ts` — `compareVerbatimRoleAssets` (pure), `readVerbatimRoleAssets`, `refuseIfGeneratedIsStale`. Only `ansible/roles/<role>/files/**` has a meaningful expected digest; Ansible templates the rest. Wired into `module verify` and into `module-deploy.ts` as a pre-flight that refuses BEFORE contacting any system (celilo#925).
246
+ - **Plane 3, host vs generated project** — `apps/celilo/src/module/packaging/host-plane.ts` — `verifyModuleOnHosts` runs the generated playbook through `executeAnsible(..., { check: true })` and classifies each `PLAY RECAP` line as `converged` / `drift` / `unmeasured`. One SSH per system, so `--deep` only. A module opts out with `verify: { deep: false, reason: … }`; the reason is required and is printed.
247
+ - **Surfaces** — `celilo module verify <id> [--deep] [--json]` (`apps/celilo/src/cli/commands/module-verify.ts`), the `module_integrity` audit category (`apps/celilo/src/services/audit/module-integrity.ts`), and `system doctor`'s fleet section. Same implementation behind all three.
248
+ - **Dead convergence machinery** — `apps/celilo/src/services/audit/detect-without-converge.ts` — the `detect_without_converge` category reports a module declaring a `reconcile_*` / `refresh_registrations` / `reassert_dhcp_dns` hook that no subscription ever fires (celilo#934).
249
+
236
250
  ## Alerting & notifications
237
251
 
238
252
  Observation and delivery: monitors run checks on a schedule, alerts hold what
@@ -64,12 +64,25 @@ that, not a primitive. The primitives are for the module's *own* box.
64
64
  ### `probe(target, check, runner?, opts?) → { healthy, detail }`
65
65
  Read-only health check. Never mutates. `check` is one of:
66
66
  - `{ kind: 'systemd', unit }` — unit is `active`.
67
- - `{ kind: 'http', url, expectStatus?, headers? }` — curl runs **on the box**, so
68
- it doesn't depend on Caddy/public DNS. `expectStatus` defaults to 200; pass a
69
- list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
70
67
  - `{ kind: 'command', command, expectStdoutIncludes? }`.
68
+
69
+ For HTTP use `probeHttp`, not `probe` — see below.
70
+ ```ts
71
+ probe(sys, { kind: 'systemd', unit: 'caddy' }, run);
72
+ ```
73
+
74
+ ### `probeHttp(target, { port, path?, expectStatus?, headers?, timeoutMs? }) → Promise<{ healthy, detail, failure? }>`
75
+ HTTP health check, run **from the management server** over `fetch`. Nothing runs
76
+ on the target — no SSH, no curl (the production LXC image ships none, and a
77
+ missing binary used to be indistinguishable from a dead service). There is
78
+ deliberately **no `url`**: you give a port and a path, and the primitive builds
79
+ the address from the target's `ipv4_address`, so a check cannot name `localhost`
80
+ and end up probing celilo-mgr itself. `expectStatus` defaults to 200; pass a
81
+ list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
82
+ Redirects are **observed, not followed**. On failure, `failure` is
83
+ `'unreachable'` (could not connect) or `'status'` (answered with the wrong code).
71
84
  ```ts
72
- probe(sys, { kind: 'http', url: 'http://localhost', headers: { Host: fqdn }, expectStatus: [200, 308] }, run);
85
+ await probeHttp(sys, { port: 80, headers: { Host: fqdn }, expectStatus: [200, 308] });
73
86
  ```
74
87
 
75
88
  ### `serviceCtl(target, unit, action, runner?, opts?) → RunResult`
@@ -97,11 +110,11 @@ applyRenderedConfig({
97
110
 
98
111
  ### `waitFor(predicate, { attempts?, intervalMs?, onAttempt? }) → Promise<boolean>`
99
112
  Poll an async predicate until true or exhausted (default 30 × 5s). A combinator —
100
- it never touches the remote; wrap a `probe`/`runAppCommand` thunk. Use
113
+ it never touches the remote; wrap a `probe`/`probeHttp`/`runAppCommand` thunk. Use
101
114
  `onAttempt` to emit a heartbeat so a long wait doesn't trip the hook idle-timeout.
102
115
  Never `sleep`.
103
116
  ```ts
104
- await waitFor(() => probe(sys, { kind: 'http', url }, run).healthy,
117
+ await waitFor(async () => (await probeHttp(sys, { port: 3000 })).healthy,
105
118
  { onAttempt: (n) => logger.info(`waiting… ${n * 5}s`) });
106
119
  ```
107
120
 
@@ -0,0 +1,20 @@
1
+ -- The integrity baseline records WHICH VERSION its checksums describe
2
+ -- (openspec/changes/module-integrity-rigor, D1).
3
+ --
4
+ -- Without it `module verify` cannot distinguish "the files are old" from "the
5
+ -- checksums are old", and it reported the same violations either way. The row
6
+ -- was written once, by a plain INSERT on a UNIQUE column inside a try/catch
7
+ -- that only warned, and `module update` never touched the table at all — so a
8
+ -- module imported once and updated ten times still carried its first import's
9
+ -- checksums, and every file that had legitimately changed since read as
10
+ -- [MODIFIED]. That was the whole of the manifest.yml / README.md / scripts/*
11
+ -- block in the fleet's 47- and 25-violation outputs.
12
+ --
13
+ -- NULLABLE on purpose, and this is the Rule 3.4 exception rather than a missing
14
+ -- default: NULL means "written before celilo stamped versions", which is a
15
+ -- fact verify must be able to state. A default of '' or of the module's current
16
+ -- version would fabricate a correspondence nobody measured — exactly the
17
+ -- failure this change exists to remove. Existing rows keep NULL until the next
18
+ -- `module import` or `module update` rewrites them.
19
+
20
+ ALTER TABLE `module_integrity` ADD `version` text;
@@ -183,6 +183,13 @@
183
183
  "when": 1784100000000,
184
184
  "tag": "0025_port_forward_owner",
185
185
  "breakpoints": true
186
+ },
187
+ {
188
+ "idx": 26,
189
+ "version": "6",
190
+ "when": 1784200000000,
191
+ "tag": "0026_module_integrity_version",
192
+ "breakpoints": true
186
193
  }
187
194
  ]
188
- }
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^2.2.0",
61
+ "@celilo/capabilities": "^2.3.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.9.0",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -13,9 +13,12 @@
13
13
  import type { CommandResult } from '../types';
14
14
  import { moduleVerify } from './module-verify';
15
15
 
16
- export async function moduleAudit(args: string[]): Promise<CommandResult> {
16
+ export async function moduleAudit(
17
+ args: string[],
18
+ flags: Record<string, string | boolean> = {},
19
+ ): Promise<CommandResult> {
17
20
  process.stderr.write(
18
21
  'warning: `celilo module audit` is deprecated; use `celilo module verify` instead.\n',
19
22
  );
20
- return moduleVerify(args);
23
+ return moduleVerify(args, flags);
21
24
  }
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
10
+ import { join, relative } from 'node:path';
11
11
  import { eq } from 'drizzle-orm';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
@@ -321,3 +321,91 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
321
321
  expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9');
322
322
  });
323
323
  });
324
+
325
+ /**
326
+ * Every file under `root`, keyed by relative path, valued by its exact bytes.
327
+ * Compared whole so a TRUNCATION shows up — an existence check would not.
328
+ */
329
+ function snapshot(root: string, dir = root): Record<string, string> {
330
+ const files: Record<string, string> = {};
331
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
332
+ const full = join(dir, entry.name);
333
+ if (entry.isDirectory()) Object.assign(files, snapshot(root, full));
334
+ else if (entry.isFile()) files[relative(root, full)] = readFileSync(full, 'utf-8');
335
+ }
336
+ return files;
337
+ }
338
+
339
+ describe("updateOne — pointed at the module's own installed path", () => {
340
+ let tempDir: string;
341
+ let installedDir: string;
342
+ let db: DbClient;
343
+
344
+ beforeEach(() => {
345
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-selfpath-'));
346
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
347
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
348
+
349
+ installedDir = join(tempDir, 'installed', 'selfmod');
350
+ mkdirSync(installedDir, { recursive: true });
351
+ writeFileSync(
352
+ join(installedDir, 'manifest.yml'),
353
+ `celilo_contract: "1.0"
354
+ id: selfmod
355
+ name: Self Module
356
+ version: 1.0.0
357
+ description: fixture
358
+ `,
359
+ );
360
+
361
+ db = getDb();
362
+ db.insert(modules)
363
+ .values({
364
+ id: 'selfmod',
365
+ name: 'Self Module',
366
+ sourcePath: installedDir,
367
+ version: '1.0.0',
368
+ manifestData: {
369
+ celilo_contract: '1.0',
370
+ id: 'selfmod',
371
+ name: 'Self Module',
372
+ version: '1.0.0',
373
+ },
374
+ })
375
+ .run();
376
+ });
377
+
378
+ afterEach(() => {
379
+ rmSync(tempDir, { recursive: true, force: true });
380
+ process.env.CELILO_DB_PATH = undefined;
381
+ process.env.CELILO_ORIGINAL_CWD = undefined;
382
+ });
383
+
384
+ test('refuses by name, and writes NOTHING', async () => {
385
+ // A data-loss guard, not a UX nicety. On celilo-mgr on 2026-08-19,
386
+ // `celilo module update /var/celilo/modules/wireguard-manager` threw
387
+ // `EINVAL: copy_file_range` and TRUNCATED
388
+ // `ansible/roles/wireguard-manager/handlers/main.yml` in the module source
389
+ // on the way down. The crash was mid-copy, so the tree was left damaged
390
+ // rather than untouched. It stayed invisible for nine hours because
391
+ // `generated/` still held a good copy; the next generate propagated the
392
+ // truncation, and the deploy after that died with "The requested handler
393
+ // 'Restart wireguard-manager' was not found". App health, ingress and
394
+ // tunnel peers were all green throughout — none of them can see a
395
+ // truncated Ansible handler.
396
+ //
397
+ // So this compares the whole tree byte for byte rather than checking that
398
+ // a file still exists. "It threw" was already true of the behaviour that
399
+ // caused the damage, and an existence check cannot see a truncation.
400
+ const before = snapshot(installedDir);
401
+
402
+ const result = await updateOne(installedDir, db, {}, { quiet: true });
403
+
404
+ expect(result.status).toBe('failed');
405
+ if (result.status !== 'failed') return;
406
+ expect(result.error).toContain('IS the installed copy of selfmod');
407
+ expect(result.error).not.toContain('EINVAL');
408
+
409
+ expect(snapshot(installedDir)).toEqual(before);
410
+ });
411
+ });
@@ -9,18 +9,20 @@
9
9
  * The module ID is read from the manifest at the given path.
10
10
  */
11
11
 
12
- import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
12
+ import { cpSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
13
13
  import { unlink } from 'node:fs/promises';
14
14
  import { tmpdir } from 'node:os';
15
- import { join, resolve } from 'node:path';
15
+ import { join, relative, resolve } from 'node:path';
16
16
  import { eq } from 'drizzle-orm';
17
17
  import { parse as parseYaml } from 'yaml';
18
18
  import { registerModuleCapabilities } from '../../capabilities/registration';
19
19
  import { getDb } from '../../db/client';
20
- import { capabilities, modules } from '../../db/schema';
20
+ import { capabilities, moduleIntegrity, modules } from '../../db/schema';
21
21
  import { ModuleManifestSchema } from '../../manifest/schema';
22
22
  import type { ModuleManifest } from '../../manifest/schema';
23
+ import { computeChecksums } from '../../module/packaging/build';
23
24
  import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
25
+ import { classifyModulePath } from '../../module/packaging/package-rules';
24
26
  import { RegistryClient } from '../../registry/client';
25
27
  import { askConfirm, withInterviewSession } from '../../services/bus-interview';
26
28
  import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors';
@@ -152,6 +154,30 @@ export async function fetchAndUpdate(
152
154
  /**
153
155
  * Upgrade a single module from a source path
154
156
  */
157
+ /**
158
+ * Every file under an installed module that an update is entitled to remove:
159
+ * `package` (the new version decides whether it survives) and `unknown` (no
160
+ * version ever shipped it, so it self-heals a tree an older update polluted).
161
+ *
162
+ * `derived` is neither walked nor removed. It is celilo's or the operator's —
163
+ * `generated/`, the hook runtime closure, `screenshots/`, `cookies.json` — and
164
+ * `generated/` alone carries terraform state and provider binaries.
165
+ */
166
+ function listPrunableFiles(root: string, dir = root): string[] {
167
+ const found: string[] = [];
168
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
169
+ const full = join(dir, entry.name);
170
+ const rel = relative(root, full);
171
+ if (classifyModulePath(rel) === 'derived') continue;
172
+ if (entry.isDirectory()) {
173
+ found.push(...listPrunableFiles(root, full));
174
+ } else if (entry.isFile()) {
175
+ found.push(rel);
176
+ }
177
+ }
178
+ return found;
179
+ }
180
+
155
181
  export async function updateOne(
156
182
  sourcePath: string,
157
183
  db: ReturnType<typeof getDb>,
@@ -241,6 +267,20 @@ export async function updateOne(
241
267
  };
242
268
  }
243
269
 
270
+ // Pointed at the module's OWN install, `updateOne` used to copy every file
271
+ // onto itself and die inside `cpSync` with `EINVAL: copy_file_range` — an
272
+ // error that names a syscall and not the mistake. Worse since D1: the copy
273
+ // dies partway, after the prune has already run. Refuse by name instead
274
+ // (D11).
275
+ if (resolve(actualPath) === resolve(module.sourcePath)) {
276
+ if (tempDir) await cleanupTempDir(tempDir);
277
+ return {
278
+ status: 'failed',
279
+ moduleId,
280
+ error: `'${sourcePath}' IS the installed copy of ${moduleId}. There is nothing to update it from. Point 'module update' at the module's source tree or a .netapp, or run 'celilo module upgrade ${moduleId}' to take the registry's version.`,
281
+ };
282
+ }
283
+
244
284
  // Old version comes from the DB so we capture whatever was last
245
285
  // recorded (which IS the registry-versioned form, e.g. "1.0.0+5",
246
286
  // for registry-driven installs/upgrades).
@@ -257,18 +297,84 @@ export async function updateOne(
257
297
  const installedPath = module.sourcePath;
258
298
  const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
259
299
 
260
- const skipDirs = new Set(['.git', 'node_modules', '.next', '.cache']);
300
+ // Route the copy through the one classifier, the way `module import` does.
301
+ // `updateOne` used to copy the source tree wholesale, so updating from a
302
+ // directory planted `e2e/`, `*.test.ts` and `scripts/tsconfig.json` in the
303
+ // install — files no package ships and no target runs. Import never did,
304
+ // because a directory import goes through the packager; update is the path
305
+ // that skipped it.
261
306
  const entries = readdirSync(actualPath);
262
307
  for (const entry of entries) {
263
- if (preserveDirs.has(entry) || skipDirs.has(entry)) continue;
308
+ if (preserveDirs.has(entry)) continue;
264
309
  const src = join(actualPath, entry);
265
310
  const dest = join(installedPath, entry);
266
- cpSync(src, dest, { recursive: true, force: true });
311
+ cpSync(src, dest, {
312
+ recursive: true,
313
+ force: true,
314
+ filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown',
315
+ });
267
316
  }
268
317
 
318
+ // The integrity baseline for the version just installed. Prefer the package's
319
+ // own signed `checksums.json`; a directory update has none, so compute over
320
+ // the source we just copied. Read before the temp dir goes away.
321
+ const packagedChecksumsPath = join(actualPath, 'checksums.json');
322
+ const packagedSignaturePath = join(actualPath, 'signature.sig');
323
+ let baselineChecksums: Record<string, string>;
324
+ if (existsSync(packagedChecksumsPath)) {
325
+ const parsed = JSON.parse(readFileSync(packagedChecksumsPath, 'utf-8')) as {
326
+ files?: Record<string, string>;
327
+ };
328
+ baselineChecksums = parsed.files ?? {};
329
+ } else {
330
+ baselineChecksums = (await computeChecksums(actualPath)).files;
331
+ }
332
+ const packagedSignature = existsSync(packagedSignaturePath)
333
+ ? readFileSync(packagedSignaturePath, 'utf-8').trim()
334
+ : null;
335
+
269
336
  // Clean up temp dir if we extracted a .netapp
270
337
  if (tempDir) await cleanupTempDir(tempDir);
271
338
 
339
+ // Remove what the new version dropped. `updateOne` only ever overlaid files,
340
+ // so a hook script deleted in 1.1.0 stayed on the box and stayed runnable —
341
+ // code celilo no longer believes it has installed, which is the same class of
342
+ // lie as celilo#925 pointing the other way. Only `package`-class paths are
343
+ // pruned: `generated/`, the hook runtime closure, `screenshots/` and
344
+ // `cookies.json` are celilo's or the operator's, and survive an update by
345
+ // design.
346
+ const survivingPaths = new Set(
347
+ Object.keys(baselineChecksums).filter((p) => classifyModulePath(p) === 'package'),
348
+ );
349
+ for (const relPath of listPrunableFiles(installedPath)) {
350
+ if (!survivingPaths.has(relPath)) {
351
+ rmSync(join(installedPath, relPath));
352
+ }
353
+ }
354
+
355
+ // Record it. `updateOne` never touched this table, so the baseline stayed
356
+ // frozen at the module's FIRST import no matter how many times it was
357
+ // updated. That is why `module verify` reported the same violations whether
358
+ // the files were old or the checksums were old, and could not answer the one
359
+ // question that matters (celilo#925).
360
+ db.insert(moduleIntegrity)
361
+ .values({
362
+ moduleId,
363
+ checksums: baselineChecksums,
364
+ version: newVersion,
365
+ signature: packagedSignature,
366
+ })
367
+ .onConflictDoUpdate({
368
+ target: moduleIntegrity.moduleId,
369
+ set: {
370
+ checksums: baselineChecksums,
371
+ version: newVersion,
372
+ signature: packagedSignature,
373
+ updatedAt: new Date(),
374
+ },
375
+ })
376
+ .run();
377
+
272
378
  // Update manifest in database. We persist the display version (with
273
379
  // +N when known) so subsequent `module list` / `module update` calls
274
380
  // see the same version string the registry reported.
@@ -1,53 +1,117 @@
1
1
  import { auditModule } from '../../module/packaging/audit';
2
+ import type { IntegrityViolation } from '../../module/packaging/extract';
3
+ import { hasFlag } from '../parser';
2
4
  import type { CommandResult } from '../types';
3
5
 
4
6
  /**
5
- * Verify module integrity (signature + checksums).
7
+ * Verify module integrity across the three planes a module lives in.
6
8
  *
7
- * Usage: celilo module verify <module-id>
9
+ * Usage: celilo module verify <module-id> [--deep] [--json]
10
+ *
11
+ * installed tree vs baseline did anything change the files since install?
12
+ * generated project vs installed is what we would deploy built from it?
13
+ * host vs generated project is what is running what we generated? (--deep)
14
+ *
15
+ * The first two are local and take milliseconds. `--deep` is one SSH per
16
+ * system, so it is opt-in (openspec/changes/module-integrity-rigor, D3).
8
17
  *
9
18
  * Renamed from `module audit` per CELILO_UPDATE D11 — `audit` is now
10
19
  * reserved for system-level drift detection (`celilo system audit`).
11
20
  * The legacy `module audit` continues to work via a deprecation alias
12
21
  * (see `module-audit.ts`).
13
22
  *
14
- * Returns a CommandResult so the dispatcher controls process exit
15
- * behavior.
23
+ * Returns a CommandResult so the dispatcher controls process exit behavior.
16
24
  */
17
- export async function moduleVerify(args: string[]): Promise<CommandResult> {
25
+ export async function moduleVerify(
26
+ args: string[],
27
+ flags: Record<string, string | boolean> = {},
28
+ ): Promise<CommandResult> {
18
29
  if (args.length === 0) {
19
30
  return {
20
31
  success: false,
21
- error: 'Module ID is required\n\nUsage: celilo module verify <module-id>',
32
+ error: 'Module ID is required\n\nUsage: celilo module verify <module-id> [--deep] [--json]',
22
33
  };
23
34
  }
24
35
 
25
36
  const moduleId = args[0];
37
+ const deep = hasFlag(flags, 'deep');
38
+ const json = hasFlag(flags, 'json');
39
+
40
+ const result = await auditModule(moduleId, undefined, { deep });
26
41
 
27
- const result = await auditModule(moduleId);
42
+ if (json) {
43
+ // The whole point of D9: one call answers "is the installed tree the
44
+ // version celilo thinks it is", with the digests on both sides, so nobody
45
+ // needs a shell on celilo-mgr to settle a celilo#925-shaped question.
46
+ const payload = {
47
+ moduleId,
48
+ deep,
49
+ ok: result.success && !result.error,
50
+ error: result.error ?? null,
51
+ moduleVersion: result.moduleVersion ?? null,
52
+ baselineVersion: result.baselineVersion ?? null,
53
+ violations: result.violations.map((v) => ({
54
+ type: v.type,
55
+ path: v.path,
56
+ message: v.message,
57
+ expectedDigest: v.expectedDigest ?? null,
58
+ actualDigest: v.actualDigest ?? null,
59
+ })),
60
+ hosts: result.hostPlane?.findings ?? [],
61
+ deepOptOut: result.hostPlane?.optedOut ?? null,
62
+ };
63
+ const text = JSON.stringify(payload, null, 2);
64
+ return payload.ok ? { success: true, message: text } : { success: false, error: text };
65
+ }
28
66
 
29
67
  if (result.error) {
30
68
  return { success: false, error: result.error };
31
69
  }
32
70
 
71
+ const lines: string[] = [];
72
+
73
+ // An opt-out nobody sees is a check that quietly disappeared, so it prints
74
+ // whether the module is clean or not (task 6.3).
75
+ if (result.hostPlane?.optedOut) {
76
+ lines.push(
77
+ ` ⚠ [DEEP SKIPPED] This module opts out of the host check: ${result.hostPlane.optedOut.reason}`,
78
+ );
79
+ }
80
+ for (const finding of result.hostPlane?.findings ?? []) {
81
+ if (finding.state === 'converged') {
82
+ lines.push(` ✓ [HOST] ${finding.hostname}: running what celilo generated`);
83
+ } else {
84
+ const tag = finding.state === 'drift' ? 'HOST-DRIFT' : 'HOST-UNMEASURED';
85
+ lines.push(
86
+ ` ${finding.state === 'drift' ? '✗' : '⚠'} [${tag}] ${finding.hostname}: ${finding.detail}`,
87
+ );
88
+ }
89
+ }
90
+
33
91
  if (result.success) {
34
92
  return {
35
93
  success: true,
36
- message: `Module '${moduleId}' passed integrity check\n No violations found.`,
94
+ message: [`Module '${moduleId}' passed integrity check`, ...lines, ' No violations found.']
95
+ .join('\n')
96
+ .trimEnd(),
37
97
  };
38
98
  }
39
99
 
40
- const violationLines = result.violations.map((v) => {
41
- const icon = v.type === 'missing' ? '⚠' : v.type === 'modified' ? '✗' : '!';
42
- return ` ${icon} [${v.type.toUpperCase()}] ${v.message}`;
43
- });
100
+ const ICONS: Record<IntegrityViolation['type'], string> = {
101
+ missing: '⚠',
102
+ modified: '✗',
103
+ extra: '!',
104
+ 'stale-baseline': '⚠',
105
+ 'stale-generated': '✗',
106
+ };
44
107
 
45
108
  return {
46
109
  success: false,
47
110
  error: [
48
111
  `Module '${moduleId}' failed integrity check`,
49
112
  ` Found ${result.violations.length} violation(s):`,
50
- ...violationLines,
113
+ ...result.violations.map((v) => ` ${ICONS[v.type]} [${v.type.toUpperCase()}] ${v.message}`),
114
+ ...lines,
51
115
  ].join('\n'),
52
116
  };
53
117
  }