@celilo/cli 0.24.0 → 0.24.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,7 +57,12 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
57
57
  const result = await handleModuleConfigSet(['testmod', 'vmid', '203']);
58
58
  expect(result.success).toBe(false);
59
59
  if (!result.success) {
60
- expect(result.error).toContain('infrastructure-managed');
60
+ // The refusal now names the source rather than a bespoke
61
+ // "infrastructure-managed" phrase, because `infrastructure` stopped being
62
+ // the only refused source — every non-`user` source is refused, and each
63
+ // gets guidance aimed at its own upstream.
64
+ expect(result.error).toContain('derived by celilo (source: infrastructure)');
65
+ expect(result.error).toContain('not operator-settable');
61
66
  }
62
67
  });
63
68
 
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
6
6
  import { z } from 'zod';
7
7
  import { getDb } from '../../db/client';
8
8
  import { modules } from '../../db/schema';
9
+ import type { ModuleManifest, VariableDeclare } from '../../manifest/schema';
9
10
  import {
10
11
  HEALTH_CHECK_INTERVAL_CONFIG_KEY,
11
12
  reconcileModuleWatchState,
@@ -20,6 +21,12 @@ import {
20
21
  MONITOR_INTERVAL_FLOOR_MINUTES,
21
22
  cadenceSchema,
22
23
  } from '../../services/cadence';
24
+ import {
25
+ declaredVariables,
26
+ describeDerivedSource,
27
+ explainNotSettable,
28
+ isDerivedVariable,
29
+ } from '../../services/config-provenance';
23
30
  import {
24
31
  deleteModuleConfig,
25
32
  formatConfigValue,
@@ -27,6 +34,7 @@ import {
27
34
  getModuleConfigValue,
28
35
  setModuleConfigValue,
29
36
  } from '../../services/module-config';
37
+ import { readResolutionContext } from '../../variables/context';
30
38
  import { getArg, validateRequiredArgs } from '../parser';
31
39
  import type { CommandResult } from '../types';
32
40
 
@@ -180,17 +188,14 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
180
188
  const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS;
181
189
 
182
190
  // Validate key against manifest
183
- const manifest = module.manifestData as Record<string, unknown>;
184
- const variables = manifest.variables as
185
- | { owns?: Array<{ name: string; required?: boolean; default?: string; source?: string }> }
186
- | undefined;
187
- const declaredVars = variables?.owns || [];
191
+ const manifest = module.manifestData as ModuleManifest;
192
+ const declaredVars = manifest.variables?.owns ?? [];
188
193
 
189
194
  // Check if key is declared in manifest
190
195
  const declaredVar = declaredVars.find((v) => v.name === key);
191
196
  if (!declaredVar && !isFrameworkKey) {
192
197
  const settableKeys = declaredVars
193
- .filter((v) => v.source !== 'infrastructure')
198
+ .filter((v) => !isDerivedVariable(v))
194
199
  .map((v) => v.name)
195
200
  .join(', ');
196
201
  const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', ');
@@ -200,15 +205,23 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
200
205
  };
201
206
  }
202
207
 
203
- // ISS-0069: reject infrastructure-managed keys at SET time rather than
204
- // accepting-then-silently-overriding them at deploy. `source: infrastructure`
205
- // variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
206
- // derived by the deploy (IPAM allocates vmid/IP; the container service supplies
207
- // node/template/gateway/vlan), so a value set here would be ignored.
208
- if (declaredVar?.source === 'infrastructure') {
208
+ // Refuse EVERY derived source, not just `infrastructure`.
209
+ //
210
+ // ISS-0069 established the principle refuse at SET time rather than
211
+ // accepting-then-silently-overriding at deploy and then applied it to one
212
+ // source out of four. So `celilo module config set authentik auth_url …`
213
+ // (a `capability`-sourced value) reported success, wrote the row, and was
214
+ // discarded on the next deploy. A command that says "Set config for authentik"
215
+ // and changes nothing is worse than one that refuses.
216
+ //
217
+ // No counter-example survived review of a good reason to pin a derived value:
218
+ // a derived value computes the right answer from one source of truth, so if
219
+ // the answer is wrong the source is wrong, and fixing the source fixes every
220
+ // consumer at once while pinning one module hides the divergence.
221
+ if (declaredVar && isDerivedVariable(declaredVar)) {
209
222
  return {
210
223
  success: false,
211
- error: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
224
+ error: explainNotSettable(moduleId, declaredVar),
212
225
  };
213
226
  }
214
227
 
@@ -354,10 +367,24 @@ export async function handleModuleConfigGet(args: string[]): Promise<CommandResu
354
367
  };
355
368
  }
356
369
 
357
- // Get all config for module
358
- const configs = getAllModuleConfigValues(moduleId);
370
+ // Get all config for module, split by who owns each value.
371
+ //
372
+ // Printing every row flat presented a value celilo computed as if the
373
+ // operator had chosen it, which is how a derived value gets "corrected" by
374
+ // hand and silently reverted. The derived section is also read from the
375
+ // resolution context rather than the config rows, so it shows what celilo
376
+ // computes RIGHT NOW — including values that were never written down, the
377
+ // absence that caused the 2026-08-14 DNS outage.
378
+ const declared = declaredVariables(module.manifestData as ModuleManifest);
379
+ const stored = getAllModuleConfigValues(moduleId);
380
+ const userConfigs = stored.filter((c) => {
381
+ const variable = declared.get(c.key);
382
+ return !variable || !isDerivedVariable(variable);
383
+ });
384
+
385
+ const derivedValues = await resolveDerivedForDisplay(moduleId, declared, db);
359
386
 
