@celilo/cli 2.3.0 → 3.0.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 (90) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/serve.ts +13 -1
  4. package/src/cli/commands/module-health.test.ts +35 -0
  5. package/src/cli/commands/module-health.ts +11 -3
  6. package/src/cli/commands/module-publish.test.ts +22 -0
  7. package/src/cli/commands/module-publish.ts +14 -12
  8. package/src/cli/commands/module-update.ts +69 -17
  9. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  10. package/src/cli/commands/module-upgrade.ts +43 -3
  11. package/src/cli/commands/publish/helpers.ts +4 -3
  12. package/src/cli/commands/publish/index.ts +13 -1
  13. package/src/cli/commands/publish/plan.test.ts +64 -0
  14. package/src/cli/commands/publish/plan.ts +52 -19
  15. package/src/cli/commands/publish/types.ts +16 -3
  16. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  17. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  18. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  19. package/src/cli/commands/subscribers-serve.ts +22 -4
  20. package/src/cli/commands/system-audit.ts +14 -5
  21. package/src/cli/commands/system-update.ts +11 -6
  22. package/src/cli/completion.ts +10 -1
  23. package/src/cli/fuel-gauge.ts +12 -4
  24. package/src/cli/index.ts +12 -0
  25. package/src/cli/json-output.test.ts +81 -0
  26. package/src/cli/types.ts +9 -0
  27. package/src/hooks/broker.test.ts +9 -3
  28. package/src/hooks/executor.test.ts +5 -2
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  30. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  31. package/src/hooks/hook-trespass.test.ts +13 -4
  32. package/src/hooks/run-named-hook.ts +19 -16
  33. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  34. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  35. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  36. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  37. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  38. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  41. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  42. package/src/manifest/contracts/v1.ts +19 -14
  43. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  44. package/src/manifest/schema.ts +66 -16
  45. package/src/manifest/validate.test.ts +47 -0
  46. package/src/policy/capability-shape-baseline.ts +63 -21
  47. package/src/policy/capability-shape-drift.test.ts +53 -1
  48. package/src/policy/capability-shape.test.ts +105 -0
  49. package/src/policy/capability-shape.ts +283 -2
  50. package/src/registry/client.test.ts +65 -0
  51. package/src/registry/client.ts +7 -7
  52. package/src/secrets/storage.test.ts +69 -3
  53. package/src/secrets/storage.ts +71 -1
  54. package/src/services/audit/health.test.ts +58 -0
  55. package/src/services/audit/health.ts +15 -3
  56. package/src/services/audit/index.test.ts +1 -1
  57. package/src/services/audit/interface-classification.test.ts +16 -5
  58. package/src/services/audit/interface-classification.ts +25 -2
  59. package/src/services/audit/module-versions.ts +5 -1
  60. package/src/services/audit/public-dns.test.ts +20 -0
  61. package/src/services/audit/public-dns.ts +7 -2
  62. package/src/services/audit/recurrence-gate.test.ts +225 -0
  63. package/src/services/audit/trusted-sources.test.ts +17 -0
  64. package/src/services/audit/trusted-sources.ts +21 -0
  65. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  66. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  67. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  68. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  69. package/src/services/build-bus/hook-dispatch.ts +99 -121
  70. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  71. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  72. package/src/services/build-bus/receiver-daemon.ts +355 -0
  73. package/src/services/build-bus/self-update.ts +156 -0
  74. package/src/services/capability-compat.test.ts +90 -0
  75. package/src/services/capability-compat.ts +128 -0
  76. package/src/services/deploy-terraform.ts +38 -1
  77. package/src/services/events-daemon.test.ts +57 -0
  78. package/src/services/events-daemon.ts +76 -0
  79. package/src/services/firewall-reach.ts +21 -8
  80. package/src/services/fleet-checks.test.ts +37 -1
  81. package/src/services/fleet-checks.ts +31 -1
  82. package/src/services/health-runner.ts +43 -5
  83. package/src/services/module-deploy-prune.test.ts +89 -0
  84. package/src/services/module-deploy.ts +72 -125
  85. package/src/services/module-types-drift.test.ts +1 -1
  86. package/src/services/module-validator/capability-versions.test.ts +13 -2
  87. package/src/services/terraform-safety.test.ts +83 -0
  88. package/src/services/terraform-safety.ts +53 -0
  89. package/src/services/update/orchestrator.test.ts +1 -1
  90. package/tsconfig.json +2 -13
