@celilo/cli 1.6.0 → 1.8.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 (64) hide show
  1. package/CELILO_CORE_MODULES.md +3 -1
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +238 -2
  11. package/src/capabilities/validation.ts +67 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-remove.ts +34 -2
  14. package/src/cli/commands/module-update.test.ts +149 -2
  15. package/src/cli/commands/module-update.ts +113 -25
  16. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  17. package/src/cli/commands/service-set-credentials.ts +115 -0
  18. package/src/cli/commands/system-migrate.ts +6 -4
  19. package/src/cli/completion.ts +16 -1
  20. package/src/cli/index.ts +9 -0
  21. package/src/db/client.ts +10 -8
  22. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  23. package/src/db/migrate.test.ts +147 -0
  24. package/src/db/migrate.ts +69 -1
  25. package/src/db/schema.ts +21 -4
  26. package/src/hooks/capability-loader.test.ts +55 -0
  27. package/src/hooks/capability-loader.ts +16 -1
  28. package/src/manifest/template-validator.test.ts +47 -0
  29. package/src/manifest/template-validator.ts +18 -1
  30. package/src/module/import.ts +39 -6
  31. package/src/policy/capability-shape-baseline.ts +88 -0
  32. package/src/policy/capability-shape-drift.test.ts +162 -0
  33. package/src/policy/capability-shape.ts +117 -0
  34. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  35. package/src/policy/module-business-baseline.ts +32 -18
  36. package/src/services/alerting/monitors.ts +54 -2
  37. package/src/services/alerting/sweep-runner.ts +38 -1
  38. package/src/services/capability-table-rows.test.ts +191 -0
  39. package/src/services/capability-table-rows.ts +103 -0
  40. package/src/services/consumer-cleanup.ts +18 -10
  41. package/src/services/container-service.test.ts +34 -0
  42. package/src/services/container-service.ts +44 -0
  43. package/src/services/deployed-systems.test.ts +101 -0
  44. package/src/services/deployed-systems.ts +43 -11
  45. package/src/services/dns-internal-records.test.ts +72 -1
  46. package/src/services/dns-provider-backfill.ts +30 -0
  47. package/src/services/fleet-checks.test.ts +26 -0
  48. package/src/services/fleet-checks.ts +11 -1
  49. package/src/services/module-deploy.ts +88 -41
  50. package/src/services/module-validator/capability-versions.test.ts +6 -1
  51. package/src/services/port-forwards.test.ts +6 -2
  52. package/src/services/port-forwards.ts +0 -11
  53. package/src/services/provider-arrival.test.ts +241 -0
  54. package/src/services/provider-arrival.ts +213 -0
  55. package/src/services/trusted-sources.ts +0 -5
  56. package/src/templates/generator.test.ts +35 -0
  57. package/src/templates/generator.ts +29 -1
  58. package/src/variables/context.test.ts +63 -0
  59. package/src/variables/context.ts +85 -12
  60. package/src/variables/declarative-derivation.test.ts +47 -8
  61. package/src/variables/declarative-derivation.ts +6 -4
  62. package/src/variables/lxc-nameserver.test.ts +144 -0
  63. package/src/services/public-web-republish.test.ts +0 -189
  64. package/src/services/public-web-republish.ts +0 -84
