@celilo/cli 1.7.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.
@@ -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 {
@@ -266,6 +273,24 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
266
273
  count: 1,
267
274
  why: 'X3 — reaches capabilitiesMap.dns_internal by name; should read a declared field (X4 is the model) (#945)',
268
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
+ },
269
294
  {
270
295
  file: 'packages/capabilities/src/capability-contract.ts',
271
296
  capability: 'control_plane_vpn',