@@ -17,9 +17,15 @@
17
17
  * pure for testing. The aggregator wires `runAllHealthChecks` to it.
18
18
  */
19
19
 
20
+ import type { HealthWaiver } from '../../manifest/schema';
20
21
  import type { HealthCheckResult } from '../health-runner';
21
22
  import type { DriftFinding } from './types';
22
23
 
24
+ /** Render a waiver as the annotation carried on the `unmeasured` finding. */
25
+ export function waiverAnnotation(waiver: HealthWaiver): string {
26
+ return `WAIVED by ${waiver.by} (${waiver.at}): ${waiver.reason}`;
27
+ }
28
+
23
29
  export interface HealthAuditDeps {
24
30
  results: HealthCheckResult[];
25
31
  }
@@ -49,6 +55,12 @@ export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]
49
55
  const skipped = r.checks.filter((c) => c.status === 'skip');
50
56
  const measured = r.checks.filter((c) => c.status !== 'skip');
51
57
 
58
+ // A waiver annotates an `unmeasured` finding; it never replaces it.
59
+ // The severity stays `unmeasured` so the fleet verdict stays UNKNOWN
60
+ // (D3) — zero unmeasured findings after a waiver would mean the
61
+ // waiver re-greened the board, which is the failure it guards.
62
+ const waiverNote = r.waiver ? ` — ${waiverAnnotation(r.waiver)}` : '';
63
+
52
64
  if (r.status === 'no-checks') {
53
65
  // Either the module declares no health_check hook, or the hook ran
54
66
  // and every check reported skip. Neither measured anything.
@@ -57,13 +69,13 @@ export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]
57
69
  ? skippedChecksFinding(
58
70
  r,
59
71
  'health_unmeasured',
60
- `${r.moduleId}: all ${skipped.length} health checks skipped — nothing measured`,
72
+ `${r.moduleId}: all ${skipped.length} health checks skipped — nothing measured${waiverNote}`,
61
73
  )
62
74
  : {
63
75
  category: 'health',
64
76
  severity: 'unmeasured',
65
77
  code: 'health_unmeasured',
66
- message: `${r.moduleId}: no health_check hook — never measured`,
78
+ message: `${r.moduleId}: no health_check hook — never measured${waiverNote}`,
67
79
  remediation: `Add a health_check hook to the ${r.moduleId} module manifest`,
68
80
  actionable: false,
69
81
  subject: r.moduleId,
@@ -77,7 +89,7 @@ export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]
77
89
  skippedChecksFinding(
78
90
  r,
79
91
  'health_checks_skipped',
80
- `${r.moduleId}: ${skipped.length} of ${r.checks.length} health checks skipped`,
92
+ `${r.moduleId}: ${skipped.length} of ${r.checks.length} health checks skipped${waiverNote}`,
81
93
  ),
82
94
  );
83
95
  }
@@ -44,7 +44,7 @@ const emptyDeps = {
44
44
  machinesReachable: { results: [] },
45
45
  diskSpace: { results: [] },
46
46
  transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
47
- trustedSources: { firewalls: [] },
47
+ trustedSources: { firewalls: [], unreachableFirewalls: [] },
48
48
  publicDns: { records: [], probe: unusedPublicDnsProbe },
49
49
  };
50
50
 