360
- if (configs.length === 0) {
387
+ if (userConfigs.length === 0 && derivedValues.length === 0) {
361
388
  return {
362
389
  success: true,
363
390
  message: `No configuration set for ${moduleId}`,
@@ -365,14 +392,70 @@ export async function handleModuleConfigGet(args: string[]): Promise<CommandResu
365
392
  }
366
393
 
367
394
  const lines = [`Configuration for ${moduleId}:`, ''];
368
- for (const config of configs) {
369
- const formatted = formatConfigValue(config);
370
- lines.push(`${config.key} = ${formatted}`);
395
+ if (userConfigs.length > 0) {
396
+ for (const config of userConfigs) {
397
+ lines.push(`${config.key} = ${formatConfigValue(config)}`);
398
+ }
399
+ } else {
400
+ lines.push('(nothing set by you)');
401
+ }
402
+
403
+ if (derivedValues.length > 0) {
404
+ lines.push('', 'Derived by celilo (not settable — fix the source instead):', '');
405
+ for (const derived of derivedValues) {
406
+ lines.push(`${derived.key} = ${derived.value}`);
407
+ lines.push(` ${describeDerivedSource(derived.variable)}`);
408
+ }
371
409
  }
372
410
 
373
411
  return {
374
412
  success: true,
375
413
  message: lines.join('\n'),
376
- data: configs.map((c) => ({ key: c.key, value: c.value })),
414
+ data: {
415
+ config: userConfigs.map((c) => ({ key: c.key, value: c.value })),
416
+ derived: derivedValues.map((d) => ({
417
+ key: d.key,
418
+ value: d.value,
419
+ source: d.variable.source,
420
+ })),
421
+ },
377
422
  };
378
423
  }
424
+
425
+ /**
426
+ * The current value of each derived variable, for display only.
427
+ *
428
+ * Uses the side-effect-free resolution context: reading a config must not seed
429
+ * rows or allocate addresses. A module whose derives cannot resolve yet (an
430
+ * undeployed provider, an unset system key) reports nothing derived rather than
431
+ * failing the whole command — `get` is how an operator diagnoses that state, so
432
+ * it has to survive it.
433
+ */
434
+ async function resolveDerivedForDisplay(
435
+ moduleId: string,
436
+ declared: Map<string, VariableDeclare>,
437
+ db: ReturnType<typeof getDb>,
438
+ ): Promise<Array<{ key: string; value: string; variable: VariableDeclare }>> {
439
+ const derivedVars = [...declared.values()].filter(isDerivedVariable);
440
+ if (derivedVars.length === 0) return [];
441
+
442
+ let selfConfig: Record<string, string>;
443
+ try {
444
+ selfConfig = (await readResolutionContext(moduleId, db)).selfConfig;
445
+ } catch {
446
+ // Reported as "not computed yet" below rather than as an error, so the
447
+ // command still shows the operator their own config.
448
+ return [];
449
+ }
450
+
451
+ const resolved: Array<{ key: string; value: string; variable: VariableDeclare }> = [];
452
+ for (const variable of derivedVars) {
453
+ const value = selfConfig[variable.name];
454
+ resolved.push({
455
+ key: variable.name,
456
+ value: value ?? '(not computed yet)',
457
+ variable,
458
+ });
459
+ }
460
+ return resolved;
461
+ }
@@ -21,6 +21,7 @@ import {
21
21
  effectiveBackupSchedule,
22
22
  } from '../../services/backup-schedule';
23
23
  import { formatCadence } from '../../services/cadence';
24
+ import { declaredVariables, isDerivedVariable } from '../../services/config-provenance';
24
25
  import { getModuleSystems } from '../../services/deployed-systems';
25
26
  import { configOverride, parseStoredConfigValue } from '../../services/module-config';
26
27
  import { formatPlacementLine, reconcilePlacement } from '../../services/placement-reconcile';
@@ -210,22 +211,40 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
210
211
  sections.push(placementLines.join('\n'));
211
212
  }
212
213
 
213
- // Section 2: Configuration
214
- if (configs.length > 0) {
215
- const configLines = ['Configuration:'];
216
- for (const config of configs) {
217
- // `value` is the human-readable display form (e.g. "test-host"
218
- // for a string, "2222" for a number, JSON-stringified for
219
- // complex types) populated by upsertModuleConfig alongside
220
- // the canonical valueJson. Using it here keeps the status
221
- // output free of JSON-quote noise around primitives.
222
- configLines.push(` ${config.key}: ${config.value}`);
223
- }
224
- sections.push(configLines.join('\n'));
214
+ // Section 2: Configuration, split by who owns each value. A flat list
215
+ // presented a value celilo computed as if the operator had chosen it, which
216
+ // is how a derived value gets "corrected" by hand and silently reverted.
217
+ // `source` is the authority for the split, never the presence of a
218
+ // `derive_from` see services/config-provenance.ts.
219
+ const declared = declaredVariables(module.manifestData as ModuleManifest);
220
+ const isDerivedKey = (key: string) => {
221
+ const variable = declared.get(key);
222
+ return variable !== undefined && isDerivedVariable(variable);
223
+ };
224
+ // `value` is the human-readable display form (e.g. "test-host" for a string,
225
+ // "2222" for a number, JSON-stringified for complex types) — populated by
226
+ // upsertModuleConfig alongside the canonical valueJson. Using it here keeps
227
+ // the status output free of JSON-quote noise around primitives.
228
+ const userConfigs = configs.filter((c) => !isDerivedKey(c.key));
229
+ const derivedConfigs = configs.filter((c) => isDerivedKey(c.key));
230
+
231
+ if (userConfigs.length > 0) {
232
+ sections.push(
233
+ ['Configuration:', ...userConfigs.map((c) => ` ${c.key}: ${c.value}`)].join('\n'),
234
+ );
225
235
  } else {
226
236
  sections.push('Configuration: (none)');
227
237
  }
228
238
 
239
+ if (derivedConfigs.length > 0) {
240
+ sections.push(
241
+ [
242
+ 'Derived by celilo (not settable):',
243
+ ...derivedConfigs.map((c) => ` ${c.key}: ${c.value} [${declared.get(c.key)?.source}]`),
244
+ ].join('\n'),
245
+ );
246
+ }
247
+
229
248
  // Section 2b: Per-module policy — what celilo will DO to this module, and
230
249
  // whether that came from the operator or from the module's author. A raw
231
250
  // config key does not tell an operator what the manifest said, and an
@@ -17,8 +17,9 @@
17
17
  */
18
18
 
19
19
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { eq } from 'drizzle-orm';
20
21
  import type { DbClient } from '../db/client';
21
- import { machines, moduleInfrastructure, modules } from '../db/schema';
22
+ import { machines, moduleConfigs, moduleInfrastructure, modules, systemConfig } from '../db/schema';
22
23
  import { upsertModuleConfig } from '../services/module-config';
23
24
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
24
25
  import { loadHookConfigMap } from './load-hook-config';
@@ -165,3 +166,170 @@ describe('loadHookConfigMap', () => {
165
166
  expect(result['ip.primary']).toBeUndefined();
166
167
  });
167
168
  });
