@celilo/cli 1.5.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 (50) hide show
  1. package/CELILO_SUBSYSTEMS.md +16 -2
  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 +12 -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/services/audit/cli-version.test.ts +6 -2
  32. package/src/services/audit/cli-version.ts +20 -6
  33. package/src/services/audit/detect-without-converge.test.ts +91 -0
  34. package/src/services/audit/detect-without-converge.ts +81 -0
  35. package/src/services/audit/disk-space.test.ts +5 -2
  36. package/src/services/audit/disk-space.ts +5 -3
  37. package/src/services/audit/health.test.ts +39 -0
  38. package/src/services/audit/index.test.ts +7 -1
  39. package/src/services/audit/index.ts +12 -0
  40. package/src/services/audit/module-integrity.test.ts +146 -0
  41. package/src/services/audit/module-integrity.ts +113 -0
  42. package/src/services/audit/module-versions.ts +4 -1
  43. package/src/services/audit/schema.test.ts +7 -2
  44. package/src/services/audit/schema.ts +19 -1
  45. package/src/services/audit/terraform-plan.ts +17 -2
  46. package/src/services/audit/types.test.ts +29 -0
  47. package/src/services/audit/types.ts +30 -4
  48. package/src/services/module-deploy.ts +21 -0
  49. package/src/services/restore-from-file.ts +4 -0
  50. package/src/services/update/orchestrator.test.ts +2 -0
@@ -18,9 +18,14 @@ import { type BackupsAuditDeps, auditBackups } from './backups';
18
18
  import { type BrowserPinAuditDeps, auditBrowserPin } from './browser-pin';
19
19
  import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
20
20
  import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
21
+ import {
22
+ type DetectWithoutConvergeAuditDeps,
23
+ auditDetectWithoutConverge,
24
+ } from './detect-without-converge';
21
25
  import { type HealthAuditDeps, auditHealth } from './health';
22
26
  import { type MachinesReachableAuditDeps, auditMachinesReachable } from './machines-reachable';
23
27
  import { type ModuleConfigsAuditDeps, auditModuleConfigs } from './module-configs';
28
+ import { type ModuleIntegrityAuditDeps, auditModuleIntegrity } from './module-integrity';
24
29
  import { type ModuleVersionsAuditDeps, auditModuleVersions } from './module-versions';
25
30
  import { type PublicDnsAuditDeps, auditPublicDns } from './public-dns';
26
31
  import { type SchemaAuditDeps, auditSchema } from './schema';