@@ -207,14 +207,25 @@ describe('describeClassification — the picture, not a finding', () => {
207
207
 
208
208
  describe('auditInterfaceClassification — every firewall, flattened', () => {
209
209
  test('reports across several firewalls, each subjected on its own hostname', () => {
210
- const findings = auditInterfaceClassification([
211
- liveFleetShape(),
212
- liveFleetShape({ hostname: 'fw-branch' }),
213
- ]);
210
+ const findings = auditInterfaceClassification({
211
+ unreachableFirewalls: [],
212
+ views: [liveFleetShape(), liveFleetShape({ hostname: 'fw-branch' })],
213
+ });
214
214
  expect(findings.map((f) => f.subject).sort()).toEqual(['fw-branch', 'fw-main']);
215
215
  });
216
216
 
217
217
  test('no firewalls means no findings', () => {
218
- expect(auditInterfaceClassification([])).toEqual([]);
218
+ expect(auditInterfaceClassification({ views: [], unreachableFirewalls: [] })).toEqual([]);
219
+ });
220
+
221
+ test('a firewall whose interfaces could not be read is unmeasured, not silent (D7)', () => {
222
+ const findings = auditInterfaceClassification({
223
+ views: [liveFleetShape()],
224
+ unreachableFirewalls: ['fw-branch'],
225
+ });
226
+ const unmeasured = findings.filter((f) => f.severity === 'unmeasured');
227
+ expect(unmeasured).toHaveLength(1);
228
+ expect(unmeasured[0].subject).toBe('fw-branch');
229
+ expect(unmeasured[0].code).toBe('interface_classification_unmeasured');
219
230
  });
220
231
  });
@@ -142,8 +142,31 @@ export function auditFirewallInterfaces(view: FirewallInterfaceView): DriftFindi
142
142
  }
143
143
 
144
144
  /** Every firewall's findings, flattened. */
145
- export function auditInterfaceClassification(views: FirewallInterfaceView[]): DriftFinding[] {
146
- return views.flatMap(auditFirewallInterfaces);
145
+ export interface InterfaceClassificationAuditDeps {
146
+ views: FirewallInterfaceView[];
147
+ /**
148
+ * Firewalls celilo knows about but whose interface state could not be read
149
+ * over SSH. Each is an `unmeasured` finding: an interface survey that
150
+ * cannot see a firewall has measured nothing about it (D7).
151
+ */
152
+ unreachableFirewalls: string[];
153
+ }
154
+
155
+ export function auditInterfaceClassification(
156
+ deps: InterfaceClassificationAuditDeps,
157
+ ): DriftFinding[] {
158
+ const unreachable = deps.unreachableFirewalls.map((firewallIp): DriftFinding => {
159
+ return {
160
+ category: 'interface_classification',
161
+ severity: 'unmeasured',
162
+ code: 'interface_classification_unmeasured',
163
+ subject: firewallIp,
164
+ message: `${firewallIp}: interfaces could not be read, so classification is unknown`,
165
+ remediation: `Check SSH reachability of ${firewallIp}, then re-audit. This finding records that the interfaces were not read, not that they are classified.`,
166
+ actionable: true,
167
+ };
168
+ });
169
+ return [...unreachable, ...deps.views.flatMap(auditFirewallInterfaces)];
147
170
  }
148
171
 
149
172
  /**
@@ -84,7 +84,11 @@ export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promis
84
84
  const [reason] = errorBuckets.keys();
85
85
  findings.push({
86
86
  category: 'module_versions',
87
- severity: 'drift',
87
+ // Same fact as the per-module rejection below: the registry did not
88
+ // answer, so nothing was measured. D7 severity, not drift — this
89
+ // aggregate used to say "drift" and rendered the fleet DRIFT when
90
+ // the truth was "I could not ask".
91
+ severity: 'unmeasured',
88
92
  code: 'registry_unreachable',
89
93
  message: `Registry unreachable — ${rejected.length} module${rejected.length === 1 ? '' : 's'} unchecked (${reason})`,
90
94
  remediation: 'Check network connectivity to the registry, then press R to re-audit.',
@@ -139,6 +139,9 @@ describe('auditPublicDns', () => {
139
139
  // Distinguishable from "the site is unreachable" — this says nothing is
140
140
  // currently checking, which is the state the outage hid behind.
141
141
  expect(findings[0].message).toContain('unverifiable');
142
+ // D7: "could not tell" is unmeasured, not drift. It must stop the verdict
143
+ // at UNKNOWN, never render as an ordinary divergence.
144
+ expect(findings[0].severity).toBe('unmeasured');
142
145
  });
143
146
 
144
147
  test('a run that obtained evidence resets the count', async () => {
@@ -189,6 +192,23 @@ describe('auditPublicDns', () => {
189
192
  expect(findings.map((f) => f.code)).toEqual(['public_dns_missing']);
190
193
  });
191
194
 
195
+ test('a name the resolver will not answer for is unmeasured once it stops being a blip (D7)', async () => {
196
+ const args = () => ({
197
+ records: [declared('nexus.lunacycle.net')],
198
+ probe: probe({
199
+ answers: { 'nexus.lunacycle.net': { kind: 'undetermined' as const, reason: 'timeout' } },
200
+ }),
201
+ undeterminedThreshold: 1,
202
+ now: NOW,
203
+ });
204
+
205
+ const findings = await auditPublicDns(args());
206
+ expect(findings.map((f) => f.code)).toEqual(['public_dns_unverifiable']);
207
+ // The resolver did not answer, so the truth about this name is unknown —
208
+ // not fine, not broken. It must stop the verdict at UNKNOWN.
209
+ expect(findings[0].severity).toBe('unmeasured');
210
+ });
211
+
192
212
  test('an empty ledger never touches the network', async () => {
193
213
  const findings = await auditPublicDns({
194
214
  records: [],
@@ -172,7 +172,10 @@ export async function auditPublicDns(deps: PublicDnsAuditDeps): Promise<DriftFin
172
172
  if (runs >= threshold) {
173
173
  findings.push({
174
174
  category: 'public_dns',
175
- severity: 'drift',
175
+ // The vantage point is gone: nothing was measured this run, and D7
176
+ // says that is `unmeasured`, not `drift`. This finding was `drift`
177
+ // before D7 existed; its own text already said what unmeasured means.
178
+ severity: 'unmeasured',
176
179
  code: 'public_dns_unverifiable',
177
180
  message: `Public DNS has been unverifiable for ${runs} consecutive checks`,
178
181
  details: `The echo service (${deps.probe.echoService}) could not be reached, so there is no\nexpectation to compare public DNS against. Latest reason: ${ingress.reason}.\n\nThis is NOT a report that the fleet is reachable, and not a report that it\nis unreachable — it is a report that nothing is currently checking. The\noutage this check exists for ran for nine days behind exactly this silence.`,
@@ -196,7 +199,9 @@ export async function auditPublicDns(deps: PublicDnsAuditDeps): Promise<DriftFin
196
199
  if (runs >= threshold) {
197
200
  findings.push({
198
201
  category: 'public_dns',
199
- severity: 'drift',
202
+ // The resolver would not answer for this name, so whether it resolves
203
+ // publicly is unknown — not fine, not broken. Unmeasured per D7.
204
+ severity: 'unmeasured',
200
205
  code: 'public_dns_unverifiable',
201
206
  message: `${record.fqdn}: public reachability unverifiable for ${runs} consecutive checks`,
202
207
  details: `${deps.probe.resolver} did not answer for this name. Latest reason: ${resolution.reason}.\nWhether the name resolves publicly is currently unknown — which is not the\nsame as it being fine.`,
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The recurrence gate (celilo#1326, part two).
3
+ *
4
+ * D7's rule: every claim celilo makes about the fleet is measured against the
5
+ * thing it describes, or reported as `unmeasured`. There is no third option,
6
+ * because contributing nothing renders the category green and the verdict
7
+ * READY — the defect shape #1326 exists to close.
8
+ *
9
+ * A category can silently violate that rule three ways: someone adds a new
10
+ * DriftCategory with no unmeasured path, someone rewires a category's
11
+ * "could not reach" case to invent a finding of the wrong severity, or a
12
+ * refactor drops the path entirely. This gate catches all three by walking
13
+ * EVERY category — computed from the type, not from a list someone maintains
14
+ * by hand — and demanding each either:
15
+ *
16
+ * - `probed`: its audit function, given deps that cannot reach their
17
+ * subject, produces at least one `unmeasured` finding; or
18
+ * - `allow-listed`: the category structurally always reaches its subject,
19
+ * with the reason recorded here.
20
+ *
21
+ * `Record<DriftCategory, GateEntry>` is the enforcement: a category added to
22
+ * the union without a gate entry is a type error, and the author has to
23
+ * answer the question this test exists to ask.
24
+ *
25
+ * WATCHING IT FAIL (Rule 7.6): revert ce-prr5's `health.ts` change (the
26
+ * `no-checks` branch in auditHealth) and the `health` entry fails — health
27
+ * produces nothing for a module with no hook, which is exactly how six
28
+ * modules rendered READY while never having been measured.
29
+ */
30
+
31
+ import { describe, expect, test } from 'bun:test';
32
+ import { ALL_CATEGORIES } from '../../cli/tui/audit-state';
33
+ import type { DbClient } from '../../db/client';
34
+ import { auditCliVersion } from './cli-version';
35
+ import { auditDiskSpace } from './disk-space';
36
+ import { auditHealth } from './health';
37
+ import { auditInterfaceClassification } from './interface-classification';
38
+ import { auditModuleIntegrity } from './module-integrity';
39
+ import { auditModuleVersions } from './module-versions';
40
+ import { auditPublicDns } from './public-dns';
41
+ import { auditSchema } from './schema';
42
+ import { auditTerraformPlan } from './terraform-plan';
43
+ import { auditTrustedSources } from './trusted-sources';
44
+ import type { DriftCategory, DriftFinding, DriftSeverity } from './types';
45
+
46
+ type GateEntry = {
47
+ /** The probe: call the category's audit with deps that cannot reach their subject. */
48
+ readonly probe?: () => Promise<DriftFinding[]> | DriftFinding[];
49
+ /** Why the category structurally always reaches its subject. */
50
+ readonly because?: string;
51
+ };
52
+
53
+ const GATE: Record<DriftCategory, GateEntry> = {
54
+ cli_version: {
55
+ probe: () => auditCliVersion({ installedVersion: '9.9.9', fetcher: async () => null }),
56
+ because: 'probed: the npm registry can be unreachable, and that is unmeasured',
57
+ },
58
+ schema: {
59
+ probe: () =>
60
+ auditSchema({
61
+ journal: () => null,
62
+ applied: () => [],
63
+ db: {} as DbClient,
64
+ }),
65
+ because: 'probed: a missing migration journal means nothing was compared',
66
+ },
67
+ capability_abi: {
68
+ because:
69
+ 'compares capability versions claimed in installed module manifests against the framework registry and deployed providers, all read from celilo own DB and local manifests — the subject is celilo itself and is always reachable',
70
+ },
71
+ browser_pin: {
72
+ because:
73
+ 'compares a bundled client playwright revision against the locally provisioned browser binary, both on this machine — always reachable',
74
+ },
75
+ terraform_plan: {
76
+ probe: () =>
77
+ auditTerraformPlan({
78
+ modules: [{ id: 'm', terraformDir: '/tmp/does-not-matter', envVars: {} }],
79
+ run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
80
+ }),
81
+ because: 'probed: terraform exiting zero with an unreadable summary is not a clean plan',
82
+ },
83
+ module_versions: {
84
+ probe: () =>
85
+ auditModuleVersions({
86
+ installed: [{ id: 'm', version: '1.0.0' }],
87
+ fetcher: () => Promise.reject(new Error('registry unreachable')),
88
+ }),
89
+ because: 'probed: a registry that did not answer is not a version difference',
90
+ },
91
+ module_configs: {
92
+ because:
93
+ 'walks manifest variables against config rows in celilo DB — the subject is celilo itself and is always reachable',
94
+ },
95
+ health: {
96
+ probe: () => auditHealth({ results: [{ moduleId: 'm', status: 'no-checks', checks: [] }] }),
97
+ because: 'probed: a module with no hook, or whose checks all skip, has measured nothing',
98
+ },
99
+ backups: {
100
+ because:
101
+ 'reads recorded backup outcomes and effective cadences from celilo DB — the subject is celilo itself and is always reachable; a missing backup is drift, not silence',
102
+ },
103
+ abandoned_operations: {
104
+ because: 'counts reclaimed operation rows in celilo DB — always reachable',
105
+ },
106
+ undeployed_modules: {
107
+ because: 'reads module lifecycle states from celilo DB — always reachable',
108
+ },
109
+ unconfigured_modules: {
110
+ because: 'reads config-row counts from celilo DB — always reachable',
111
+ },
112
+ services_credentials: {
113
+ because:
114
+ 'reads credential envelopes from celilo DB and decrypts with the local master key; a failed decrypt is itself a measurement (blocked), never silence',
115
+ },
116
+ secrets_decryptable: {
117
+ because:
118
+ 'walks encrypted rows in celilo DB and decrypts; a failed decrypt is itself a measurement (blocked), never silence',
119
+ },
120
+ services_reachable: {
121
+ because:
122
+ 'the probe outcome IS the measurement: an unreachable service is reachable=false and a finding, so every probe measures its subject',
123
+ },
124
+ machines_reachable: {
125
+ because:
126
+ 'the SSH probe outcome IS the measurement: an unreachable machine is reachable=false and a finding, so every probe measures its subject',
127
+ },
128
+ public_dns: {
129
+ probe: () =>
130
+ auditPublicDns({
131
+ records: [{ fqdn: 'git.celilo.computer', companion: false, lastAssertedAt: new Date(0) }],
132
+ probe: {
133
+ resolver: '1.1.1.1',
134
+ echoService: 'https://echo.example',
135
+ observeIngress: async () => ({ kind: 'undetermined', reason: 'ECONNREFUSED' }),
136
+ resolve: async () => {
137
+ throw new Error('not reached: the vantage is down');
138
+ },
139
+ },
140
+ undeterminedThreshold: 1,
141
+ now: new Date(0),
142
+ }),
143
+ because: 'probed: the off-fleet echo vantage is external and can be down; unmeasured',
144
+ },
145
+ disk_space: {
146
+ probe: () =>
147
+ auditDiskSpace({
148
+ results: [
149
+ { hostname: 'box', ipAddress: '10.0.0.1', usedPercent: null, message: 'df failed' },
150
+ ],
151
+ }),
152
+ because: 'probed: a df that could not run measured nothing about the disk',
153
+ },
154
+ transport_reads: {
155
+ because:
156
+ 'reads the notification poller recorded read outcomes from celilo DB — always reachable; not happening is itself the drift finding',
157
+ },
158
+ trusted_sources: {
159
+ probe: () => auditTrustedSources({ firewalls: [], unreachableFirewalls: ['10.0.0.254'] }),
160
+ because: 'probed: a firewall whose live ruleset cannot be read is unknown, not clean',
161
+ },
162
+ interface_classification: {
163
+ probe: () => auditInterfaceClassification({ views: [], unreachableFirewalls: ['10.0.0.254'] }),
164
+ because: 'probed: a firewall whose interfaces cannot be read is unknown, not clean',
165
+ },
166
+ module_integrity: {
167
+ probe: () =>
168
+ auditModuleIntegrity({
169
+ results: [
170
+ { success: false, moduleId: 'm', violations: [], error: 'No integrity data found.' },
171
+ ],
172
+ }),
173
+ because: 'probed: no baseline row, or a stale one, means nothing was verified',
174
+ },
175
+ detect_without_converge: {
176
+ because:
177
+ 'static analysis of module manifests and system-config rows in celilo DB — always reachable',
178
+ },
179
+ jail_exemptions: {
180
+ because:
181
+ 'reads recorded per-module jail exemptions from celilo DB — the subject is celilo itself and is always reachable; no exemptions is the normal state, not silence',
182
+ },
183
+ };
184
+
185
+ const ALLOW_LISTED = Object.entries(GATE).filter(([, e]) => !e.probe);
186
+
187
+ describe('recurrence gate: every DriftCategory measures its subject or says why it cannot fail to', () => {
188
+ test('the gate covers every category in the union (compile-time) and matches the TUI list (run-time)', async () => {
189
+ // `GATE` is `Record<DriftCategory, GateEntry>`, so the compiler rejects a
190
+ // new category until it gets an entry. This run-time line additionally
191
+ // catches the reverse: an entry for a category no longer in the union.
192
+ const gateCategories = Object.keys(GATE) as DriftCategory[];
193
+ expect(gateCategories.sort()).toEqual([...ALL_CATEGORIES].sort());
194
+ });
195
+
196
+ for (const [category, entry] of Object.entries(GATE) as [DriftCategory, GateEntry][]) {
197
+ if (!entry.probe) {
198
+ test(`${category}: allow-listed — ${entry.because}`, () => {
199
+ expect(entry.because).toBeTruthy();
200
+ });
201
+ continue;
202
+ }
203
+
204
+ test(`${category}: cannot reach its subject and still reports unmeasured`, async () => {
205
+ const probe = entry.probe;
206
+ if (!probe) expect.unreachable();
207
+ const findings = await probe();
208
+ const severities: DriftSeverity[] = findings.map((f) => f.severity);
209
+ expect(severities).toContain('unmeasured');
210
+ // The gate exists because silence renders as READY. If an unmeasured
211
+ // path ever degrades back to drift, the verdict distinction D7 added
212
+ // is gone and this is the line that says so.
213
+ const unmeasured = findings.find((f) => f.severity === 'unmeasured') as DriftFinding;
214
+ expect(unmeasured.category).toBe(category);
215
+ });
216
+ }
217
+
218
+ test('the allow-list is not a dumping ground: 14 categories, each individually justified', () => {
219
+ // Not a magic number to defend — a tripwire. If this count changes, the
220
+ // diff is the review: someone moved a category in or out of the set of
221
+ // "structurally always reaches its subject", and the reason strings
222
+ // above are where they have to argue it.
223
+ expect(ALLOW_LISTED).toHaveLength(14);
224
+ });
225
+ });
@@ -59,6 +59,7 @@ describe('parsing reach rules from a live ruleset', () => {
59
59
  describe('unowned-trusted-network audit', () => {
60
60
  it('reports a network celilo does not recognise', async () => {
61
61
  const findings = await auditTrustedSources({
62
+ unreachableFirewalls: [],
62
63
  firewalls: [
63
64
  {
64
65
  firewallIp: '192.168.0.254',
@@ -78,6 +79,7 @@ describe('unowned-trusted-network audit', () => {
78
79
  // The point of the change: once a module owns the VPN and registers its
79
80
  // subnet, the rule IS the rendered output and there is nothing to report.
80
81
  const findings = await auditTrustedSources({
82
+ unreachableFirewalls: [],
81
83
  firewalls: [
82
84
  {
83
85
  firewallIp: '192.168.0.254',
@@ -96,6 +98,7 @@ describe('unowned-trusted-network audit', () => {
96
98
  ZONES,
97
99
  );
98
100
  const findings = await auditTrustedSources({
101
+ unreachableFirewalls: [],
99
102
  firewalls: [{ firewallIp: '192.168.0.254', live, known: [CONTROL_PLANE] }],
100
103
  });
101
104
 
@@ -104,6 +107,7 @@ describe('unowned-trusted-network audit', () => {
104
107
 
105
108
  it('names the interface when trust is granted by interface, and says why it cannot be registered as-is', async () => {
106
109
  const findings = await auditTrustedSources({
110
+ unreachableFirewalls: [],
107
111
  firewalls: [
108
112
  {
109
113
  firewallIp: '192.168.0.254',
@@ -120,6 +124,7 @@ describe('unowned-trusted-network audit', () => {
120
124
 
121
125
  it('a firewall with no live reach beyond what celilo composes is clean', async () => {
122
126
  const findings = await auditTrustedSources({
127
+ unreachableFirewalls: [],
123
128
  firewalls: [
124
129
  {
125
130
  firewallIp: '192.168.0.254',
@@ -134,4 +139,16 @@ describe('unowned-trusted-network audit', () => {
134
139
 
135
140
  expect(findings).toEqual([]);
136
141
  });
142
+
143
+ it('a firewall whose ruleset could not be read is unmeasured, not silent (D7)', async () => {
144
+ const findings = await auditTrustedSources({
145
+ unreachableFirewalls: ['192.168.0.254'],
146
+ firewalls: [],
147
+ });
148
+
149
+ expect(findings).toHaveLength(1);
150
+ expect(findings[0].severity).toBe('unmeasured');
151
+ expect(findings[0].code).toBe('trusted_sources_unmeasured');
152
+ expect(findings[0].subject).toBe('192.168.0.254');
153
+ });
137
154
  });
@@ -37,6 +37,13 @@ export interface FirewallReachState {
37
37
 
38
38
  export interface TrustedSourcesAuditDeps {
39
39
  firewalls: FirewallReachState[];
40
+ /**
41
+ * Firewalls celilo knows about but whose live ruleset could not be read
42
+ * (`iptables-save` failed over SSH). Each is an `unmeasured` finding, not
43
+ * silence: a check that cannot see a firewall has not measured that
44
+ * firewall, and silence renders as READY (D7).
45
+ */
46
+ unreachableFirewalls: string[];
40
47
  }
41
48
 
42
49
  /**
@@ -79,6 +86,20 @@ function originKey(origin: ReachOrigin): string {
79
86
  export async function auditTrustedSources(deps: TrustedSourcesAuditDeps): Promise<DriftFinding[]> {
80
87
  const findings: DriftFinding[] = [];
81
88
 
89
+ // Unreachable firewalls first, so the report leads with what could not be
90
+ // measured at all before anything it did measure.
91
+ for (const firewallIp of deps.unreachableFirewalls) {
92
+ findings.push({
93
+ category: 'trusted_sources',
94
+ severity: 'unmeasured',
95
+ code: 'trusted_sources_unmeasured',
96
+ message: `${firewallIp}: live ruleset could not be read, so trusted-source drift is unknown`,
97
+ remediation: `Check SSH reachability of ${firewallIp}, then re-audit. This finding records that the ruleset was not read, not that it is clean.`,
98
+ actionable: true,
99
+ subject: firewallIp,
100
+ });
101
+ }
102
+
82
103
  for (const firewall of deps.firewalls) {
83
104
  const known = new Set(firewall.known);
84
105
  const reported = new Set<string>();