@@ -300,7 +300,8 @@ export function moduleExists(moduleId: string, db = getDb()): boolean {
300
300
  * Validate well-known capabilities
301
301
  *
302
302
  * Policy function - checks if module's well-known capabilities are valid:
303
- * 1. No other module provides the same well-known capability (uniqueness)
303
+ * 1. No other module provides the same well-known capability in an
304
+ * overlapping scope (zone-aware uniqueness)
304
305
  * 2. Module's zone matches capability's required zone (zone enforcement)
305
306
  *
306
307
  * @param manifest - Module manifest
@@ -321,16 +322,30 @@ export async function validateWellKnownCapabilities(
321
322
 
322
323
  const wellKnown = getWellKnownCapability(capability.name);
323
324
 
324
- // Check 1: Capability uniqueness - only one module can provide this capability
325
+ // Check 1: Capability uniqueness within an overlapping scope. An explicit
326
+ // zone-scoped provider may coexist with a zone-agnostic fallback because
327
+ // lookup deterministically prefers the explicit match. Two agnostic
328
+ // providers, or two explicit providers sharing a zone, remain ambiguous.
325
329
  const existingCapability = await db
326
330
  .select()
327
331
  .from(capabilities)
328
332
  .where(eq(capabilities.capabilityName, capability.name))
329
333
  .all();
330
334
 
331
- if (existingCapability.length > 0) {
332
- const conflictingModule = existingCapability[0];
333
- return `Well-known capability '${capability.name}' is already provided by module '${conflictingModule.moduleId}'. A home lab can only have one module providing this capability. Remove '${conflictingModule.moduleId}' before importing this module.`;
335
+ const newZones = capability.zones ?? null;
336
+ const conflictingModule = existingCapability.find((candidate) => {
337
+ const existingZones = candidate.zones ?? null;
338
+
339
+ if (newZones === null || existingZones === null) {
340
+ return newZones === null && existingZones === null;
341
+ }
342
+
343
+ return newZones.some((zone) => existingZones.includes(zone));
344
+ });
345
+
346
+ if (conflictingModule) {
347
+ const scope = newZones ? ` zone(s) ${newZones.join(', ')}` : ' the zone-agnostic scope';
348
+ return `Well-known capability '${capability.name}' is already provided in${scope} by module '${conflictingModule.moduleId}'. Remove '${conflictingModule.moduleId}' or use a non-overlapping explicit zone scope before importing this module.`;
334
349
  }
335
350
 
336
351
  // Check 2: Zone enforcement - module must be in the correct zone
@@ -592,7 +607,25 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
592
607
  // Execution: Validate capability access if module requires capabilities
593
608
  if (manifest.requires?.capabilities && manifest.requires.capabilities.length > 0) {
594
609
  const { validateCapabilityAccess } = await import('../capabilities/validation');
595
- const accessResult = await validateCapabilityAccess(manifest, db.$client);
610
+ // Templates are where CLAUDE.md's Definition of Done tells module authors
611
+ // to put `$capability:` references, so the import-time gate has to see
612
+ // them (celilo#854, celilo#1027).
613
+ //
614
+ // This adds no parser. `validateModuleTemplates` above already reads and
615
+ // parses every `.tpl` on every import, roughly 25 lines before the gate
616
+ // runs — it was discarding the references it saw. So there is no new file
617
+ // walk, no new failure mode and no new ordering question: the data is
618
+ // already in scope and the gate simply was not looking at it.
619
+ //
620
+ // Fail-closed by that same ordering. An unreadable template makes
621
+ // `validateModuleTemplates` return success:false with no references, and
622
+ // the early return above fires BEFORE this check, so an empty reference
623
+ // set can never reach the gate as a silent pass.
624
+ const accessResult = await validateCapabilityAccess(
625
+ manifest,
626
+ db.$client,
627
+ templateValidation.capabilityReferences,
628
+ );
596
629
 
597
630
  if (!accessResult.success) {
598
631
  if (tempDir) await cleanupTempDir(tempDir);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Expected shape hashes per capability — the fixture behind
3
+ * `capability-shape-drift.test.ts`.
4
+ *
5
+ * `CAPABILITY_CONTRACT_VERSIONS` is bumped BY HAND, so the whole scheme rests on
6
+ * a human remembering. `capability-contract.ts` used to claim that risk was
7
+ * "mitigated by a CI check that compares interface AST hashes against an
8
+ * expected fixture". No such check existed. It was designed in the retired
9
+ * `apps/celilo/designs/CELILO_UPDATE.md` and never built, and three design
10
+ * documents reasoned from it anyway (celilo#1042). This is that check.
11
+ *
12
+ * Changing a capability's interface OR its table declaration changes its hash.
13
+ * The test then fails until BOTH the contract version and the entry below move,
14
+ * in the same commit — which is the whole point, because the version is what
15
+ * `compareProviderToRuntime` and `celilo system audit` compare against.
16
+ *
17
+ * Comments and formatting do NOT change a hash. The interface is re-printed from
18
+ * its AST with comments removed before hashing, so a doc-only edit stays a patch
19
+ * (the bump rule `capability-contract.ts` already states).
20
+ *
21
+ * WHY THIS LIVES IN apps/celilo AND NOT NEXT TO THE CONTRACT. It needs the
22
+ * TypeScript compiler API to parse, and `db/schema.ts` to check a declaration
23
+ * against the real table. `@celilo/capabilities` is bundled into every module
24
+ * (celilo#173), so adding a `typescript` dependency there to serve a test would
25
+ * be paid for by every module on the fleet. apps/celilo already depends on both.
26
+ */
27
+
28
+ export interface CapabilityShape {
29
+ /** The contract version this hash was recorded at. */
30
+ readonly version: string;
31
+ /** sha256 of the printed interface plus the capability's table declaration. */
32
+ readonly hash: string;
33
+ }
34
+
35
+ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>> = {
36
+ public_web: {
37
+ version: '3.2.0',
38
+ hash: 'a353bf184552f1ed82e5d97c9b4609dc243718c20051a9446d8dc7174561db50',
39
+ },
40
+ idp: {
41
+ version: '1.2.0',
42
+ hash: '99eee190ed670f53595554335f23e1745b872e3226dc9ffdb1d0bf6397d6013a',
43
+ },
44
+ dns_registrar: {
45
+ version: '4.0.0',
46
+ hash: 'f5d02196a3394ec71d865beb45825160103800baf632293a769a6e72dc76236a',
47
+ },
48
+ firewall: {
49
+ version: '1.1.0',
50
+ hash: '564a83ebf8ca937faf955afc4f7bf4278fe9ed01694f0237bfbc38e2eff38408',
51
+ },
52
+ source_forge: {
53
+ version: '1.0.0',
54
+ hash: '3bc797cf8c2990c61723fb680299f6a1d4b99ac42fd0371dd9dc64d05c588991',
55
+ },
56
+ registry_publish: {
57
+ version: '1.0.0',
58
+ hash: '693e7d08e9f105acd90dd00f8f9282ab3508def16e48237b47bc0ccba7885679',
59
+ },
60
+ dhcp_server: {
61
+ version: '1.0.0',
62
+ hash: '291a247d98bc11b18ab34d40b520bec679b95a031cbd674d513b313559fa3288',
63
+ },
64
+ dns_internal: {
65
+ version: '1.1.0',
66
+ hash: 'af72e861a23f6c81fe9a1a679121b7d3ea87ac5e8167efa20d5d6323010e5fef',
67
+ },
68
+ external_web: {
69
+ version: '1.0.0',
70
+ hash: '7396028ace76938f4e892e6ef8a53450183cdbcf8dff3ce767724bd9b2276dae',
71
+ },
72
+ notification: {
73
+ version: '1.0.0',
74
+ hash: '40629722b397a314d65a448a618d600fc8560c4aae0767dd7324d497d4cc9d41',
75
+ },
76
+ control_plane_vpn: {
77
+ version: '1.0.0',
78
+ hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
79
+ },
80
+ private_web: {
81
+ version: '1.0.0',
82
+ hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
83
+ },
84
+ cross_module_read: {
85
+ version: '1.0.0',
86
+ hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
87
+ },
88
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The gate `capability-contract.ts` claimed for months and did not have.
3
+ *
4
+ * Two independent failures are caught here, and they are different enough to be
5
+ * worth naming separately.
6
+ *
7
+ * SCAN A — a declaration that disagrees with the real table. This is the failure
8
+ * mode `openspec/changes/capability-owned-tables` design D4 warns is *worse than
9
+ * today's*: a capability whose declared shape does not match the database, where
10
+ * the version check still passes, "because versions live in files and the table
11
+ * lives in the database". A missing migration at least says `no such table`.
12
+ * Nothing else in the tree compares the two.
13
+ *
14
+ * SCAN B — an interface or declaration that changed with no version bump.
15
+ * `CAPABILITY_CONTRACT_VERSIONS` is hand-maintained, and `compareProviderToRuntime`
16
+ * plus `celilo system audit` both trust it. A silently-changed shape makes both
17
+ * of them confidently wrong. Comments and formatting are excluded, so a doc-only
18
+ * edit stays a patch.
19
+ *
20
+ * The subject list is DERIVED from `CapabilityRegistry` rather than written out,
21
+ * so a new capability is covered the day it is added rather than the day someone
22
+ * remembers to add it here.
23
+ */
24
+
25
+ import { describe, expect, test } from 'bun:test';
26
+ import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
27
+ import { is } from 'drizzle-orm';
28
+ import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
29
+ import * as dbSchema from '../db/schema';
30
+ import { capabilitySubjects, shapeHash, tablesOf } from './capability-shape';
31
+ import { CAPABILITY_SHAPE_BASELINE } from './capability-shape-baseline';
32
+
33
+ /** Physical table name → the drizzle table the running code declares. */
34
+ function drizzleTables(): Map<string, ReturnType<typeof getTableConfig>> {
35
+ const out = new Map<string, ReturnType<typeof getTableConfig>>();
36
+ for (const value of Object.values(dbSchema)) {
37
+ if (!is(value, SQLiteTable)) continue;
38
+ const config = getTableConfig(value);
39
+ out.set(config.name, config);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ const DRIZZLE_TYPE_TO_DECLARED: Record<string, string> = {
45
+ SQLiteText: 'text',
46
+ SQLiteInteger: 'integer',
47
+ SQLiteBoolean: 'boolean',
48
+ SQLiteTimestamp: 'timestamp',
49
+ };
50
+
51
+ describe('Scan A — a declared table matches the real one', () => {
52
+ test('every declared table exists in db/schema.ts', async () => {
53
+ const tables = drizzleTables();
54
+ const missing: string[] = [];
55
+ for (const [capability, subject] of capabilitySubjects()) {
56
+ for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
57
+ if (!tables.has(decl.table)) missing.push(`${capability}.${key} → ${decl.table}`);
58
+ }
59
+ }
60
+ expect(missing).toEqual([]);
61
+ });
62
+
63
+ test('every declared column exists on the real table, claim included', async () => {
64
+ const tables = drizzleTables();
65
+ const missing: string[] = [];
66
+ for (const [capability, subject] of capabilitySubjects()) {
67
+ for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
68
+ const config = tables.get(decl.table);
69
+ if (!config) continue;
70
+ const present = new Set(config.columns.map((c) => c.name));
71
+ for (const column of [decl.claim.column, ...Object.keys(decl.columns)]) {
72
+ if (!present.has(column)) missing.push(`${capability}.${key}: ${decl.table}.${column}`);
73
+ }
74
+ }
75
+ }
76
+ expect(missing).toEqual([]);
77
+ });
78
+
79
+ /**
80
+ * The half that catches a rename or a retype rather than an absence. A column
81
+ * declared `text` that is really an integer reads as present to the check
82
+ * above, and every consumer of the declaration would then be typed wrong.
83
+ */
84
+ test('every declared column has the declared type and nullability', async () => {
85
+ const tables = drizzleTables();
86
+ const wrong: string[] = [];
87
+ for (const [capability, subject] of capabilitySubjects()) {
88
+ for (const [key, decl] of Object.entries(await tablesOf(subject.file))) {
89
+ const config = tables.get(decl.table);
90
+ if (!config) continue;
91
+ for (const [column, declared] of Object.entries(decl.columns)) {
92
+ const actual = config.columns.find((c) => c.name === column);
93
+ if (!actual) continue;
94
+ const base = DRIZZLE_TYPE_TO_DECLARED[actual.columnType] ?? actual.columnType;
95
+ const expected = declared.endsWith('?') ? declared.slice(0, -1) : declared;
96
+ const nullableDeclared = declared.endsWith('?');
97
+ if (base !== expected) {
98
+ wrong.push(
99
+ `${capability}.${key}: ${decl.table}.${column} is ${base}, declared ${expected}`,
100
+ );
101
+ }
102
+ if (nullableDeclared === actual.notNull) {
103
+ wrong.push(
104
+ `${capability}.${key}: ${decl.table}.${column} nullability disagrees (declared ${declared}, notNull=${actual.notNull})`,
105
+ );
106
+ }
107
+ }
108
+ }
109
+ }
110
+ expect(wrong).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe('Scan B — a shape change carries a version bump', () => {
115
+ test('every capability in the registry has a baseline entry', async () => {
116
+ const missing = [...capabilitySubjects().keys()].filter(
117
+ (name) => !(name in CAPABILITY_SHAPE_BASELINE),
118
+ );
119
+ expect(missing).toEqual([]);
120
+ });
121
+
122
+ /**
123
+ * The fixture records the version its hash was taken at. If the contract has
124
+ * moved since, the baseline describes a shape nobody re-measured — which is
125
+ * how a bump lands without the regenerate that is supposed to accompany it.
126
+ *
127
+ * This does NOT catch the reverse (regenerating without bumping), and cannot:
128
+ * telling a deliberate shape change from an accidental one needs history the
129
+ * gate does not have. That case shows in review as a hash moving while a
130
+ * version stands still, which is why the generator reads versions from the
131
+ * contract rather than inventing them.
132
+ */
133
+ test('every baseline entry was taken at the CURRENT contract version', () => {
134
+ const versions = CAPABILITY_CONTRACT_VERSIONS as Record<string, string>;
135
+ const stale: string[] = [];
136
+ for (const capability of capabilitySubjects().keys()) {
137
+ const recorded = CAPABILITY_SHAPE_BASELINE[capability];
138
+ if (!recorded) continue;
139
+ if (recorded.version !== versions[capability]) {
140
+ stale.push(
141
+ `${capability}: baseline recorded at ${recorded.version}, contract is now ${versions[capability]}. Re-run bun run apps/celilo/scripts/regenerate-capability-shape-baseline.ts`,
142
+ );
143
+ }
144
+ }
145
+ expect(stale).toEqual([]);
146
+ });
147
+
148
+ test('no capability changed shape without moving its baseline', async () => {
149
+ const drifted: string[] = [];
150
+ for (const capability of capabilitySubjects().keys()) {
151
+ const recorded = CAPABILITY_SHAPE_BASELINE[capability];
152
+ if (!recorded) continue;
153
+ const actual = await shapeHash(capability);
154
+ if (actual !== recorded.hash) {
155
+ drifted.push(
156
+ `${capability}: shape changed. Bump CAPABILITY_CONTRACT_VERSIONS.${capability} (currently recorded at ${recorded.version}) and set its baseline hash to ${actual}.`,
157
+ );
158
+ }
159
+ }
160
+ expect(drifted).toEqual([]);
161
+ });
162
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Reading a capability's SHAPE: its interface, and the tables it declares.
3
+ *
4
+ * Extracted from `capability-shape-drift.test.ts` so the gate and the tool that
5
+ * regenerates its fixture compute the same hash from the same code. Two
6
+ * implementations of "what is this capability's shape" would drift, and the one
7
+ * in the GENERATOR drifting is indistinguishable from the gate passing.
8
+ *
9
+ * Same split as `module-script-scan.ts` / `no-hand-built-ssh.test.ts` in this
10
+ * directory, for the same reason.
11
+ */
12
+
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import type { CapabilityTableDeclaration, CapabilityTables } from '@celilo/capabilities';
17
+ import ts from 'typescript';
18
+
19
+ /** Walk up to the repo root (the dir holding both modules/ and apps/). */
20
+ export function repoRoot(): string {
21
+ let dir = import.meta.dir;
22
+ for (let i = 0; i < 8; i++) {
23
+ if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
24
+ dir = resolve(dir, '..');
25
+ }
26
+ throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
27
+ }
28
+
29
+ const CAPABILITIES_SRC = join(repoRoot(), 'packages', 'capabilities', 'src');
30
+
31
+ function parse(file: string): ts.SourceFile {
32
+ return ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true);
33
+ }
34
+
35
+ /**
36
+ * capability name → { interface type name, source file }, read out of
37
+ * `capability-registry.ts` itself.
38
+ */
39
+ export function capabilitySubjects(): Map<string, { typeName: string; file: string }> {
40
+ const registryFile = join(CAPABILITIES_SRC, 'capability-registry.ts');
41
+ const source = parse(registryFile);
42
+
43
+ // `import type { XCapability } from './x'` → XCapability lives in ./x.ts
44
+ const fileOfType = new Map<string, string>();
45
+ for (const statement of source.statements) {
46
+ if (!ts.isImportDeclaration(statement)) continue;
47
+ const bindings = statement.importClause?.namedBindings;
48
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
49
+ const specifier = (statement.moduleSpecifier as ts.StringLiteral).text;
50
+ for (const element of bindings.elements) {
51
+ fileOfType.set(
52
+ element.name.text,
53
+ join(CAPABILITIES_SRC, `${specifier.replace('./', '')}.ts`),
54
+ );
55
+ }
56
+ }
57
+
58
+ const subjects = new Map<string, { typeName: string; file: string }>();
59
+ for (const statement of source.statements) {
60
+ if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'CapabilityRegistry') {
61
+ continue;
62
+ }
63
+ for (const member of statement.members) {
64
+ if (!ts.isPropertySignature(member) || !member.name || !member.type) continue;
65
+ const name = member.name.getText(source);
66
+ const typeName = member.type.getText(source);
67
+ const file = fileOfType.get(typeName);
68
+ if (file) subjects.set(name, { typeName, file });
69
+ }
70
+ }
71
+ return subjects;
72
+ }
73
+
74
+ /** The interface, re-printed from its AST with comments dropped. */
75
+ function printedInterface(file: string, typeName: string): string {
76
+ const source = parse(file);
77
+ const printer = ts.createPrinter({ removeComments: true });
78
+ for (const statement of source.statements) {
79
+ if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) {
80
+ return printer.printNode(ts.EmitHint.Unspecified, statement, source);
81
+ }
82
+ }
83
+ throw new Error(`${typeName} not found in ${file}`);
84
+ }
85
+
86
+ /** Every `* satisfies CapabilityTables` const exported from a capability's module. */
87
+ export async function tablesOf(file: string): Promise<CapabilityTables> {
88
+ const loaded = (await import(file)) as Record<string, unknown>;
89
+ const out: Record<string, CapabilityTableDeclaration> = {};
90
+ for (const [exportName, value] of Object.entries(loaded)) {
91
+ if (!exportName.endsWith('_TABLES') || typeof value !== 'object' || value === null) continue;
92
+ for (const [key, decl] of Object.entries(value as Record<string, unknown>)) {
93
+ out[key] = decl as CapabilityTableDeclaration;
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Order-independent so a reordered literal is not a shape change. */
100
+ function canonical(value: unknown): string {
101
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
102
+ if (value && typeof value === 'object') {
103
+ const entries = Object.entries(value as Record<string, unknown>)
104
+ .map(([k, v]) => `${k}:${canonical(v)}`)
105
+ .sort();
106
+ return `{${entries.join(',')}}`;
107
+ }
108
+ return JSON.stringify(value);
109
+ }
110
+
111
+ export async function shapeHash(capability: string): Promise<string> {
112
+ const subject = capabilitySubjects().get(capability);
113
+ if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`);
114
+ const iface = printedInterface(subject.file, subject.typeName);
115
+ const tables = canonical(await tablesOf(subject.file));
116
+ return createHash('sha256').update(`${iface}\n--tables--\n${tables}`).digest('hex');
117
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The declared coverage and the aspect that does the covering must agree.
3
+ *
4
+ * `lxc-dns-at-birth` splits ownership: terraform owns birth DNS, the
5
+ * base-module aspect owns ongoing DNS. Terraform injects
6
+ * `lifecycle { ignore_changes = [nameserver] }`, so it can never correct the
7
+ * birth value — a zone the aspect does not cover has NO owner for ongoing DNS
8
+ * and its birth list is permanent. That is why the nameserver composition only
9
+ * strips the public resolvers for a zone the deployed provider's aspect covers
10
+ * (design D5d).
11
+ *
12
+ * Core reads that coverage from the provider's CAPABILITY DATA rather than
13
+ * looking up the provider's manifest, because core naming a capability to find
14
+ * its provider is what the module-business gate exists to stop. The cost of
15
+ * that choice is two lists in one manifest instead of one, and drift between
16
+ * them is not cosmetic: a zone listed as covered but absent from the aspect
17
+ * loses its public resolvers with nothing owning what replaces them.
18
+ *
19
+ * So this test is the thing that makes the choice safe. It is the reason the
20
+ * duplication is acceptable.
21
+ */
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { join, resolve } from 'node:path';
26
+ import { parse } from 'yaml';
27
+
28
+ function repoRoot(): string {
29
+ let dir = import.meta.dir;
30
+ for (let i = 0; i < 8; i++) {
31
+ if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
32
+ dir = resolve(dir, '..');
33
+ }
34
+ throw new Error('could not locate repo root');
35
+ }
36
+
37
+ interface ProviderManifest {
38
+ provides?: {
39
+ capabilities?: { name: string; data?: { aspect?: { covered_zones?: string[] } } }[];
40
+ };
41
+ base_module_aspect?: { applicable_zones?: string[] };
42
+ }
43
+
44
+ /** Every module declaring a `dns_internal` capability, with its manifest. */
45
+ function dnsInternalProviders(): { id: string; manifest: ProviderManifest }[] {
46
+ const modulesDir = join(repoRoot(), 'modules');
47
+ const found: { id: string; manifest: ProviderManifest }[] = [];
48
+ for (const id of readdirSync(modulesDir)) {
49
+ const path = join(modulesDir, id, 'manifest.yml');
50
+ if (!existsSync(path)) continue;
51
+ let manifest: ProviderManifest;
52
+ try {
53
+ manifest = parse(readFileSync(path, 'utf-8')) as ProviderManifest;
54
+ } catch {
55
+ continue;
56
+ }
57
+ if (manifest.provides?.capabilities?.some((c) => c.name === 'dns_internal')) {
58
+ found.push({ id, manifest });
59
+ }
60
+ }
61
+ return found;
62
+ }
63
+
64
+ describe('dns_internal providers declare the coverage their aspect actually has', () => {
65
+ const providers = dnsInternalProviders();
66
+
67
+ test('the scan found the providers (sanity — it actually ran)', () => {
68
+ // knot-unbound-internal and technitium. A scan that silently found nothing
69
+ // would make every assertion below vacuously true.
70
+ expect(providers.map((p) => p.id).sort()).toEqual(['knot-unbound-internal', 'technitium']);
71
+ });
72
+
73
+ test.each(dnsInternalProviders().map((p) => [p.id, p] as const))(
74
+ '%s: declared covered_zones equals base_module_aspect.applicable_zones',
75
+ (_id, provider) => {
76
+ const capability = provider.manifest.provides?.capabilities?.find(
77
+ (c) => c.name === 'dns_internal',
78
+ );
79
+ const declared = capability?.data?.aspect?.covered_zones;
80
+ const applicable = provider.manifest.base_module_aspect?.applicable_zones;
81
+
82
+ expect(declared, 'dns_internal.data.aspect.covered_zones is missing').toBeDefined();
83
+ expect(applicable, 'base_module_aspect.applicable_zones is missing').toBeDefined();
84
+ expect([...(declared ?? [])].sort()).toEqual([...(applicable ?? [])].sort());
85
+ },
86
+ );
87
+
88
+ test('no provider claims to cover `external`', () => {
89
+ // An `external` system is a cloud VPS outside the perimeter with no route
90
+ // to a dmz-resident resolver. Its public resolvers are the only working
91
+ // configuration, not a fallback that might mask a split-horizon error, so
92
+ // claiming coverage there would strip the only addresses it can reach.
93
+ for (const provider of providers) {
94
+ const declared =
95
+ provider.manifest.provides?.capabilities?.find((c) => c.name === 'dns_internal')?.data
96
+ ?.aspect?.covered_zones ?? [];
97
+ expect(declared, `${provider.id} claims to cover external`).not.toContain('external');
98
+ }
99
+ });
100
+ });
@@ -33,21 +33,28 @@
33
33
  * never go away.
34
34
  */
35
35
 
36
+ import { allDeclaredTables } from '@celilo/capabilities';
37
+
36
38
  /**
37
39
  * Tables tagged `@owner capability:*` in `db/schema.ts`, and the capability
38
40
  * each belongs to. Scan A asserts the tagged set EQUALS this map — not that it
39
- * is empty. These four exist today (audit T1, T2, T3, T6) and Phase 1 migrates
41
+ * is empty.
42
+ *
43
+ * DERIVED from the capability declarations, not hand-written
44
+ * (openspec/changes/capability-owned-tables task 2.7). It used to be a literal,
45
+ * which made `@owner capability:public_web` a comment checked against another
46
+ * comment. Now the tag is checked against the thing that actually governs the
47
+ * table, so a declared table that nobody tagged and a tagged table nobody
48
+ * declared BOTH fail, and the map cannot drift from the declarations because it
49
+ * no longer exists apart from them. These four exist today (audit T1, T2, T3, T6) and Phase 1 migrates
40
50
  * nothing; an assertion that core holds none would be red the day it landed.
41
51
  *
42
52
  * Tracked by #939. Migrating them keeps the ownership CLAIM in core — dropping
43
53
  * it is the mistake that made `dns_registration_consumers` necessary (#626).
44
54
  */
45
- export const CAPABILITY_OWNED_TABLES: Readonly<Record<string, string>> = {
46
- web_routes: 'public_web',
47
- port_forwards: 'firewall',
48
- trusted_sources: 'firewall',
49
- dns_internal_records: 'dns_internal',
50
- };
55
+ export const CAPABILITY_OWNED_TABLES: Readonly<Record<string, string>> = Object.fromEntries(
56
+ allDeclaredTables().map(({ capability, declaration }) => [declaration.table, capability]),
57
+ );
51
58
 
52
59
  /** One `{file, capability}` pair and how many times core names it. */
53
60
  export interface CapabilityNameRow {
@@ -248,12 +255,6 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
248
255
  count: 2,
249
256
  why: "S16 — two more copies of S15's decision; fixing S15 removes all three (#938)",
250
257
  },
251
- {
252
- file: 'apps/celilo/src/services/public-web-republish.ts',
253
- capability: 'public_web',
254
- count: 1,
255
- why: "S2 — encodes caddy's redeploy behaviour; generalise to a provider-declared re-assert signal (#945)",
256
- },
257
258
  {
258
259
  file: 'apps/celilo/src/services/zone-policy.ts',
259
260
  capability: 'public_web',
@@ -272,6 +273,24 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
272
273
  count: 1,
273
274
  why: 'X3 — reaches capabilitiesMap.dns_internal by name; should read a declared field (X4 is the model) (#945)',
274
275
  },
276
+ {
277
+ file: 'packages/capabilities/src/declared-tables.ts',
278
+ capability: 'dns_internal',
279
+ count: 1,
280
+ why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
281
+ },
282
+ {
283
+ file: 'packages/capabilities/src/declared-tables.ts',
284
+ capability: 'firewall',
285
+ count: 1,
286
+ why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
287
+ },
288
+ {
289
+ file: 'packages/capabilities/src/declared-tables.ts',
290
+ capability: 'public_web',
291
+ count: 1,
292
+ why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
293
+ },
275
294
  {
276
295
  file: 'packages/capabilities/src/capability-contract.ts',
277
296
  capability: 'control_plane_vpn',
@@ -391,11 +410,6 @@ export const SERVICE_FILENAME_BASELINE: readonly ServiceFilenameRow[] = [
391
410
  capability: 'firewall',
392
411
  why: 'S8 — core reaching into one provider implementation (#941)',
393
412
  },
394
- {
395
- file: 'apps/celilo/src/services/public-web-republish.ts',
396
- capability: 'public_web',
397
- why: "S2 — named for one provider's redeploy behaviour (#945)",
398
- },
399
413
  ];
400
414
 
401
415
  export const PROVIDER_LITERAL_BASELINE: readonly ProviderLiteralRow[] = [