@@ -53,6 +58,8 @@ export interface AuditDeps {
53
58
  terraformPlan: TerraformPlanAuditDeps;
54
59
  moduleVersions: ModuleVersionsAuditDeps;
55
60
  moduleConfigs: ModuleConfigsAuditDeps;
61
+ moduleIntegrity: ModuleIntegrityAuditDeps;
62
+ detectWithoutConverge: DetectWithoutConvergeAuditDeps;
56
63
  health: HealthAuditDeps;
57
64
  backups: BackupsAuditDeps;
58
65
  abandonedOperations: AbandonedOperationsAuditDeps;
@@ -107,6 +114,11 @@ export async function runAudit(
107
114
  wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
108
115
  wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
109
116
  wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
117
+ wrap('module_integrity', Promise.resolve(auditModuleIntegrity(deps.moduleIntegrity))),
118
+ wrap(
119
+ 'detect_without_converge',
120
+ Promise.resolve(auditDetectWithoutConverge(deps.detectWithoutConverge)),
121
+ ),
110
122
  wrap('health', auditHealth(deps.health)),
111
123
  wrap('backups', auditBackups(deps.backups)),
112
124
  wrap(
@@ -0,0 +1,146 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { AuditResult } from '../../module/packaging/audit';
3
+ import { auditModuleIntegrity } from './module-integrity';
4
+ import { computeVerdict } from './types';
5
+
6
+ const clean = (moduleId: string): AuditResult => ({
7
+ success: true,
8
+ moduleId,
9
+ violations: [],
10
+ moduleVersion: '1.0.0',
11
+ baselineVersion: '1.0.0',
12
+ });
13
+
14
+ describe('auditModuleIntegrity', () => {
15
+ test('a clean module produces nothing', () => {
16
+ expect(auditModuleIntegrity({ results: [clean('caddy')] })).toEqual([]);
17
+ });
18
+
19
+ test('a check that could not run is unmeasured, and the verdict is UNKNOWN', () => {
20
+ const findings = auditModuleIntegrity({
21
+ results: [
22
+ {
23
+ success: false,
24
+ moduleId: 'caddy',
25
+ violations: [],
26
+ error: "No integrity data found for module 'caddy'.",
27
+ },
28
+ ],
29
+ });
30
+ expect(findings.map((f) => f.severity)).toEqual(['unmeasured']);
31
+ expect(computeVerdict(findings)).toBe('UNKNOWN');
32
+ });
33
+
34
+ test('a stale baseline is unmeasured, and it SUPPRESSES the file findings it explains', () => {
35
+ // The 72-violation shape. One frozen row made every legitimately-changed
36
+ // file read as [MODIFIED], and printing all of them is what made `module
37
+ // verify` unreadable. The baseline is the finding; the files are its
38
+ // consequence.
39
+ const findings = auditModuleIntegrity({
40
+ results: [
41
+ {
42
+ ...clean('wireguard'),
43
+ success: false,
44
+ baselineVersion: '0.7.0',
45
+ moduleVersion: '0.8.0',
46
+ violations: [
47
+ {
48
+ type: 'stale-baseline',
49
+ path: 'checksums.json',
50
+ message: 'Baseline describes 0.7.0, module records 0.8.0.',
51
+ },
52
+ { type: 'modified', path: 'manifest.yml', message: 'Checksum mismatch: manifest.yml' },
53
+ { type: 'extra', path: 'scripts/new.ts', message: 'Unexpected file: scripts/new.ts' },
54
+ ],
55
+ },
56
+ ],
57
+ });
58
+ expect(findings).toHaveLength(1);
59
+ expect(findings[0]?.code).toBe('module_integrity_stale_baseline');
60
+ expect(findings[0]?.severity).toBe('unmeasured');
61
+ expect(findings[0]?.remediation).toBe('celilo module update wireguard');
62
+ });
63
+
64
+ test('file drift under a TRUSTWORTHY baseline is drift, and lists every file', () => {
65
+ const findings = auditModuleIntegrity({
66
+ results: [
67
+ {
68
+ ...clean('wireguard'),
69
+ success: false,
70
+ violations: [
71
+ { type: 'modified', path: 'manifest.yml', message: 'Checksum mismatch: manifest.yml' },
72
+ { type: 'extra', path: 'scripts/new.ts', message: 'Unexpected file: scripts/new.ts' },
73
+ ],
74
+ },
75
+ ],
76
+ });
77
+ expect(findings).toHaveLength(1);
78
+ expect(findings[0]?.severity).toBe('drift');
79
+ // Never truncated to the first item. celilo#951's real finding would have
80
+ // arrived at position 19 of 19 and never been printed.
81
+ expect(findings[0]?.details).toContain('manifest.yml');
82
+ expect(findings[0]?.details).toContain('scripts/new.ts');
83
+ });
84
+
85
+ test('a stale generated project is drift, and says a deploy would ship the wrong bytes', () => {
86
+ const findings = auditModuleIntegrity({
87
+ results: [
88
+ {
89
+ ...clean('wireguard-manager'),
90
+ success: false,
91
+ violations: [
92
+ {
93
+ type: 'stale-generated',
94
+ path: 'ansible/roles/vpn/files/bin',
95
+ message: 'Generated project holds different bytes',
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ });
101
+ expect(findings.map((f) => f.code)).toEqual(['module_generated_stale']);
102
+ expect(findings[0]?.severity).toBe('drift');
103
+ expect(findings[0]?.remediation).toBe('celilo module generate wireguard-manager');
104
+ });
105
+
106
+ test('host-plane results carry through with the right severity', () => {
107
+ const findings = auditModuleIntegrity({
108
+ results: [
109
+ {
110
+ ...clean('wireguard'),
111
+ hostPlane: {
112
+ findings: [
113
+ { hostname: 'a', state: 'converged' },
114
+ { hostname: 'b', state: 'drift', detail: 'would change 2 things' },
115
+ { hostname: 'c', state: 'unmeasured', detail: 'did not answer' },
116
+ ],
117
+ },
118
+ },
119
+ ],
120
+ });
121
+ expect(findings.map((f) => [f.code, f.severity])).toEqual([
122
+ ['module_host_drifted', 'drift'],
123
+ ['module_host_unmeasured', 'unmeasured'],
124
+ ]);
125
+ // An unreachable host must not let the run read as READY.
126
+ expect(computeVerdict(findings)).toBe('UNKNOWN');
127
+ });
128
+
129
+ test('every remediation is a runnable celilo command', () => {
130
+ const findings = auditModuleIntegrity({
131
+ results: [
132
+ { success: false, moduleId: 'a', violations: [], error: 'gone' },
133
+ {
134
+ ...clean('b'),
135
+ success: false,
136
+ violations: [{ type: 'modified', path: 'x', message: 'x' }],
137
+ },
138
+ ],
139
+ });
140
+ expect(findings.length).toBeGreaterThan(0);
141
+ for (const f of findings) {
142
+ expect(`${f.code}: ${f.remediation}`).toMatch(/: celilo /);
143
+ expect(f.actionable).toBe(true);
144
+ }
145
+ });
146
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Module integrity as a drift category
3
+ * (openspec/changes/module-integrity-rigor, D8).
4
+ *
5
+ * One implementation, three surfaces: `celilo module verify <id>` for one
6
+ * module, `celilo system audit` for every module, and `system doctor`'s fleet
7
+ * section. `system doctor` answers "can this box run celilo, and is the runtime
8
+ * wired up"; `system audit` answers "has anything drifted from desired state",
9
+ * and module integrity is a drift category by every structural test.
10
+ *
11
+ * Pure of I/O in the same way its siblings are: the caller does the auditing
12
+ * and passes the results in, so this file is testable without a filesystem.
13
+ */
14
+
15
+ import type { AuditResult } from '../../module/packaging/audit';
16
+ import type { DriftFinding } from './types';
17
+
18
+ export interface ModuleIntegrityAuditDeps {
19
+ /** One `auditModule` result per installed module. */
20
+ results: AuditResult[];
21
+ }
22
+
23
+ /**
24
+ * A verify failure celilo could not perform at all — no baseline row, module
25
+ * directory gone — is `unmeasured`, not `drift`. It is the difference between
26
+ * "the files moved" and "I never looked", which is the whole point of D7.
27
+ */
28
+ export function auditModuleIntegrity(deps: ModuleIntegrityAuditDeps): DriftFinding[] {
29
+ const findings: DriftFinding[] = [];
30
+
31
+ for (const result of deps.results) {
32
+ if (result.error) {
33
+ findings.push({
34
+ category: 'module_integrity',
35
+ severity: 'unmeasured',
36
+ code: 'module_integrity_unmeasured',
37
+ message: `${result.moduleId}: integrity could not be checked — ${result.error}`,
38
+ remediation: `celilo module verify ${result.moduleId}`,
39
+ actionable: true,
40
+ subject: result.moduleId,
41
+ });
42
+ continue;
43
+ }
44
+
45
+ // Ahead of the file findings it explains. When the baseline describes a
46
+ // different version, every file difference beneath it is a consequence of
47
+ // that and not independent evidence.
48
+ const stale = result.violations.filter((v) => v.type === 'stale-baseline');
49
+ for (const violation of stale) {
50
+ findings.push({
51
+ category: 'module_integrity',
52
+ severity: 'unmeasured',
53
+ code: 'module_integrity_stale_baseline',
54
+ message: `${result.moduleId}: ${violation.message}`,
55
+ remediation: `celilo module update ${result.moduleId}`,
56
+ actionable: true,
57
+ subject: result.moduleId,
58
+ });
59
+ }
60
+
61
+ const generated = result.violations.filter((v) => v.type === 'stale-generated');
62
+ if (generated.length > 0) {
63
+ findings.push({
64
+ category: 'module_integrity',
65
+ severity: 'drift',
66
+ code: 'module_generated_stale',
67
+ message: `${result.moduleId}: ${generated.length} generated asset(s) do not match the installed module — a deploy would ship the wrong bytes`,
68
+ details: generated.map((v) => ` • ${v.path}`).join('\n'),
69
+ remediation: `celilo module generate ${result.moduleId}`,
70
+ actionable: true,
71
+ subject: result.moduleId,
72
+ });
73
+ }
74
+
75
+ // File differences are only meaningful once the baseline is trustworthy.
76
+ // Reporting them under a stale baseline is what made `module verify`
77
+ // unreadable: 72 violations across two healthy modules, nearly all of them
78
+ // consequences of one frozen row.
79
+ const files = result.violations.filter(
80
+ (v) => v.type === 'modified' || v.type === 'missing' || v.type === 'extra',
81
+ );
82
+ if (files.length > 0 && stale.length === 0) {
83
+ findings.push({
84
+ category: 'module_integrity',
85
+ severity: 'drift',
86
+ code: 'module_files_drifted',
87
+ message: `${result.moduleId}: ${files.length} installed file(s) do not match the ${result.baselineVersion ?? 'recorded'} baseline`,
88
+ details: files.map((v) => ` • [${v.type}] ${v.path}`).join('\n'),
89
+ remediation: `celilo module verify ${result.moduleId} --json`,
90
+ actionable: true,
91
+ subject: result.moduleId,
92
+ });
93
+ }
94
+
95
+ for (const host of result.hostPlane?.findings ?? []) {
96
+ if (host.state === 'converged') continue;
97
+ findings.push({
98
+ category: 'module_integrity',
99
+ severity: host.state === 'drift' ? 'drift' : 'unmeasured',
100
+ code: host.state === 'drift' ? 'module_host_drifted' : 'module_host_unmeasured',
101
+ message: `${result.moduleId} on ${host.hostname}: ${host.detail}`,
102
+ remediation:
103
+ host.state === 'drift'
104
+ ? `celilo module deploy ${result.moduleId}`
105
+ : `celilo module verify ${result.moduleId} --deep`,
106
+ actionable: true,
107
+ subject: result.moduleId,
108
+ });
109
+ }
110
+ }
111
+
112
+ return findings;
113
+ }
@@ -102,7 +102,10 @@ export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promis
102
102
  if (r.status === 'rejected') {
103
103
  findings.push({
104
104
  category: 'module_versions',
105
- severity: 'drift',
105
+ // A registry that did not answer is not a version difference. Reporting
106
+ // it as `drift` said "this module is behind" when the truth was "I
107
+ // could not ask" — the ambiguity D7 exists to remove.
108
+ severity: 'unmeasured',
106
109
  code: 'module_version_lookup_failed',
107
110
  message: `${installed.id}: registry lookup failed (${String(r.reason).slice(0, 80)})`,
108
111
  remediation: 'Check network connectivity to the registry, then press R to re-audit.',
@@ -11,13 +11,18 @@ const journalWith = (tags: string[]) => () => ({
11
11
  });
12
12
 
13
13
  describe('auditSchema', () => {
14
- test('no finding when journal is missing (folder not on disk)', async () => {
14
+ test('an unreadable journal is unmeasured, not clean', async () => {
15
+ // Was `expect(result).toEqual([])`. Silence rendered as READY, and pending
16
+ // migrations are the difference between a `.deb` that installed and a
17
+ // `.deb` that works — `apt upgrade` does not run them (celilo#169).
15
18
  const result = await auditSchema({
16
19
  journal: () => null,
17
20
  applied: () => [],
18
21
  db: fakeDb,
19
22
  });
20
- expect(result).toEqual([]);
23
+ expect(result).toHaveLength(1);
24
+ expect(result[0]?.severity).toBe('unmeasured');
25
+ expect(result[0]?.code).toBe('schema_journal_unreadable');
21
26
  });
22
27
 
23
28
  test('no finding when every journal entry is applied', async () => {
@@ -78,7 +78,25 @@ export interface SchemaAuditDeps {
78
78
 
79
79
  export async function auditSchema(deps: SchemaAuditDeps): Promise<DriftFinding[]> {
80
80
  const journal = deps.journal();
81
- if (!journal) return []; // No migrations folder → nothing to compare.
81
+ if (!journal) {
82
+ // Pending migrations are the difference between a `.deb` that installed and
83
+ // a `.deb` that works — `apt upgrade` does not run them (celilo#169). With
84
+ // no journal there is nothing to compare against, and silence used to
85
+ // render that as READY.
86
+ return [
87
+ {
88
+ category: 'schema',
89
+ severity: 'unmeasured',
90
+ code: 'schema_journal_unreadable',
91
+ message:
92
+ 'No drizzle migration journal could be read, so pending schema migrations are unknown',
93
+ remediation:
94
+ 'The installed @celilo/cli is missing its `drizzle/meta/_journal.json`. Reinstall it, then re-audit. This records that the comparison did not happen, not that the schema is current.',
95
+ actionable: false,
96
+ subject: 'system',
97
+ },
98
+ ];
99
+ }
82
100
 
83
101
  // drizzle stores SHA-256 hashes (not tag names) in __drizzle_migrations,
84
102
  // so we can't intersect tags. The next-best signal is "did all the
@@ -112,8 +112,23 @@ export async function auditTerraformPlan(deps: TerraformPlanAuditDeps): Promise<
112
112
  }
113
113
 
114
114
  const summary = parsePlanSummary(result.stdout);
115
- if (!summary || (summary.add === 0 && summary.change === 0 && summary.destroy === 0)) {
116
- // Plan is empty or unparseable-but-non-error no drift.
115
+ if (!summary) {
116
+ // Terraform exited zero and celilo could not read its answer. That is not
117
+ // "no drift" — it is no measurement, and it used to be indistinguishable
118
+ // from a clean plan (D7).
119
+ findings.push({
120
+ category: 'terraform_plan',
121
+ severity: 'unmeasured',
122
+ code: 'terraform_plan_unparseable',
123
+ message: `${m.id}: terraform plan succeeded but its summary could not be parsed, so infrastructure drift is unknown`,
124
+ details: result.stdout.slice(0, 500),
125
+ remediation: `Run terraform plan in ${m.terraformDir} and read it directly. This records that the comparison did not happen, not that the infrastructure matches.`,
126
+ actionable: false,
127
+ subject: m.id,
128
+ });
129
+ continue;
130
+ }
131
+ if (summary.add === 0 && summary.change === 0 && summary.destroy === 0) {
117
132
  continue;
118
133
  }
119
134
 
@@ -17,6 +17,14 @@ const blocked: DriftFinding = {
17
17
  subject: 'lunacycle',
18
18
  };
19
19
 
20
+ const unmeasured: DriftFinding = {
21
+ category: 'module_integrity',
22
+ severity: 'unmeasured',
23
+ code: 'module_integrity_unmeasured',
24
+ message: 'wireguard-manager: the host did not answer, so nothing was measured',
25
+ subject: 'wireguard-manager',
26
+ };
27
+
20
28
  const todo: DriftFinding = {
21
29
  category: 'undeployed_modules',
22
30
  severity: 'todo',
@@ -59,4 +67,25 @@ describe('computeVerdict', () => {
59
67
  test('BLOCKED still wins over todos', () => {
60
68
  expect(computeVerdict([todo, drift, blocked])).toBe('BLOCKED');
61
69
  });
70
+
71
+ // D7. An unmeasured check is not a pass, and it is not the same statement as
72
+ // a measured difference. Before this, a category that could not reach its
73
+ // subject contributed nothing, and contributing nothing rendered as READY.
74
+ test('UNKNOWN when something could not be measured', () => {
75
+ expect(computeVerdict([unmeasured])).toBe('UNKNOWN');
76
+ });
77
+
78
+ test('an unmeasured finding never lets the verdict return READY', () => {
79
+ expect(computeVerdict([todo, unmeasured])).not.toBe('READY');
80
+ });
81
+
82
+ test('UNKNOWN outranks DRIFT — you cannot act on a diff you are not sure you have', () => {
83
+ expect(computeVerdict([drift, unmeasured])).toBe('UNKNOWN');
84
+ expect(computeVerdict([unmeasured, drift])).toBe('UNKNOWN');
85
+ });
86
+
87
+ test('BLOCKED still outranks UNKNOWN — a hard stop is still a hard stop', () => {
88
+ expect(computeVerdict([unmeasured, blocked])).toBe('BLOCKED');
89
+ expect(computeVerdict([blocked, unmeasured, drift, todo])).toBe('BLOCKED');
90
+ });
62
91
  });
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * The overall verdict is computed from the findings:
11
11
  * - any `blocked` finding → BLOCKED
12
- * - any `drift` finding (no blocked) → DRIFT
12
+ * - any `unmeasured` finding (no blocked) → UNKNOWN
13
+ * - any `drift` finding (no blocked, no unmeasured) → DRIFT
13
14
  * - only `todo` findings (or none) → READY
14
15
  *
15
16
  * `todo` exists because some categories surface "next-step reminders"
@@ -42,11 +43,35 @@ export type DriftCategory =
42
43
  | 'disk_space'
43
44
  | 'transport_reads'
44
45
  | 'trusted_sources'
45
- | 'interface_classification';
46
+ | 'interface_classification'
47
+ | 'module_integrity'
48
+ | 'detect_without_converge';
46
49
 
47
- export type DriftSeverity = 'todo' | 'drift' | 'blocked';
50
+ /**
51
+ * `unmeasured` is a check that could not reach its subject
52
+ * (openspec/changes/module-integrity-rigor, D7).
53
+ *
54
+ * It exists because there was nowhere to put that. A category that could not
55
+ * measure either invented a finding of the wrong severity or contributed
56
+ * nothing, and contributing nothing renders as READY — the same defect as
57
+ * everything else this change is about, sitting in the framework that is
58
+ * supposed to catch it.
59
+ *
60
+ * Every claim celilo makes about the fleet is either measured against the thing
61
+ * it describes, or reported as unmeasured. There is no third category, and
62
+ * unmeasured never renders as green.
63
+ */
64
+ export type DriftSeverity = 'todo' | 'drift' | 'unmeasured' | 'blocked';
48
65
 
49
- export type AuditVerdict = 'READY' | 'DRIFT' | 'BLOCKED';
66
+ /**
67
+ * `UNKNOWN` is deliberately distinct from `DRIFT` rather than folded into it.
68
+ * "I could not tell" and "I can tell, and it is wrong" call for different
69
+ * operator responses, and collapsing them recreates exactly the ambiguity D1
70
+ * exists to remove one level down. It ranks below `BLOCKED` — a hard stop is
71
+ * still a hard stop — and above `DRIFT`, because you cannot act on a diff you
72
+ * are not sure you have.
73
+ */
74
+ export type AuditVerdict = 'READY' | 'DRIFT' | 'UNKNOWN' | 'BLOCKED';
50
75
 
51
76
  /**
52
77
  * A single drift finding produced by a category check.
@@ -102,6 +127,7 @@ export interface SystemAuditReport {
102
127
  */
103
128
  export function computeVerdict(findings: DriftFinding[]): AuditVerdict {
104
129
  if (findings.some((f) => f.severity === 'blocked')) return 'BLOCKED';
130
+ if (findings.some((f) => f.severity === 'unmeasured')) return 'UNKNOWN';
105
131
  if (findings.some((f) => f.severity === 'drift')) return 'DRIFT';
106
132
  return 'READY';
107
133
  }
@@ -559,6 +559,27 @@ async function deployModuleImpl(
559
559
 
560
560
  log.success('Templates generated');
561
561
 
562
+ // Pre-flight: refuse before contacting any system if the generated project
563
+ // does not match the module tree it was generated from (D6).
564
+ //
565
+ // This is the check that would have turned celilo#925 from a silent success
566
+ // into a refusal at the point of the fault. A verbatim role asset that
567
+ // failed to refresh is invisible downstream: `cp` reports no error, Ansible
568
+ // copies the stale bytes and reports `ok`, unchanged, and the module's
569
+ // version advances past code that never shipped. Local, milliseconds, no
570
+ // SSH. It deliberately does NOT verify after the deploy — Ansible's
571
+ // `changed` is truthful about what Ansible did, and the lie is upstream of
572
+ // Ansible.
573
+ const { refuseIfGeneratedIsStale } = await import('../module/packaging/generated-plane');
574
+ const staleGenerated = await refuseIfGeneratedIsStale(
575
+ moduleId,
576
+ module.sourcePath,
577
+ generatedPath,
578
+ );
579
+ if (staleGenerated) {
580
+ return { success: false, error: staleGenerated, phases };
581
+ }
582
+
562
583
  // Run validate_config hook if defined (e.g., credential validation via Playwright)
563
584
  if (manifest.hooks?.validate_config) {
564
585
  const hookDef = manifest.hooks.validate_config;
@@ -401,8 +401,12 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
401
401
  mkdirSync(moduleStorageBase, { recursive: true });
402
402
  for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) {
403
403
  if (!entry.isDirectory()) continue;
404
+ // `force` explicit: this merges over an EXISTING module dir and the
405
+ // restored bytes must win. bun does not honour the documented default
406
+ // on every cp path (see generator.ts#copyAnsibleRoleFilesDirs).
404
407
  cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), {
405
408
  recursive: true,
409
+ force: true,
406
410
  });
407
411
  moduleSourcesApplied += 1;
408
412
  }
@@ -76,6 +76,8 @@ const cleanAudit: AuditDeps = {
76
76
  terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
77
77
  moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
78
78
  moduleConfigs: { modules: [] },
79
+ moduleIntegrity: { results: [] },
80
+ detectWithoutConverge: { modules: [] },
79
81
  health: { results: [] },
80
82
  backups: { modules: [] },
81
83
  abandonedOperations: { records: [] },