169
+
170
+ /**
171
+ * The 2026-08-14 outage, as a test.
172
+ *
173
+ * `technitium` declares `vpn_subnet` with `source: system` and
174
+ * `derive_from: $system:network.control-plane-vpn.subnet`. The system key was
175
+ * set on the fleet AFTER the module had already been configured, so no
176
+ * `module_configs` row was ever written for it — `applyDeclarativeDerivations`
177
+ * skips a `source: system` variable once the module has any stored config for
178
+ * it, and nothing re-derives one that is absent. The variable is
179
+ * `required: false`, so deploy-validation passed without comment.
180
+ *
181
+ * The consumer was a capability factory, which gets its config from
182
+ * `loadHookConfigMap`. A direct `module_configs` select cannot see a value that
183
+ * was never stored, so the factory built split-horizon DNS with no view for the
184
+ * admin VPN: queries from it matched nothing, returned NOERROR with zero
185
+ * records, fell through to public DNS, and the operator could not reach the
186
+ * forge while every service reported healthy.
187
+ *
188
+ * The fix is that the hook config map is built from the resolution context —
189
+ * which recomputes derived values on every build — not from the table alone.
190
+ */
191
+ describe('loadHookConfigMap: derived values the config table never stored', () => {
192
+ let db: DbClient;
193
+
194
+ const VPN_SUBNET_KEY = 'network.control-plane-vpn.subnet';
195
+ const VPN_SUBNET = '10.255.255.0/24';
196
+
197
+ beforeEach(async () => {
198
+ db = await setupTestDatabase();
199
+ db.insert(modules)
200
+ .values({
201
+ id: 'technitium',
202
+ name: 'Technitium DNS',
203
+ version: '1.0.0',
204
+ sourcePath: '/tmp/technitium',
205
+ manifestData: {
206
+ variables: {
207
+ owns: [
208
+ { name: 'hostname', type: 'string', source: 'user' },
209
+ {
210
+ name: 'vpn_subnet',
211
+ type: 'string',
212
+ source: 'system',
213
+ required: false,
214
+ derive_from: `$system:${VPN_SUBNET_KEY}`,
215
+ },
216
+ ],
217
+ },
218
+ },
219
+ })
220
+ .run();
221
+ // The module was configured first: it has its user rows, and no row for
222
+ // the derived variable.
223
+ upsertModuleConfig(db, 'technitium', 'hostname', 'dns-int');
224
+ });
225
+
226
+ afterEach(async () => {
227
+ await cleanupTestDatabase(db);
228
+ });
229
+
230
+ test('a hook sees a source:system derive whose system key was set after the module was configured', async () => {
231
+ db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
232
+
233
+ const result = await loadHookConfigMap('technitium', db);
234
+
235
+ expect(result.hostname).toBe('dns-int');
236
+ expect(result.vpn_subnet).toBe(VPN_SUBNET);
237
+ });
238
+
239
+ test('recomputing does not write the derived value back into module_configs', async () => {
240
+ db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
241
+
242
+ await loadHookConfigMap('technitium', db);
243
+
244
+ // Reading a hook's config is not a deploy. It must not seed or refresh
245
+ // stored config as a side effect — that is how a stale snapshot gets
246
+ // written in the first place.
247
+ const stored = db
248
+ .select()
249
+ .from(moduleConfigs)
250
+ .where(eq(moduleConfigs.moduleId, 'technitium'))
251
+ .all();
252
+ expect(stored.map((row) => row.key).sort()).toEqual(['hostname']);
253
+ });
254
+
255
+ test('an optional derive whose system key is still unset stays absent, not an error', async () => {
256
+ // No systemConfig row at all — the state the fleet was in before the VPN
257
+ // subnet was declared. `required: false`, so this is silence, not failure.
258
+ const result = await loadHookConfigMap('technitium', db);
259
+
260
+ expect(result.hostname).toBe('dns-int');
261
+ expect(result.vpn_subnet).toBeUndefined();
262
+ });
263
+
264
+ test('an already-stored value reaches the hook unchanged', async () => {
265
+ // Precedence: a stored row wins, and the recomputed context only fills
266
+ // keys the table has no row for. Every derived value on the fleet today is
267
+ // stored, so this is what keeps the new read path from changing what any
268
+ // deployed hook already receives — the change is purely additive here.
269
+ // (Once derived values stop being persisted, the stored row disappears and
270
+ // the recomputed value is all that is left.)
271
+ db.insert(systemConfig).values({ key: VPN_SUBNET_KEY, value: VPN_SUBNET }).run();
272
+ upsertModuleConfig(db, 'technitium', 'vpn_subnet', '10.99.0.0/24');
273
+
274
+ const result = await loadHookConfigMap('technitium', db);
275
+
276
+ expect(result.vpn_subnet).toBe('10.99.0.0/24');
277
+ });
278
+ });
279
+
280
+ /**
281
+ * Recomputation must not make a hook's config LESS available than reading the
282
+ * table did.
283
+ *
284
+ * `applyDeclarativeDerivations` throws when a `required: true` variable's
285
+ * derivation fails, which at generate time is exactly right — a deploy that
286
+ * cannot resolve a required value should stop. But this reader also serves
287
+ * health checks and `module run-hook`, which used to be unable to fail this
288
+ * way at all: they read stored rows, and a stored row cannot throw. A provider
289
+ * that is paused, removed, or not yet deployed would take its consumers'
290
+ * health checks down with it — reporting the consumer as broken when the
291
+ * consumer is fine.
292
+ */
293
+ describe('loadHookConfigMap: a failing derive does not take the hook down', () => {
294
+ let db: DbClient;
295
+
296
+ beforeEach(async () => {
297
+ db = await setupTestDatabase();
298
+ db.insert(modules)
299
+ .values({
300
+ id: 'caddy',
301
+ name: 'Caddy',
302
+ version: '1.0.0',
303
+ sourcePath: '/tmp/caddy',
304
+ manifestData: {
305
+ variables: {
306
+ owns: [
307
+ { name: 'hostname', type: 'string', source: 'user' },
308
+ {
309
+ name: 'primary_domain',
310
+ type: 'string',
311
+ source: 'capability',
312
+ required: true,
313
+ derive_from: '$capability:dns_registrar.zone.primary_domain',
314
+ },
315
+ ],
316
+ },
317
+ },
318
+ })
319
+ .run();
320
+ upsertModuleConfig(db, 'caddy', 'hostname', 'caddy');
321
+ });
322
+
323
+ afterEach(async () => {
324
+ await cleanupTestDatabase(db);
325
+ });
326
+
327
+ test('stored config still reaches the hook when a required derive cannot resolve', async () => {
328
+ // No `dns_registrar` capability is registered — the provider is paused,
329
+ // removed, or has not been deployed yet.
330
+ const result = await loadHookConfigMap('caddy', db);
331
+
332
+ expect(result.hostname).toBe('caddy');
333
+ expect(result.primary_domain).toBeUndefined();
334
+ });
335
+ });
@@ -11,30 +11,65 @@
11
11
  * `capability-loader.ts:loadModuleConfig` had — same shape, different
12
12
  * code, easy to miss.
13
13
  *
14
- * The shape:
15
- * - Every row from `module_configs`, parsed from `valueJson` via
16
- * the shared `parseStoredConfigValue` helper. This preserves the
17
- * types declared in each module's manifest: `number` reads as
18
- * `number`, `boolean` as `boolean`, complex types as their
19
- * parsed JSON shape. Pre-Defect-1, this path returned raw strings
20
- * for primitives (because `valueJson` was NULL for them); that
21
- * broke capability calls like `firewall.exposeService({ports:[...]})`
22
- * that did a `typeof === 'number'` check downstream. Fixed in
23
- * v2 by always populating valueJson on write.
24
- * - If `target_ip` isn't in the row set, look up the deployment
25
- * machine and inject both `target_ip` AND `ip.primary` from
26
- * `machines.ipAddress`. Two keys because consumers historically used
27
- * either name (e.g. caddy's `setup-network.ts` checks
28
- * `target_ip || ip.primary`); fixing the drift means filling both.
14
+ * The shape, in the order the layers are applied:
15
+ * 1. Every row from `module_configs`, parsed from `valueJson` via
16
+ * the shared `parseStoredConfigValue` helper. This preserves the
17
+ * types declared in each module's manifest: `number` reads as
18
+ * `number`, `boolean` as `boolean`, complex types as their
19
+ * parsed JSON shape. Pre-Defect-1, this path returned raw strings
20
+ * for primitives (because `valueJson` was NULL for them); that
21
+ * broke capability calls like `firewall.exposeService({ports:[...]})`
22
+ * that did a `typeof === 'number'` check downstream. Fixed in
23
+ * v2 by always populating valueJson on write.
24
+ * 2. If `target_ip` isn't in the row set, look up the deployment
25
+ * machine and inject both `target_ip` AND `ip.primary` from
26
+ * `machines.ipAddress`. Two keys because consumers historically used
27
+ * either name (e.g. caddy's `setup-network.ts` checks
28
+ * `target_ip || ip.primary`); fixing the drift means filling both.
29
+ * 3. For each variable the module's manifest declares, the value the
30
+ * resolution context currently computes — but only where the first two
31
+ * layers left that key unset. See below.
29
32
  *
30
33
  * Container deploys write `target_ip` into `module_configs` explicitly
31
34
  * during generate/deploy, so the fallback only fires for machine
32
35
  * deploys (existing iron, no terraform — what every e2e test uses).
36
+ *
37
+ * ## Why layer 3 exists
38
+ *
39
+ * A derived variable — one whose value comes from system config, a
40
+ * capability, or the selected infrastructure rather than from the operator —
41
+ * only reaches a hook through this map. Reading `module_configs` alone can
42
+ * only see the derives that happen to have been WRITTEN there, and one that
43
+ * was never written is indistinguishable from one that does not exist.
44
+ *
45
+ * On 2026-08-14 that was not a theoretical gap. `technitium`'s `vpn_subnet`
46
+ * (`source: system`) had no row, because the system key was set after the
47
+ * module was configured and nothing re-derives a `source: system` value that
48
+ * is already absent. Being `required: false`, deploy-validation passed in
49
+ * silence. The capability factory that builds split-horizon DNS therefore
50
+ * built no view for the admin VPN; queries from it matched nothing, returned
51
+ * NOERROR with zero records, fell through to public DNS, and the operator
52
+ * could not reach the forge while every service reported healthy.
53
+ *
54
+ * So the map is completed from `readResolutionContext` — the same computation
55
+ * a build does, run without any of a build's side effects. Stored rows still
56
+ * win, which is what makes this purely additive for every value already on
57
+ * the fleet; the context only supplies what the table is missing. Context
58
+ * values arrive as strings (declarative derivation resolves string templates
59
+ * only), so this layer never overwrites a typed row with a stringified one.
60
+ *
61
+ * It is narrowed to the manifest's declared variables on purpose. The
62
+ * resolution context also carries values that exist to drive template
63
+ * generation — `inventory.*`, `requires.system.*`, `lxc_nameserver` — which a
64
+ * hook has no business reading and which have never appeared in this map.
65
+ * Widening a hook's view of the world is not what this layer is for.
33
66
  */
34
67
  import { eq } from 'drizzle-orm';
35
68
  import type { DbClient } from '../db/client';
36
- import { machines, moduleConfigs, moduleInfrastructure } from '../db/schema';
69
+ import { machines, moduleConfigs, moduleInfrastructure, modules } from '../db/schema';
70
+ import type { ModuleManifest } from '../manifest/schema';
37
71
  import { parseStoredConfigValue } from '../services/module-config';
72
+ import { readResolutionContext } from '../variables/context';
38
73
 
39
74
  export async function loadHookConfigMap(
40
75
  moduleId: string,
@@ -51,19 +86,82 @@ export async function loadHookConfigMap(
51
86
  configMap[c.key] = parseStoredConfigValue(c);
52
87
  }
53
88
 
54
- if (configMap.target_ip) return configMap;
89
+ applyMachineAddressFallback(configMap, moduleId, db);
90
+ await applyRecomputedDerivedValues(configMap, moduleId, db);
91
+
92
+ return configMap;
93
+ }
94
+
95
+ /**
96
+ * Fill in the declared variables the config rows have no value for, using the
97
+ * value the resolution context computes right now. Mutates `configMap`.
98
+ */
99
+ async function applyRecomputedDerivedValues(
100
+ configMap: Record<string, unknown>,
101
+ moduleId: string,
102
+ db: DbClient,
103
+ ): Promise<void> {
104
+ const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
105
+ if (!module?.manifestData) return;
106
+
107
+ const declared = (module.manifestData as ModuleManifest).variables?.owns ?? [];
108
+ const missing = declared.filter((variable) => !(variable.name in configMap));
109
+ if (missing.length === 0) return;
110
+
111
+ let context: Awaited<ReturnType<typeof readResolutionContext>>;
112
+ try {
113
+ context = await readResolutionContext(moduleId, db);
114
+ } catch (error) {
115
+ // Derivation THROWS when a `required: true` variable cannot be resolved —
116
+ // correct at generate time, where a deploy that cannot resolve a required
117
+ // value should stop. This reader also serves health checks and
118
+ // `module run-hook`, which previously could not fail this way at all: a
119
+ // stored row cannot throw. Letting it propagate would mean a provider that
120
+ // is paused, removed, or not yet deployed takes down the health checks of
121
+ // every module that derives from it, reporting healthy consumers as
122
+ // broken.
123
+ //
124
+ // So the hook falls back to what the table holds — exactly what it
125
+ // received before recomputation existed, never less. Logged rather than
126
+ // swallowed (Rule 6.2): a derive that cannot resolve is worth knowing
127
+ // about even when the hook survives it.
128
+ console.error(
129
+ `Could not recompute derived config for '${moduleId}'; the hook sees only its stored config. ` +
130
+ `Derived values (${missing.map((v) => v.name).join(', ')}) may be missing:`,
131
+ error,
132
+ );
133
+ return;
134
+ }
135
+
136
+ for (const variable of missing) {
137
+ const value = context.selfConfig[variable.name];
138
+ if (value !== undefined) {
139
+ configMap[variable.name] = value;
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Fill `target_ip` / `ip.primary` from the deployment machine when the config
146
+ * rows carry no address of their own. Mutates `configMap` in place.
147
+ */
148
+ function applyMachineAddressFallback(
149
+ configMap: Record<string, unknown>,
150
+ moduleId: string,
151
+ db: DbClient,
152
+ ): void {
153
+ if (configMap.target_ip) return;
55
154
 
56
155
  const infra = db
57
156
  .select()
58
157
  .from(moduleInfrastructure)
59
158
  .where(eq(moduleInfrastructure.moduleId, moduleId))
60
159
  .get();
61
- if (!infra?.machineId) return configMap;
160
+ if (!infra?.machineId) return;
62
161
 
63
162
  const machine = db.select().from(machines).where(eq(machines.id, infra.machineId)).get();
64
- if (!machine) return configMap;
163
+ if (!machine) return;
65
164
 
66
165
  configMap.target_ip = machine.ipAddress;
67
166
  configMap['ip.primary'] = machine.ipAddress;
68
- return configMap;
69
167
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Who owns a config value — the operator, or celilo.
3
+ *
4
+ * The load-bearing case here is the one that looks like a special case and is
5
+ * not: `derive_from` does NOT mean derived. Getting that backwards refuses an
6
+ * operator's edit to their own config, and a migration written on the same test
7
+ * would delete the row.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import type { VariableDeclare } from '../manifest/schema';
12
+ import {
13
+ declaredVariables,
14
+ describeDerivedSource,
15
+ explainNotSettable,
16
+ isDerivedVariable,
17
+ } from './config-provenance';
18
+
19
+ function variable(overrides: Partial<VariableDeclare> & { name: string }): VariableDeclare {
20
+ return {
21
+ type: 'string',
22
+ required: false,
23
+ source: 'user',
24
+ ...overrides,
25
+ } as VariableDeclare;
26
+ }
27
+
28
+ describe('isDerivedVariable', () => {
29
+ test('a user-sourced variable is the operator’s', () => {
30
+ expect(isDerivedVariable(variable({ name: 'acme_email', source: 'user' }))).toBe(false);
31
+ });
32
+
33
+ test.each(['capability', 'system', 'infrastructure', 'terraform'] as const)(
34
+ 'a %s-sourced variable is celilo’s',
35
+ (source) => {
36
+ expect(isDerivedVariable(variable({ name: 'x', source }))).toBe(true);
37
+ },
38
+ );
39
+
40
+ test('a variable with NO declared source reads as the operator’s', () => {
41
+ // The manifest schema requires `source`, so this only happens for a
42
+ // malformed or pre-schema manifest already sitting in `manifest_data`. The
43
+ // question is which way to be wrong: guessing "derived" refuses an
44
+ // operator's `set` with a message insisting celilo owns a value nothing
45
+ // computes, which they cannot act on.
46
+ const noSource = {
47
+ name: 'app_port',
48
+ type: 'integer',
49
+ required: false,
50
+ } as unknown as VariableDeclare;
51
+
52
+ expect(isDerivedVariable(noSource)).toBe(false);
53
+ });
54
+
55
+ test('a user-sourced variable WITH a derive_from is still the operator’s', () => {
56
+ // iptables: `firewall_ip`, `source: user`, `derive_from: $machine:ipAddress`.
57
+ // `$machine:` derives are answered by the config interview — they seed a
58
+ // default the operator confirms — so the row is operator config. Classing
59
+ // it as derived would refuse an operator correcting their own firewall
60
+ // address, and deleting it on the same test would blind the trusted-sources
61
+ // audit in services/firewall-reach.ts, which reads exactly this row.
62
+ const firewallIp = variable({
63
+ name: 'firewall_ip',
64
+ source: 'user',
65
+ required: true,
66
+ derive_from: '$machine:ipAddress',
67
+ });
68
+
69
+ expect(isDerivedVariable(firewallIp)).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe('declaredVariables', () => {
74
+ test('indexes a manifest’s owned variables by name', () => {
75
+ const declared = declaredVariables({
76
+ variables: {
77
+ owns: [variable({ name: 'hostname' }), variable({ name: 'vpn_subnet', source: 'system' })],
78
+ },
79
+ } as never);
80
+
81
+ expect([...declared.keys()].sort()).toEqual(['hostname', 'vpn_subnet']);
82
+ expect(declared.get('vpn_subnet')?.source).toBe('system');
83
+ });
84
+
85
+ test('a manifest declaring nothing yields an empty index, not a throw', () => {
86
+ expect(declaredVariables({} as never).size).toBe(0);
87
+ });
88
+ });
89
+
90
+ describe('explainNotSettable', () => {
91
+ test('a capability-sourced value points at the provider, not at this module', () => {
92
+ // The live footgun: `celilo module config set authentik auth_url …`
93
+ // reported success, wrote the row, and was discarded on the next deploy.
94
+ const message = explainNotSettable(
95
+ 'authentik',
96
+ variable({
97
+ name: 'auth_url',
98
+ source: 'capability',
99
+ derive_from: '$capability:authentication.url',
100
+ }),
101
+ );
102
+
103
+ expect(message).toContain('not operator-settable');
104
+ expect(message).toContain('source: capability');
105
+ // Actionable: the only way to change a derived value is to fix its source.
106
+ expect(message).toContain('provider');
107
+ expect(message).toContain('redeploy');
108
+ });
109
+
110
+ test('a system-sourced value names the system key to set', () => {
111
+ const message = explainNotSettable(
112
+ 'technitium',
113
+ variable({
114
+ name: 'vpn_subnet',
115
+ source: 'system',
116
+ derive_from: '$system:network.control-plane-vpn.subnet',
117
+ }),
118
+ );
119
+
120
+ // The operator should be able to copy the fix out of the error. The
121
+ // `$system:` prefix is stripped so the key is the one `system config set`
122
+ // actually takes.
123
+ expect(message).toContain('celilo system config set network.control-plane-vpn.subnet');
124
+ });
125
+
126
+ test('an infrastructure-sourced value keeps the placement guidance', () => {
127
+ const message = explainNotSettable(
128
+ 'caddy',
129
+ variable({ name: 'vmid', source: 'infrastructure' }),
130
+ );
131
+
132
+ expect(message).toContain('IPAM');
133
+ expect(message).toContain('celilo proxmox migrate');
134
+ });
135
+ });
136
+
137
+ describe('describeDerivedSource', () => {
138
+ test('names the upstream, and the template when there is one', () => {
139
+ expect(
140
+ describeDerivedSource(
141
+ variable({
142
+ name: 'dmz_subnet',
143
+ source: 'system',
144
+ derive_from: '$system:network.dmz.subnet',
145
+ }),
146
+ ),
147
+ ).toBe('from system config ($system:network.dmz.subnet)');
148
+ });
149
+
150
+ test('degrades to the upstream alone when no template is declared', () => {
151
+ expect(describeDerivedSource(variable({ name: 'vmid', source: 'infrastructure' }))).toContain(
152
+ 'infrastructure celilo selected',
153
+ );
154
+ });
155
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Who owns a module-config value: the operator, or celilo.
3
+ *
4
+ * A module's manifest declares a `source` for every variable it owns. `user`
5
+ * means the operator supplies it. Every other source — `capability`, `system`,
6
+ * `infrastructure`, `terraform` — means celilo computes it from somewhere else:
7
+ * another module's published capability data, system config, the selected
8
+ * infrastructure, a Terraform output.
9
+ *
10
+ * That distinction has to be shared rather than re-derived per command, because
11
+ * it was previously spelled differently in each place and the disagreements were
12
+ * silent. `module config set` refused only `source: infrastructure`, so setting
13
+ * a `capability`- or `system`-sourced value REPORTED SUCCESS, wrote the row, and
14
+ * was then discarded on the next deploy —
15
+ * `celilo module config set authentik auth_url …` being the live example. And
16
+ * `module config get` printed every row flat, so a value celilo derived was
17
+ * indistinguishable from one the operator had chosen.
18
+ *
19
+ * ## `derive_from` does not mean derived
20
+ *
21
+ * The tempting shortcut is "it has a `derive_from` template, so celilo computes
22
+ * it". That is wrong, and expensively so. `iptables` declares:
23
+ *
24
+ * - name: firewall_ip
25
+ * source: user
26
+ * derive_from: "$machine:ipAddress"
27
+ *
28
+ * `$machine:` derivations are answered by the config interview — they seed a
29
+ * default the operator confirms — not by template resolution. The row is
30
+ * operator config. Treating it as derived would refuse an operator's attempt to
31
+ * correct their own firewall address, and a migration that deleted rows on the
32
+ * same test would blind the trusted-sources audit that reads it.
33
+ *
34
+ * `source` is the authority. Nothing else is.
35
+ */
36
+ import type { ModuleManifest, VariableDeclare } from '../manifest/schema';
37
+
38
+ /**
39
+ * Does celilo compute this variable, rather than the operator supply it?
40
+ *
41
+ * An ABSENT source reads as the operator's. The manifest schema requires
42
+ * `source`, so absent means a malformed or pre-schema manifest sitting in
43
+ * `modules.manifest_data` — and for those the question is which way to be
44
+ * wrong. Guessing "derived" refuses an operator's attempt to set their own
45
+ * config with a message insisting celilo owns a value nothing computes, which
46
+ * is unanswerable. Guessing "user" preserves what celilo did before this
47
+ * predicate existed, when only `infrastructure` was refused.
48
+ */
49
+ export function isDerivedVariable(variable: Pick<VariableDeclare, 'source'>): boolean {
50
+ return variable.source !== undefined && variable.source !== 'user';
51
+ }
52
+
53
+ /** Every variable a module's manifest declares, indexed by name. */
54
+ export function declaredVariables(manifest: ModuleManifest): Map<string, VariableDeclare> {
55
+ return new Map((manifest.variables?.owns ?? []).map((variable) => [variable.name, variable]));
56
+ }
57
+
58
+ /**
59
+ * A one-line explanation of where a derived value comes from, for output an
60
+ * operator reads. Says which upstream to go fix, since fixing the source is the
61
+ * only way to change a derived value.
62
+ */
63
+ export function describeDerivedSource(variable: VariableDeclare): string {
64
+ switch (variable.source) {
65
+ case 'capability':
66
+ return variable.derive_from
67
+ ? `from another module's capability data (${variable.derive_from})`
68
+ : "from another module's capability data";
69
+ case 'system':
70
+ return variable.derive_from
71
+ ? `from system config (${variable.derive_from})`
72
+ : 'from system config';
73
+ case 'infrastructure':
74
+ return 'from the infrastructure celilo selected for this module';
75
+ case 'terraform':
76
+ return 'from a Terraform output, at deploy time';
77
+ default:
78
+ return `computed by celilo (source: ${variable.source})`;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Why `module config set` refuses this variable, and what to do instead.
84
+ * Actionable per source: a derived value is only wrong because its upstream is
85
+ * wrong, and fixing the upstream fixes every consumer at once, where pinning one
86
+ * module hides the divergence.
87
+ */
88
+ export function explainNotSettable(moduleId: string, variable: VariableDeclare): string {
89
+ const header = `'${variable.name}' is derived by celilo (source: ${variable.source}) — not operator-settable.`;
90
+ const origin = `It is computed ${describeDerivedSource(variable)}, so a value set here would be overwritten the next time ${moduleId} is generated.`;
91
+
92
+ switch (variable.source) {
93
+ case 'capability':
94
+ return `${header}\n${origin}\n • Fix it at the provider: change the config of the module that publishes this capability, then redeploy it.\n • 'celilo module config get ${moduleId}' shows the value celilo currently computes.`;
95
+ case 'system':
96
+ return `${header}\n${origin}\n • Fix it at the source: 'celilo system config set ${variable.derive_from?.replace(/^\$\{?system:/, '').replace(/\}$/, '') ?? '<key>'} <value>'.\n • That corrects every module deriving from it at once, rather than pinning this one.`;
97
+ case 'infrastructure':
98
+ return `${header}\n${origin}\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate'.\n • vmid / IP: auto-allocated by IPAM.`;
99
+ case 'terraform':
100
+ return `${header}\n${origin}\n • It is read back from Terraform outputs after the deploy creates the resource.`;
101
+ default:
102
+ return `${header}\n${origin}`;
103
+ }
104
+ }
@@ -185,9 +185,14 @@ function resolveSelfRefsInObject(
185
185
  }
186
186
 
187
187
  /**
188
- * Build resolution context for a module
188
+ * Build the resolution context for a module, provisioning as it goes.
189
189
  *
190
- * Execution function (Rule 10.1) - performs database queries
190
+ * Execution function (Rule 10.1) - performs database queries AND writes:
191
+ * it seeds `module_configs` with defaults and zone-derived networking, and
192
+ * records the module's deployed system (allocating IPAM addresses when the
193
+ * host is a celilo-provisioned container). This is the generate/deploy-time
194
+ * entrypoint. Anything that only wants to READ the resolved configuration
195
+ * wants {@link readResolutionContext} instead.
191
196
  *
192
197
  * @param moduleId - Module to build context for
193
198
  * @param db - Database client (optional, for testing)
@@ -197,6 +202,55 @@ export async function buildResolutionContext(
197
202
  moduleId: string,
198
203
  db = getDb(),
199
204
  ): Promise<ResolutionContext> {
205
+ return assembleResolutionContext(moduleId, db, { provision: true });
206
+ }
207
+
208
+ /**
209
+ * The same resolved configuration, computed without touching the database.
210
+ *
211
+ * Every derived value is recomputed from its current upstream, exactly as a
212
+ * build would compute it, but nothing is written: no config rows are seeded,
213
+ * no addresses are allocated, no deployed system is recorded. That makes it
214
+ * safe to call from read paths that run on a cadence — health checks, hook
215
+ * invocations, capability factories — where a provisioning side effect would
216
+ * be both surprising and, in the IPAM case, harmful.
217
+ *
218
+ * This is what {@link import('../hooks/load-hook-config').loadHookConfigMap}
219
+ * uses so a hook sees the value a derive currently produces rather than only
220
+ * the ones that happen to have been stored.
221
+ *
222
+ * @param moduleId - Module to build context for
223
+ * @param db - Database client (optional, for testing)
224
+ * @returns Resolution context with all data sources
225
+ */
226
+ export async function readResolutionContext(
227
+ moduleId: string,
228
+ db = getDb(),
229
+ ): Promise<ResolutionContext> {
230
+ return assembleResolutionContext(moduleId, db, { provision: false });
231
+ }
232
+
233
+ /**
234
+ * The shared body of both entrypoints. `provision` is deliberately private
235
+ * (Rule 10.3): callers choose a named function, not a flag.
236
+ */
237
+ async function assembleResolutionContext(
238
+ moduleId: string,
239
+ db: DbClient,
240
+ { provision }: { provision: boolean },
241
+ ): Promise<ResolutionContext> {
242
+ /**
243
+ * Seed a config row — a no-op when only reading. Every seeded value is also
244
+ * assigned into `selfConfig` by the caller, so the resolved context is the
245
+ * same either way; what differs is whether it is written down.
246
+ */
247
+ const persistConfig = (
248
+ key: string,
249
+ value: string | number | boolean | unknown[] | Record<string, unknown>,
250
+ ): void => {
251
+ if (provision) upsertModuleConfig(db, moduleId, key, value);
252
+ };
253
+
200
254
  // Fetch module manifest for VM resources
201
255
  const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
202
256
 
@@ -226,12 +280,12 @@ export async function buildResolutionContext(
226
280
 
227
281
  // Store assigned values in module config
228
282
  if (assigned.hostname) {
229
- upsertModuleConfig(db, moduleId, 'hostname', assigned.hostname);
283
+ persistConfig('hostname', assigned.hostname);
230
284
  selfConfig.hostname = assigned.hostname;
231
285
  }
232
286
 
233
287
  if (assigned.zone) {
234
- upsertModuleConfig(db, moduleId, 'zone', assigned.zone);
288
+ persistConfig('zone', assigned.zone);
235
289
  selfConfig.zone = assigned.zone;
236
290
  }
237
291
  }
@@ -248,9 +302,7 @@ export async function buildResolutionContext(
248
302
  // declared shape — e.g. `default: 2222` (YAML int) round-trips as
249
303
  // `number` not the string "2222". This is the root of Defect 1.
250
304
  if (variable.default !== undefined && !selfConfig[variable.name]) {
251
- upsertModuleConfig(
252
- db,
253
- moduleId,
305
+ persistConfig(
254
306
  variable.name,
255
307
  variable.default as string | number | boolean | unknown[] | Record<string, unknown>,
256
308
  );
@@ -303,7 +355,7 @@ export async function buildResolutionContext(
303
355
  for (const { manifestKey, configKey, systemValue } of resourceMappings) {
304
356
  if (systemValue != null) {
305
357
  // Canonical system size — always wins so a resize propagates.
306
- upsertModuleConfig(db, moduleId, configKey, systemValue);
358
+ persistConfig(configKey, systemValue);
307
359
  selfConfig[configKey] = String(systemValue);
308
360
  continue;
309
361
  }
@@ -312,9 +364,7 @@ export async function buildResolutionContext(
312
364
  // Pass them through unstringified so valueJson preserves the
313
365
  // shape — see comment in the variable-defaults block above.
314
366
  if (value !== undefined && !selfConfig[configKey]) {
315
- upsertModuleConfig(
316
- db,
317
- moduleId,
367
+ persistConfig(
318
368
  configKey,
319
369
  value as string | number | boolean | unknown[] | Record<string, unknown>,
320
370
  );
@@ -333,7 +383,11 @@ export async function buildResolutionContext(
333
383
  // outputs (resolveInfrastructureVariables), not here.
334
384
  // This is the single place generate-time addresses are recorded — `target_ip`
335
385
  // no longer lives in module_configs.
336
- if (module?.manifestData) {
386
+ //
387
+ // Provisioning only. A read must never reach this: allocating an address is
388
+ // not something looking at a config should do, and by the time any hook runs
389
+ // the row is already there for `buildInfraSystemsMap` below to read.
390
+ if (provision && module?.manifestData) {
337
391
  const manifest = module.manifestData as ModuleManifest;
338
392
  const declared = getDeclaredSystems(manifest);
339
393
  const hostname = selfConfig.hostname;
@@ -593,7 +647,7 @@ export async function buildResolutionContext(
593
647
 
594
648
  // If zone from manifest but not in selfConfig, store it as first-class config
595
649
  if (zone && !selfConfig.zone) {
596
- upsertModuleConfig(db, moduleId, 'zone', zone);
650
+ persistConfig('zone', zone);
597
651
  selfConfig.zone = zone;
598
652
  }
599
653
 
@@ -625,7 +679,7 @@ export async function buildResolutionContext(
625
679
  }
626
680
  return value;
627
681
  })();
628
- upsertModuleConfig(db, moduleId, field, coerced);
682
+ persistConfig(field, coerced);
629
683
  selfConfig[field] = String(coerced);
630
684
  }
631
685
  }
@@ -685,7 +739,7 @@ export async function buildResolutionContext(
685
739
  }
686
740
 
687
741
  if (isNew || isChanged) {
688
- upsertModuleConfig(db, moduleId, key, value);
742
+ persistConfig(key, value);
689
743
  selfConfig[key] = value;
690
744
  }
691
745
  }
@@ -52,6 +52,59 @@ describe('resolveDeclarativeDerivation', () => {
52
52
  expect(result).toBe('10.0.10.0/24');
53
53
  });
54
54
 
55
+ test('resolves a system key containing hyphens', () => {
56
+ // celilo's zone names are kebab-case, so shipped manifests contain
57
+ // `$system:network.control-plane-vpn.subnet` and
58
+ // `$system:network.secure-mgmt.subnet`. While the match stopped at the
59
+ // first hyphen these looked up `network.control` / `network.secure`,
60
+ // threw, and — being optional — resolved to nothing in silence. That is
61
+ // half of the 2026-08-14 DNS outage.
62
+ const variable: VariableDeclare = {
63
+ name: 'vpn_subnet',
64
+ type: 'string',
65
+ required: false,
66
+ source: 'system',
67
+ derive_from: '$system:network.control-plane-vpn.subnet',
68
+ };
69
+
70
+ const context: ResolutionContext = {
71
+ moduleId: 'technitium',
72
+ selfConfig: {},
73
+ systemConfig: { 'network.control-plane-vpn.subnet': '10.255.255.0/24' },
74
+ systemSecrets: {},
75
+ secrets: {},
76
+ capabilities: {},
77
+ };
78
+
79
+ expect(resolveDeclarativeDerivation(variable, context)).toBe('10.255.255.0/24');
80
+ });
81
+
82
+ test('names the whole hyphenated key when it is missing', () => {
83
+ // The message has to name the key the manifest asked for. Reporting
84
+ // `network.secure` for a manifest that says `network.secure-mgmt` sends
85
+ // the reader looking for a key that was never requested.
86
+ const variable: VariableDeclare = {
87
+ name: 'secure_mgmt_subnet',
88
+ type: 'string',
89
+ required: true,
90
+ source: 'system',
91
+ derive_from: '$system:network.secure-mgmt.subnet',
92
+ };
93
+
94
+ const context: ResolutionContext = {
95
+ moduleId: 'technitium',
96
+ selfConfig: {},
97
+ systemConfig: {},
98
+ systemSecrets: {},
99
+ secrets: {},
100
+ capabilities: {},
101
+ };
102
+
103
+ expect(() => resolveDeclarativeDerivation(variable, context)).toThrow(
104
+ "Missing system config: network.secure-mgmt.subnet (required by variable 'secure_mgmt_subnet')",
105
+ );
106
+ });
107
+
55
108
  test('throws on missing system config', () => {
56
109
  const variable: VariableDeclare = {
57
110
  name: 'primary_domain',
@@ -56,8 +56,19 @@ function substituteVariables(
56
56
  ): string {
57
57
  let result = input;
58
58
 
59
- // Replace $system:key patterns (both $system:key and ${system:key} forms)
60
- result = result.replace(/\$\{?system:([a-zA-Z0-9_.]+)\}?/g, (_match, key) => {
59
+ // Replace $system:key patterns (both $system:key and ${system:key} forms).
60
+ //
61
+ // The key may contain hyphens. celilo's own zone names are kebab-case, so
62
+ // `$system:network.control-plane-vpn.subnet` and
63
+ // `$system:network.secure-mgmt.subnet` are both real keys in shipped
64
+ // manifests — and neither could ever resolve while this class excluded `-`:
65
+ // the match stopped at the first hyphen, looked up `network.control`, and
66
+ // threw. For an optional variable that throw is swallowed, so the derive
67
+ // simply produced nothing, forever, in silence. That is half of the
68
+ // 2026-08-14 DNS outage (`technitium.vpn_subnet`); the other half is that
69
+ // nothing re-derived the value at read time — see
70
+ // `hooks/load-hook-config.ts`.
71
+ result = result.replace(/\$\{?system:([a-zA-Z0-9_.-]+)\}?/g, (_match, key) => {
61
72
  const value = context.systemConfig[key];
62
73
  if (value === undefined) {
63
74
  throw new Error(`Missing system config: ${key} (required by variable '${variableName}')`);